diff --git a/.mailmap b/.mailmap index 54e01b7d..0682f673 100644 --- a/.mailmap +++ b/.mailmap @@ -23,4 +23,7 @@ Ignasi Fosch Christopher Schirner Laura Barber John Cass -Ronald Zielaznicki +Ronald Zielaznicki +Kyle Heyne +Tom Roth +Eric Jahn diff --git a/.travis.yml b/.travis.yml index 2058fcc7..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: diff --git a/AUTHORS b/AUTHORS index 91b71008..a370ce6c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -52,4 +52,18 @@ - John Cass - Laura Barber - Jakab Kristóf -- Ronald Zielaznicki +- Ronald Zielaznicki +- Wojciech Wnętrzak +- Camilo Nova +- Dražen Lučanin +- Naglis Jonaitis +- Kyle Heyne +- Tom Roth +- Mark Greenwood +- Stein Karlsen +- Dejan Prokić +- Eric Jahn +- Mikhail Golubev +- Danilo Bargen +- Bjørnar Snoksrud +- Giorgos Logiotatidis diff --git a/dev-requirements.txt b/dev-requirements.txt index eba66348..809a0038 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -10,9 +10,11 @@ flake8-import-order # Mock dependencies in tests mock +responses # Test runners pytest +pytest-capturelog pytest-cov pytest-xdist tox diff --git a/docs/api/concepts.rst b/docs/api/architecture.rst similarity index 97% rename from docs/api/concepts.rst rename to docs/api/architecture.rst index 9c542777..b0789f49 100644 --- a/docs/api/concepts.rst +++ b/docs/api/architecture.rst @@ -1,8 +1,8 @@ .. _concepts: -************************* -Architecture and concepts -************************* +************ +Architecture +************ The overall architecture of Mopidy is organized around multiple frontends and backends. The frontends use the core API. The core actor makes multiple backends diff --git a/docs/api/audio.rst b/docs/api/audio.rst index 76389fb4..1e86625c 100644 --- a/docs/api/audio.rst +++ b/docs/api/audio.rst @@ -1,8 +1,8 @@ .. _audio-api: -********* -Audio API -********* +********************************* +:mod:`mopidy.audio` --- Audio API +********************************* .. module:: mopidy.audio :synopsis: Thin wrapper around the parts of GStreamer we use diff --git a/docs/api/backends.rst b/docs/api/backend.rst similarity index 96% rename from docs/api/backends.rst rename to docs/api/backend.rst index 5e938357..f7218876 100644 --- a/docs/api/backends.rst +++ b/docs/api/backend.rst @@ -1,8 +1,8 @@ .. _backend-api: -*********** -Backend API -*********** +************************************* +:mod:`mopidy.backend` --- Backend API +************************************* .. module:: mopidy.backend :synopsis: The API implemented by backends diff --git a/docs/api/commands.rst b/docs/api/commands.rst index f0469350..216c4d46 100644 --- a/docs/api/commands.rst +++ b/docs/api/commands.rst @@ -1,8 +1,8 @@ .. _commands-api: -************ -Commands API -************ +*************************************** +:mod:`mopidy.commands` --- Commands API +*************************************** .. automodule:: mopidy.commands :synopsis: Commands API for Mopidy CLI. diff --git a/docs/api/config.rst b/docs/api/config.rst index 8b005a9d..289bda5a 100644 --- a/docs/api/config.rst +++ b/docs/api/config.rst @@ -1,8 +1,8 @@ .. _config-api: -********** -Config API -********** +*********************************** +:mod:`mopidy.config` --- Config API +*********************************** .. automodule:: mopidy.config :synopsis: Config API for config loading and validation diff --git a/docs/api/core.rst b/docs/api/core.rst index 27ab2f57..5f1e406f 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -1,79 +1,253 @@ .. _core-api: -******** -Core API -******** +******************************* +:mod:`mopidy.core` --- Core API +******************************* .. module:: mopidy.core :synopsis: Core API for use by frontends The core API is the interface that is used by frontends like -:mod:`mopidy.http` and :mod:`mopidy.mpd`. The core layer is inbetween the -frontends and the backends. +:mod:`mopidy.http` and :mod:`mopidy.mpd`. The core layer is in between the +frontends and the backends. Don't forget that you will be accessing core +as a Pykka actor. If you are only interested in being notified about changes +in core see :class:`~mopidy.core.CoreListener`. + +.. versionchanged:: 1.1 + All core API calls are now type checked. + +.. versionchanged:: 1.1 + All backend return values are now type checked. .. autoclass:: mopidy.core.Core - :members: + .. attribute:: tracklist -Playback controller -=================== + Manages everything related to the list of tracks we will play. + See :class:`~mopidy.core.TracklistController`. -Manages playback, with actions like play, pause, stop, next, previous, -seek, and volume control. + .. attribute:: playback -.. autoclass:: mopidy.core.PlaybackState - :members: + Manages playback state and the current playing track. + See :class:`~mopidy.core.PlaybackController`. -.. autoclass:: mopidy.core.PlaybackController - :members: + .. attribute:: library + + Manages the music library, e.g. searching and browsing for music. + See :class:`~mopidy.core.LibraryController`. + + .. attribute:: playlists + + Manages stored playlists. See :class:`~mopidy.core.PlaylistsController`. + + .. attribute:: mixer + + Manages volume and muting. See :class:`~mopidy.core.MixerController`. + + .. attribute:: history + + Keeps record of what tracks have been played. + See :class:`~mopidy.core.HistoryController`. + + .. automethod:: get_uri_schemes + + .. automethod:: get_version Tracklist controller ==================== -Manages everything related to the tracks we are currently playing. - .. autoclass:: mopidy.core.TracklistController - :members: + +Manipulating +------------ + +.. automethod:: mopidy.core.TracklistController.add +.. automethod:: mopidy.core.TracklistController.remove +.. automethod:: mopidy.core.TracklistController.clear +.. automethod:: mopidy.core.TracklistController.move +.. automethod:: mopidy.core.TracklistController.shuffle + +Current state +------------- + +.. automethod:: mopidy.core.TracklistController.get_tl_tracks +.. automethod:: mopidy.core.TracklistController.index +.. automethod:: mopidy.core.TracklistController.get_version + +.. automethod:: mopidy.core.TracklistController.get_length +.. automethod:: mopidy.core.TracklistController.get_tracks + +.. automethod:: mopidy.core.TracklistController.slice +.. automethod:: mopidy.core.TracklistController.filter + +Future state +------------ + +.. automethod:: mopidy.core.TracklistController.get_eot_tlid +.. automethod:: mopidy.core.TracklistController.get_next_tlid +.. automethod:: mopidy.core.TracklistController.get_previous_tlid + +.. automethod:: mopidy.core.TracklistController.eot_track +.. automethod:: mopidy.core.TracklistController.next_track +.. automethod:: mopidy.core.TracklistController.previous_track + +Options +------- + +.. automethod:: mopidy.core.TracklistController.get_consume +.. automethod:: mopidy.core.TracklistController.set_consume +.. automethod:: mopidy.core.TracklistController.get_random +.. automethod:: mopidy.core.TracklistController.set_random +.. automethod:: mopidy.core.TracklistController.get_repeat +.. automethod:: mopidy.core.TracklistController.set_repeat +.. automethod:: mopidy.core.TracklistController.get_single +.. automethod:: mopidy.core.TracklistController.set_single -History controller -================== +Playback controller +=================== -Keeps record of what tracks have been played. +.. autoclass:: mopidy.core.PlaybackController -.. autoclass:: mopidy.core.HistoryController - :members: +Playback control +---------------- +.. automethod:: mopidy.core.PlaybackController.play +.. automethod:: mopidy.core.PlaybackController.next +.. automethod:: mopidy.core.PlaybackController.previous +.. automethod:: mopidy.core.PlaybackController.stop +.. automethod:: mopidy.core.PlaybackController.pause +.. automethod:: mopidy.core.PlaybackController.resume +.. automethod:: mopidy.core.PlaybackController.seek -Playlists controller -==================== +Current track +------------- -Manages persistence of playlists. +.. automethod:: mopidy.core.PlaybackController.get_current_tl_track +.. automethod:: mopidy.core.PlaybackController.get_current_track +.. automethod:: mopidy.core.PlaybackController.get_stream_title +.. automethod:: mopidy.core.PlaybackController.get_time_position -.. autoclass:: mopidy.core.PlaylistsController - :members: +Playback states +--------------- +.. automethod:: mopidy.core.PlaybackController.get_state +.. automethod:: mopidy.core.PlaybackController.set_state + +.. class:: mopidy.core.PlaybackState + + .. attribute:: STOPPED + :annotation: = 'stopped' + .. attribute:: PLAYING + :annotation: = 'playing' + .. attribute:: PAUSED + :annotation: = 'paused' Library controller ================== -Manages the music library, e.g. searching for tracks to be added to a playlist. +.. class:: mopidy.core.LibraryController -.. autoclass:: mopidy.core.LibraryController - :members: +.. automethod:: mopidy.core.LibraryController.browse +.. automethod:: mopidy.core.LibraryController.search +.. automethod:: mopidy.core.LibraryController.lookup +.. automethod:: mopidy.core.LibraryController.refresh +.. automethod:: mopidy.core.LibraryController.get_images +.. automethod:: mopidy.core.LibraryController.get_distinct +Playlists controller +==================== + +.. class:: mopidy.core.PlaylistsController + +Fetching +-------- + +.. automethod:: mopidy.core.PlaylistsController.as_list +.. automethod:: mopidy.core.PlaylistsController.get_items +.. automethod:: mopidy.core.PlaylistsController.lookup +.. automethod:: mopidy.core.PlaylistsController.refresh + +Manipulating +------------ + +.. automethod:: mopidy.core.PlaylistsController.create +.. automethod:: mopidy.core.PlaylistsController.save +.. automethod:: mopidy.core.PlaylistsController.delete Mixer controller ================ -Manages volume and muting. +.. class:: mopidy.core.MixerController -.. autoclass:: mopidy.core.MixerController - :members: +.. automethod:: mopidy.core.MixerController.get_mute +.. automethod:: mopidy.core.MixerController.set_mute +.. automethod:: mopidy.core.MixerController.get_volume +.. automethod:: mopidy.core.MixerController.set_volume -Core listener -============= +History controller +================== + +.. class:: mopidy.core.HistoryController + +.. automethod:: mopidy.core.HistoryController.get_history +.. automethod:: mopidy.core.HistoryController.get_length + +Core events +=========== .. autoclass:: mopidy.core.CoreListener :members: + +Deprecated API features +======================= + +.. warning:: + Though these features still work, they are slated to go away in the next + major Mopidy release. + +Core +---- + +.. autoattribute:: mopidy.core.Core.version +.. autoattribute:: mopidy.core.Core.uri_schemes + +TracklistController +------------------- + +.. autoattribute:: mopidy.core.TracklistController.tl_tracks +.. autoattribute:: mopidy.core.TracklistController.tracks +.. autoattribute:: mopidy.core.TracklistController.version +.. autoattribute:: mopidy.core.TracklistController.length + +.. autoattribute:: mopidy.core.TracklistController.consume +.. autoattribute:: mopidy.core.TracklistController.random +.. autoattribute:: mopidy.core.TracklistController.repeat +.. autoattribute:: mopidy.core.TracklistController.single + +PlaylistsController +------------------- + +.. automethod:: mopidy.core.PlaybackController.get_mute +.. automethod:: mopidy.core.PlaybackController.get_volume + +.. autoattribute:: mopidy.core.PlaybackController.current_tl_track +.. autoattribute:: mopidy.core.PlaybackController.current_track +.. autoattribute:: mopidy.core.PlaybackController.state +.. autoattribute:: mopidy.core.PlaybackController.time_position +.. autoattribute:: mopidy.core.PlaybackController.mute +.. autoattribute:: mopidy.core.PlaybackController.volume + +LibraryController +----------------- + +.. automethod:: mopidy.core.LibraryController.find_exact + +PlaybackController +------------------ + +.. automethod:: mopidy.core.PlaylistsController.filter +.. automethod:: mopidy.core.PlaylistsController.get_playlists + +.. autoattribute:: mopidy.core.PlaylistsController.playlists diff --git a/docs/api/ext.rst b/docs/api/ext.rst index 11908920..220c763b 100644 --- a/docs/api/ext.rst +++ b/docs/api/ext.rst @@ -1,8 +1,8 @@ .. _ext-api: -************* -Extension API -************* +********************************** +:mod:`mopidy.ext` -- Extension API +********************************** If you want to learn how to make Mopidy extensions, read :ref:`extensiondev`. diff --git a/docs/api/frontends.rst b/docs/api/frontend.rst similarity index 100% rename from docs/api/frontends.rst rename to docs/api/frontend.rst diff --git a/docs/api/http-server.rst b/docs/api/http-server.rst index 317a77c5..463abf5a 100644 --- a/docs/api/http-server.rst +++ b/docs/api/http-server.rst @@ -25,6 +25,8 @@ For details on how to make a Mopidy extension, see the :ref:`extensiondev` guide. +.. _static-web-client: + Static web client example ========================= diff --git a/docs/api/http.rst b/docs/api/http.rst index 9a7d56bb..f2a50b27 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -4,9 +4,6 @@ HTTP JSON-RPC API ***************** -.. module:: mopidy.http - :synopsis: The HTTP frontend APIs - The :ref:`ext-http` extension makes Mopidy's :ref:`core-api` available using JSON-RPC over HTTP using HTTP POST and WebSockets. We also provide a JavaScript wrapper, called :ref:`Mopidy.js `, around the JSON-RPC over @@ -65,14 +62,9 @@ JSON-RPC 2.0 messages can be recognized by checking for the key named please refer to the `JSON-RPC 2.0 spec `_. -All methods (not attributes) in the :ref:`core-api` is made available through -JSON-RPC calls over the WebSocket. For example, -:meth:`mopidy.core.PlaybackController.play` is available as the JSON-RPC method -``core.playback.play``. - -The core API's attributes is made available through setters and getters. For -example, the attribute :attr:`mopidy.core.PlaybackController.current_track` is -available as the JSON-RPC method ``core.playback.get_current_track``. +All methods in the :ref:`core-api` is made available through JSON-RPC calls +over the WebSocket. For example, :meth:`mopidy.core.PlaybackController.play` is +available as the JSON-RPC method ``core.playback.play``. Example JSON-RPC request:: diff --git a/docs/api/httpclient.rst b/docs/api/httpclient.rst new file mode 100644 index 00000000..85e258c3 --- /dev/null +++ b/docs/api/httpclient.rst @@ -0,0 +1,9 @@ +.. _httpclient-helper: + +************************************************ +:mod:`mopidy.httpclient` --- HTTP Client helpers +************************************************ + +.. automodule:: mopidy.httpclient + :synopsis: HTTP Client helpers for Mopidy its Extensions. + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index 2402186e..d4bd2f61 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -4,26 +4,57 @@ API reference ************* -.. note:: What is public? +.. note:: Only APIs documented here are public and open for use by Mopidy extensions. -.. toctree:: - :glob: +Concepts +======== - concepts +.. toctree:: + + architecture models - backends + + +Basics +====== + +.. toctree:: + core - audio - mixer - frontends - commands + frontend + backend ext - config - zeroconf + + +Web/JavaScript +============== + +.. toctree:: + http-server http js + + +Audio +===== + +.. toctree:: + + audio + mixer + + +Utilities +========= + +.. toctree:: + + commands + config + httpclient + zeroconf diff --git a/docs/api/js.rst b/docs/api/js.rst index 29866d14..6a8e0fcd 100644 --- a/docs/api/js.rst +++ b/docs/api/js.rst @@ -21,9 +21,9 @@ available at: You may need to adjust hostname and port for your local setup. -Thus, if you use Mopidy to host your web client, like described above, you can -load the latest version of Mopidy.js by adding the following script tag to your -HTML file: +Thus, if you use Mopidy to host your web client, like described in +:ref:`static-web-client`, you can load the latest version of Mopidy.js by +adding the following script tag to your HTML file: .. code-block:: html @@ -189,13 +189,10 @@ you've hooked up an errback (more on that a bit later) to the promise returned from the call, the errback will be called with a ``Mopidy.ConnectionError`` instance. -All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core -API attributes is *not* available, but that shouldn't be a problem as we've -added (undocumented) getters and setters for all of them, so you can access the -attributes as well from JavaScript. For example, the -:attr:`mopidy.core.PlaybackController.state` attribute is available in -JSON-RPC as the method ``core.playback.get_state`` and in Mopidy.js as -``mopidy.playback.getState()``. +All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. For +example, the :meth:`mopidy.core.PlaybackController.get_state` method is +available in JSON-RPC as the method ``core.playback.get_state`` and in +Mopidy.js as ``mopidy.playback.getState()``. Both the WebSocket API and the JavaScript API are based on introspection of the core Python API. Thus, they will always be up to date and immediately reflect @@ -218,8 +215,7 @@ by looking at the method's ``description`` and ``params`` attributes: JSON-RPC 2.0 limits method parameters to be sent *either* by-position or by-name. Combinations of both, like we're used to from Python, isn't supported -by JSON-RPC 2.0. To further limit this, Mopidy.js currently only supports -passing parameters by-position. +by JSON-RPC 2.0. Obviously, you'll want to get a return value from many of your method calls. Since everything is happening across the WebSocket and maybe even across the @@ -272,8 +268,9 @@ passing it as the second argument to ``done()``: .done(printCurrentTrack, console.error.bind(console)); If you don't hook up an error handler function and never call ``done()`` on the -promise object, when.js will log warnings to the console that you have -unhandled errors. In general, unhandled errors will not go silently missing. +promise object, warnings will be logged to the console complaining that you +have 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 diff --git a/docs/api/mixer.rst b/docs/api/mixer.rst index 6f02e3c9..272bf3c7 100644 --- a/docs/api/mixer.rst +++ b/docs/api/mixer.rst @@ -1,8 +1,8 @@ .. _mixer-api: -*************** -Audio mixer API -*************** +*************************************** +:mod:`mopidy.mixer` --- Audio mixer API +*************************************** .. module:: mopidy.mixer :synopsis: The audio mixer API diff --git a/docs/api/models.rst b/docs/api/models.rst index 23a08002..27c7647f 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -1,14 +1,14 @@ -*********** -Data models -*********** +************************************ +:mod:`mopidy.models` --- Data models +************************************ These immutable data models are used for all data transfer within the Mopidy backends and between the backends and the MPD frontend. All fields are optional and immutable. In other words, they can only be set through the class -constructor during instance creation. +constructor during instance creation. Additionally fields are type checked. If you want to modify a model, use the -:meth:`~mopidy.models.ImmutableObject.copy` method. It accepts keyword +:meth:`~mopidy.models.ImmutableObject.replace` method. It accepts keyword arguments for the parts of the model you want to change, and copies the rest of the data from the model you call it on. Example:: @@ -16,7 +16,7 @@ the data from the model you call it on. Example:: >>> track1 = Track(name='Christmas Carol', length=171) >>> track1 Track(artists=[], length=171, name='Christmas Carol') - >>> track2 = track1.copy(length=37) + >>> track2 = track1.replace(length=37) >>> track2 Track(artists=[], length=37, name='Christmas Carol') >>> track1 @@ -75,7 +75,31 @@ Data model helpers ================== .. autoclass:: mopidy.models.ImmutableObject + :members: + +.. autoclass:: mopidy.models.ValidatedImmutableObject + :members: replace + +Data model (de)serialization +---------------------------- + +.. autofunction:: mopidy.models.model_json_decoder .. autoclass:: mopidy.models.ModelJSONEncoder -.. autofunction:: mopidy.models.model_json_decoder +Data model field types +---------------------- + +.. autoclass:: mopidy.models.fields.Field + +.. autoclass:: mopidy.models.fields.String + +.. autoclass:: mopidy.models.fields.Identifier + +.. autoclass:: mopidy.models.fields.URI + +.. autoclass:: mopidy.models.fields.Date + +.. autoclass:: mopidy.models.fields.Integer + +.. autoclass:: mopidy.models.fields.Collection diff --git a/docs/api/zeroconf.rst b/docs/api/zeroconf.rst index 7cdd93f0..552c5771 100644 --- a/docs/api/zeroconf.rst +++ b/docs/api/zeroconf.rst @@ -1,8 +1,8 @@ .. _zeroconf-api: -************ -Zeroconf API -************ +*************************************** +:mod:`mopidy.zeroconf` --- Zeroconf API +*************************************** .. module:: mopidy.zeroconf :synopsis: Helper for publishing of services on Zeroconf diff --git a/docs/changelog.rst b/docs/changelog.rst index c966cc45..1bc24394 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,219 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.1.0 (2015-08-09) +=================== + +Mopidy 1.1 is here! + +Since the release of 1.0, we've closed or merged approximately 55 issues and +pull requests through about 400 commits by a record high 20 extraordinary +people, including 14 newcomers. That's less issues and commits than in the 1.0 +release, but even more contributors, and a doubling of the number of newcomers. +Thanks to :ref:`everyone ` who has :ref:`contributed `! + +As we promised with the release of Mopidy 1.0, any extension working with +Mopidy 1.0 should continue working with all Mopidy 1.x releases. However, this +release brings a lot stronger enforcement of our documented APIs. If an +extension doesn't use the APIs properly, it may no longer work. The advantage +of this change is that Mopidy is now more robust against errors in extensions, +and also provides vastly better error messages when extensions misbehave. This +should make it easier to create quality extensions. + +The major features of Mopidy 1.1 are: + +- Validation of the arguments to all core API methods, as well as all responses + from backends and all data model attributes. + +- New bundled backend, Mopidy-File. It is similar to Mopidy-Local, but allows + you to browse and play music from local disk without running a scan to index + the music first. The drawback is that it doesn't support searching. + +- The Mopidy-MPD server should now be up to date with the 0.19 version of the + MPD protocol. + +Dependencies +------------ + +- Mopidy now requires Requests. + +- Heads up: 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 +-------- + +- **Deprecated:** Calling the following methods with ``kwargs`` is being + deprecated. (PR: :issue:`1090`) + + - :meth:`mopidy.core.LibraryController.search` + - :meth:`mopidy.core.PlaylistsController.filter` + - :meth:`mopidy.core.TracklistController.filter` + - :meth:`mopidy.core.TracklistController.remove` + +- Updated core controllers to handle backend exceptions in all calls that rely + on multiple backends. (Issue: :issue:`667`) + +- Update core methods to do strict input checking. (Fixes: :issue:`700`) + +- Add ``tlid`` alternatives to methods that take ``tl_track`` and also add + ``get_{eot,next,previous}_tlid`` methods as light weight alternatives to the + ``tl_track`` versions of the calls. (Fixes: :issue:`1131` PR: :issue:`1136`, + :issue:`1140`) + +- Add :meth:`mopidy.core.PlaybackController.get_current_tlid`. + (Part of: :issue:`1137`) + +- Update core to handle backend crashes and bad data. (Fixes: :issue:`1161`) + +- Add :confval:`core/max_tracklist_length` config and limitation. (Fixes: + :issue:`997` PR: :issue:`1225`) + +- Added ``playlist_deleted`` event. (Fixes: :issue:`996`) + +Models +------ + +- Added type checks and other sanity checks to model construction and + serialization. (Fixes: :issue:`865`) + +- Memory usage for models has been greatly improved. We now have a lower + overhead per instance by using slots, interned identifiers and automatically + reuse instances. For the test data set this was developed against, a library + of ~14.000 tracks, went from needing ~75MB to ~17MB. (Fixes: :issue:`348`) + +- Added :attr:`mopidy.models.Artist.sortname` field that is mapped to + ``musicbrainz-sortname`` tag. (Fixes: :issue:`940`) + +Configuration +------------- + +- Add new configurations to set base directories to be used by Mopidy and + Mopidy extensions: :confval:`core/cache_dir`, :confval:`core/config_dir`, and + :confval:`core/data_dir`. (Fixes: :issue:`843`, PR: :issue:`1232`) + +Extension support +----------------- + +- Add new methods to :class:`~mopidy.ext.Extension` class for getting cache, + config and data directories specific to your extension: + + - :meth:`mopidy.ext.Extension.get_cache_dir` + - :meth:`mopidy.ext.Extension.get_config_dir` + - :meth:`mopidy.ext.Extension.get_data_dir` + + Extensions should use these methods so that the correct directories are used + both when Mopidy is run by a regular user and when run as a system service. + (Fixes: :issue:`843`, PR: :issue:`1232`) + +- Add :func:`mopidy.httpclient.format_proxy` and + :func:`mopidy.httpclient.format_user_agent`. (Part of: :issue:`1156`) + +- It is now possible to import :mod:`mopidy.backends` without having GObject or + GStreamer installed. In other words, a lot of backend extensions should now + be able to run tests in a virtualenv with global site-packages disabled. This + removes a lot of potential error sources. (Fixes: :issue:`1068`, PR: + :issue:`1115`) + +Local backend +------------- + +- Filter out :class:`None` from + :meth:`~mopidy.backend.LibraryProvider.get_distinct` results. All returned + results should be strings. (Fixes: :issue:`1202`) + +Stream backend +-------------- + +- Move stream playlist parsing from GStreamer to the stream backend. (Fixes: + :issue:`671`) + +File backend +------------ + +The :ref:`Mopidy-File ` backend is a new bundled backend. It is +similar to Mopidy-Local since it works with local files, but it differs in a +few key ways: + +- Mopidy-File lets you browse your media files by their file hierarchy. + +- It supports multiple media directories, all exposed under the "Files" + directory when you browse your library with e.g. an MPD client. + +- There is no index of the media files, like the JSON or SQLite files used by + Mopidy-Local. Thus no need to scan the music collection before starting + Mopidy. Everything is read from the file system when needed and changes to + the file system is thus immediately visible in Mopidy clients. + +- Because there is no index, there is no support for search. + +Our long term plan is to keep this very simple file backend in Mopidy, as it +has a well defined and limited scope, while splitting the more feature rich +Mopidy-Local extension out to an independent project. (Fixes: :issue:`1004`, +PR: :issue:`1207`) + +M3U backend +----------- + +- Support loading UTF-8 encoded M3U files with the ``.m3u8`` file extension. + (PR: :issue:`1193`) + +MPD frontend +------------ + +- The MPD command ``count`` now ignores tracks with no length, which would + previously cause a :exc:`TypeError`. (PR: :issue:`1192`) + +- Concatenate multiple artists, composers and performers using the "A;B" format + instead of "A, B". This is a part of updating our protocol implementation to + match MPD 0.19. (PR: :issue:`1213`) + +- Add "not implemented" skeletons of new commands in the MPD protocol version + 0.19: + + - Current playlist: + + - ``rangeid`` + - ``addtagid`` + - ``cleartagid`` + + - Mounts and neighbors: + + - ``mount`` + - ``unmount`` + - ``listmounts`` + - ``listneighbors`` + + - Music DB: + + - ``listfiles`` + +- Track data now include the ``Last-Modified`` field if set on the track model. + (Fixes: :issue:`1218`, PR: :issue:`1219`) + +- Implement ``tagtypes`` MPD command. (PR: :issue:`1235`) + +- Exclude empty tags fields from metadata output. (Fixes: :issue:`1045`, PR: + :issue:`1235`) + +- Implement protocol extensions to output Album URIs and Album Images when + outputting track data to clients. (PR: :issue:`1230`) + +- The MPD commands ``lsinfo`` and ``listplaylists`` are now implemented using + the :meth:`~mopidy.core.PlaylistsController.as_list` method, which retrieves + a lot less data and is thus much faster than the deprecated + :meth:`~mopidy.core.PlaylistsController.get_playlists`. The drawback is that + the ``Last-Modified`` timestamp is not available through this method, and the + timestamps in the MPD command responses are now always set to the current + time. + +Internal changes +---------------- + +- Tests have been cleaned up to stop using deprecated APIs where feasible. + (Partial fix: :issue:`1083`, PR: :issue:`1090`) + + v1.0.8 (2015-07-22) =================== diff --git a/docs/conf.py b/docs/conf.py index ec74fcbe..cc760720 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 @@ -46,7 +47,6 @@ class Mock(object): return Mock() MOCK_MODULES = [ - 'cherrypy', 'dbus', 'dbus.mainloop', 'dbus.mainloop.glib', @@ -60,12 +60,6 @@ MOCK_MODULES = [ 'pykka.actor', 'pykka.future', 'pykka.registry', - 'pylast', - 'ws4py', - 'ws4py.messaging', - 'ws4py.server', - 'ws4py.server.cherrypyserver', - 'ws4py.websocket', ] for mod_name in MOCK_MODULES: sys.modules[mod_name] = Mock() @@ -101,7 +95,7 @@ master_doc = 'index' project = 'Mopidy' copyright = '2009-2015, Stein Magnus Jodal and contributors' -from mopidy.utils.versioning import get_version +from mopidy.internal.versioning import get_version release = get_version() version = '.'.join(release.split('.')[:2]) diff --git a/docs/config.rst b/docs/config.rst index 46b15635..26304176 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -57,6 +57,48 @@ Core configuration values Mopidy's core has the following configuration values that you can change. + +Core configuration +------------------ + +.. confval:: core/cache_dir + + Path to base directory for storing cached data. + + When running Mopidy as a regular user, this should usually be + ``$XDG_CACHE_DIR/mopidy``, i.e. :file:`~/.cache/mopidy`. + + When running Mopidy as a system service, this should usually be + :file:`/var/cache/mopidy`. + +.. confval:: core/config_dir + + Path to base directory for config files. + + When running Mopidy as a regular user, this should usually be + ``$XDG_CONFIG_DIR/mopidy``, i.e. :file:`~/.config/mopidy`. + + When running Mopidy as a system service, this should usually be + :file:`/etc/mopidy`. + +.. confval:: core/data_dir + + Path to base directory for persistent data files. + + When running Mopidy as a regular user, this should usually be + ``$XDG_DATA_DIR/mopidy``, i.e. :file:`~/.local/share/mopidy`. + + When running Mopidy as a system service, this should usually be + :file:`/var/lib/mopidy`. + +.. confval:: core/max_tracklist_length + + Max length of the tracklist. Defaults to 10000. + + The original MPD server only supports 10000 tracks in the tracklist. Some + MPD clients will crash if this limit is exceeded. + + Audio configuration ------------------- diff --git a/docs/devenv.rst b/docs/devenv.rst index c67426f7..c00e6050 100644 --- a/docs/devenv.rst +++ b/docs/devenv.rst @@ -325,13 +325,6 @@ 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: diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 17e2a7ca..5f578e6f 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -57,6 +57,28 @@ Provides a backend for browsing the Internet radio channels from the `Dirble `_ directory. +Mopidy-dLeyna +============= + +https://github.com/tkem/mopidy-dleyna + +Provides a backend for playing music from Digital Media Servers using +the `dLeyna `_ D-Bus interface. + +Mopidy-File +=========== + +Bundled with Mopidy. See :ref:`ext-file`. + +Mopidy-Grooveshark +================== + +https://github.com/camilonova/mopidy-grooveshark + +Provides a backend for playing music from `Grooveshark +`_. + + Mopidy-GMusic ============= diff --git a/docs/ext/file.rst b/docs/ext/file.rst new file mode 100644 index 00000000..d31f53fd --- /dev/null +++ b/docs/ext/file.rst @@ -0,0 +1,47 @@ +.. _ext-file: + +************ +Mopidy-File +************ + +Mopidy-File is an extension for playing music from your local music archive. +It is bundled with Mopidy and enabled by default. +It allows you to browse through your local file system. +Only files that are considered playable will be shown. + +This backend handles URIs starting with ``file:``. + + +Configuration +============= + +See :ref:`config` for general help on configuring Mopidy. + +.. literalinclude:: ../../mopidy/file/ext.conf + :language: ini + +.. confval:: file/enabled + + If the file extension should be enabled or not. + +.. confval:: file/media_dirs + + A list of directories to be browsable. + Optionally the path can be followed by ``|`` and a name that will be shown for that path. + +.. confval:: file/show_dotfiles + + Whether to show hidden files and directories that start with a dot. + Default is false. + +.. confval:: file/follow_symlinks + + Whether to follow symbolic links found in :confval:`files/media_dir`. + Directories and files that are outside the configured directories will not be shown. + Default is false. + +.. confval:: file/metadata_timeout + + Number of milliseconds before giving up scanning a file and moving on to + the next file. Reducing the value might speed up the directory listing, + but can lead to some tracks not being shown. diff --git a/docs/ext/mopster.png b/docs/ext/mopster.png new file mode 100644 index 00000000..2855c4cf Binary files /dev/null and b/docs/ext/mopster.png differ diff --git a/docs/ext/web.rst b/docs/ext/web.rst index f6a2110a..bf29bf72 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -159,6 +159,21 @@ A web extension for changing settings. Used by the Pi MusicBox distribution for Raspberry Pi, but also usable for other projects. +Mopster +======= + +https://github.com/cowbell/mopster + +Simple web client hosted online written in Ember.js and styled using basic +Bootstrap by Wojciech Wnętrzak. + +.. image:: /ext/mopster.png + :width: 1275 + :height: 628 + +To use, just visit http://mopster.cowbell-labs.com/. + + Other web clients ================= diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index a2a5f463..77ce7cde 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -6,7 +6,7 @@ Extension development Mopidy started as simply an MPD server that could play music from Spotify. Early on, Mopidy got multiple "frontends" to expose Mopidy to more than just MPD -clients: for example the scrobbler frontend that scrobbles your listening +clients: for example the scrobbler frontend that scrobbles your listening history to your Last.fm account, the MPRIS frontend that integrates Mopidy into the Ubuntu Sound Menu, and the HTTP server and JavaScript player API making web based Mopidy clients possible. In Mopidy 0.9 we added support for multiple @@ -75,10 +75,10 @@ the readme of `cookiecutter-mopidy-ext Example README.rst ================== -The README file should quickly explain what the extension does, how to install -it, and how to configure it. It should also contain a link to a tarball of the -latest development version of the extension. It's important that this link ends -with ``#egg=Mopidy-Something-dev`` for installation using +The README file should quickly explain what the extension does, how to install +it, and how to configure it. It should also contain a link to a tarball of the +latest development version of the extension. It's important that this link ends +with ``#egg=Mopidy-Something-dev`` for installation using ``pip install Mopidy-Something==dev`` to work. .. code-block:: rst @@ -230,8 +230,8 @@ The root of your Python package should have an ``__version__`` attribute with a class named ``Extension`` which inherits from Mopidy's extension base class, :class:`mopidy.ext.Extension`. This is the class referred to in the ``entry_points`` part of ``setup.py``. Any imports of other files in your -extension, outside of Mopidy and it's core requirements, should be kept inside -methods. This ensures that this file can be imported without raising +extension, outside of Mopidy and it's core requirements, should be kept inside +methods. This ensures that this file can be imported without raising :exc:`ImportError` exceptions for missing dependencies, etc. The default configuration for the extension is defined by the @@ -245,7 +245,7 @@ change them. The exception is if the config value has security implications; in that case you should default to the most secure configuration. Leave any configurations that don't have meaningful defaults blank, like ``username`` and ``password``. In the example below, we've chosen to maintain the default -config as a separate file named ``ext.conf``. This makes it easy to include the +config as a separate file named ``ext.conf``. This makes it easy to include the default config in documentation without duplicating it. This is ``mopidy_soundspot/__init__.py``:: @@ -413,11 +413,11 @@ examples, see the :ref:`http-server-api` docs or explore with Running an extension ==================== -Once your extension is ready to go, to see it in action you'll need to register -it with Mopidy. Typically this is done by running ``python setup.py install`` -from your extension's Git repo root directory. While developing your extension -and to avoid doing this every time you make a change, you can instead run -``python setup.py develop`` to effectively link Mopidy directly with your +Once your extension is ready to go, to see it in action you'll need to register +it with Mopidy. Typically this is done by running ``python setup.py install`` +from your extension's Git repo root directory. While developing your extension +and to avoid doing this every time you make a change, you can instead run +``python setup.py develop`` to effectively link Mopidy directly with your development files. @@ -434,9 +434,12 @@ Use of Mopidy APIs ================== When writing an extension, you should only use APIs documented at -:ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.utils`, may change at -any time and are not something extensions should use. +:ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.internal`, may change +at any time and are not something extensions should use. +Mopidy performs type checking to help catch extension bugs. This applies to +both frontend calls into core and return values from backends. Additionally +model fields always get validated to further guard against bad data. Logging in extensions ===================== @@ -471,3 +474,76 @@ Is much better than:: If you want to turn on debug logging for your own extension, but not for everything else due to the amount of noise, see the docs for the :confval:`loglevels/*` config section. + + +Making HTTP requests from extensions +==================================== + +Many Mopidy extensions need to make HTTP requests to use some web API. Here's a +few recommendations to those extensions. + +Proxies +------- + +If you make HTTP requests please make sure to respect the :ref:`proxy configs +`, so that all the requests you make go through the proxy +configured by the Mopidy user. To make this easier for extension developers, +the helper function :func:`mopidy.httpclient.format_proxy` was added in Mopidy +1.1. This function returns the proxy settings `formatted the way Requests +expects `__. + +User-Agent strings +------------------ + +When you make HTTP requests, it's helpful for debugging and usage analysis if +the client identifies itself with a proper User-Agent string. In Mopidy 1.1, we +added the helper function :func:`mopidy.httpclient.format_user_agent`. Here's +an example of how to use it:: + + >>> from mopidy import httpclient + >>> import mopidy_soundspot + >>> httpclient.format_user_agent('%s/%s' % ( + ... mopidy_soundspot.Extension.dist_name, mopidy_soundspot.__version__)) + u'Mopidy-SoundSpot/2.0.0 Mopidy/1.0.7 Python/2.7.10' + +Example using Requests sessions +------------------------------- + +Most Mopidy extensions that make HTTP requests use the `Requests +`_ library to do so. When using Requests, the +most convenient way to make sure the proxy and User-Agent header is set +properly is to create a Requests session object and use that object to make all +your HTTP requests:: + + from mopidy import httpclient + + import requests + + import mopidy_soundspot + + + def get_requests_session(proxy_config, user_agent): + proxy = httpclient.format_proxy(proxy_config) + full_user_agent = httpclient.format_user_agent(user_agent) + + session = requests.Session() + session.proxies.update({'http': proxy, 'https': proxy}) + session.headers.update({'user-agent': full_user_agent}) + + return session + + + # ``mopidy_config`` is the config object passed to your frontend/backend + # constructor + session = get_requests_session( + proxy_config=mopidy_config['proxy'], + user_agent='%s/%s' % ( + mopidy_soundspot.Extension.dist_name, + mopidy_soundspot.__version__)) + + response = session.get('http://example.com') + # Now do something with ``response`` and/or make further requests using the + # ``session`` object. + +For further details, see Requests' docs on `session objects +`__. diff --git a/docs/glossary.rst b/docs/glossary.rst index 19c799d4..5247ba97 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -6,7 +6,7 @@ Glossary backend A part of Mopidy providing music library, playlist storage and/or - playback capability to the :term:`core`. Mopidy have a backend for each + playback capability to the :term:`core`. Mopidy has a backend for each music store or music service it supports. See :ref:`backend-api` for details. diff --git a/docs/index.rst b/docs/index.rst index 3a2998d5..9085024a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -96,6 +96,7 @@ Extensions :maxdepth: 2 ext/local + ext/file ext/m3u ext/stream ext/http diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index 3c70340c..c8793496 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -173,3 +173,20 @@ More info about this issue can be found in `this post Please note that if you're running Xbian or another XBMC distribution these instructions might vary for your system. + + +Appendix C: Installation on XBian +================================= + +Similar to the Raspbmc issue outlined in Appendix B, it's not possible to +install Mopidy on XBian without first resolving a dependency problem between +``gstreamer0.10-plugins-good`` and ``libtag1c2a``. More information can be +found in `this post +`_. + +Run the following commands to remedy this and then install Mopidy as normal:: + + cd /tmp + wget http://apt.xbian.org/pool/stable/rpi-wheezy/l/libtag1c2a/libtag1c2a_1.7.2-1_armhf.deb + sudo dpkg -i libtag1c2a_1.7.2-1_armhf.deb + rm libtag1c2a_1.7.2-1_armhf.deb diff --git a/docs/modules/local.rst b/docs/modules/local.rst index 31ca6498..395e8802 100644 --- a/docs/modules/local.rst +++ b/docs/modules/local.rst @@ -1,9 +1,23 @@ -************************************ -:mod:`mopidy.local` -- Local backend -************************************ +************************************* +:mod:`mopidy.local` --- Local backend +************************************* For details on how to use Mopidy's local backend, see :ref:`ext-local`. .. automodule:: mopidy.local :synopsis: Local backend + + +Local library API +================= + +.. autoclass:: mopidy.local.Library + :members: + + +Translation utils +================= + +.. automodule:: mopidy.local.translator + :synopsis: Translators for local library extensions :members: diff --git a/docs/modules/mpd.rst b/docs/modules/mpd.rst index 1826e535..7beadfdd 100644 --- a/docs/modules/mpd.rst +++ b/docs/modules/mpd.rst @@ -1,6 +1,6 @@ -******************************* -:mod:`mopidy.mpd` -- MPD server -******************************* +******************************** +:mod:`mopidy.mpd` --- MPD server +******************************** For details on how to use Mopidy's MPD server, see :ref:`ext-mpd`. @@ -71,6 +71,14 @@ Current playlist :members: +Mounts and neighbors +-------------------- + +.. automodule:: mopidy.mpd.protocol.mount + :synopsis: MPD protocol: mounts and neighbors + :members: + + Music database -------------- diff --git a/docs/releasing.rst b/docs/releasing.rst index 8a12cf7d..4c2d8373 100644 --- a/docs/releasing.rst +++ b/docs/releasing.rst @@ -47,8 +47,7 @@ Creating releases #. Push to GitHub:: - git push - git push --tags + git push --follow-tags #. Upload the previously built and tested sdist and bdist_wheel packages to PyPI:: diff --git a/docs/running.rst b/docs/running.rst index 2c7ced21..e329ccaa 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -13,6 +13,20 @@ When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to accept connections by any MPD client. Check out our non-exhaustive :doc:`/clients/mpd` list to find recommended clients. +Updating the library +==================== + +To update the library, e.g. after audio files have changed, run:: + + mopidy local scan + +Afterwards, to refresh the library (which is for now only available +through the API) it is necessary to run:: + + curl -d '{"jsonrpc": "2.0", "id": 1, "method": "core.library.refresh"}' http://localhost:6680/mopidy/rpc + +This makes the changes in the library visible to the clients. + Stopping Mopidy =============== diff --git a/docs/sponsors.rst b/docs/sponsors.rst index 67aef554..dc94aa6f 100644 --- a/docs/sponsors.rst +++ b/docs/sponsors.rst @@ -20,12 +20,6 @@ for free. We use their services for the following sites: - Mailgun for sending emails from the Discourse forum. -- Hosting of the Jenkins CI server at https://ci.mopidy.com. - -- Hosting of a Linux worker for https://ci.mopidy.com. - -- Hosting of a Windows worker for https://ci.mopidy.com. - - CDN hosting at http://dl.mopidy.com, which is used to distribute Pi Musicbox images. diff --git a/mopidy/__init__.py b/mopidy/__init__.py index e9f2e6c3..40308a53 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -2,7 +2,6 @@ from __future__ import absolute_import, print_function, unicode_literals import platform import sys -import textwrap import warnings @@ -11,23 +10,8 @@ if not (2, 7) <= sys.version_info < (3,): 'ERROR: Mopidy requires Python 2.7, but found %s.' % platform.python_version()) -try: - import gobject # noqa -except ImportError: - print(textwrap.dedent(""" - ERROR: The gobject Python package was not found. - - Mopidy requires GStreamer (and GObject) to work. These are C libraries - with a number of dependencies themselves, and cannot be installed with - the regular Python tools like pip. - - Please see http://docs.mopidy.com/en/latest/installation/ for - instructions on how to install the required dependencies. - """)) - raise - warnings.filterwarnings('ignore', 'could not open display') -__version__ = '1.0.8' +__version__ = '1.1.0' diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 96e10e18..ee359268 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -4,8 +4,23 @@ import logging import os import signal import sys +import textwrap + +try: + import gobject # noqa +except ImportError: + print(textwrap.dedent(""" + ERROR: The gobject Python package was not found. + + Mopidy requires GStreamer (and GObject) to work. These are C libraries + with a number of dependencies themselves, and cannot be installed with + the regular Python tools like pip. + + Please see http://docs.mopidy.com/en/latest/installation/ for + instructions on how to install the required dependencies. + """)) + raise -import gobject gobject.threads_init() try: @@ -26,7 +41,7 @@ sys.argv[1:] = [] from mopidy import commands, config as config_lib, ext -from mopidy.utils import encoding, log, path, process, versioning +from mopidy.internal import encoding, log, path, process, versioning logger = logging.getLogger(__name__) @@ -51,21 +66,23 @@ def main(): root_cmd.add_child('config', config_cmd) root_cmd.add_child('deps', deps_cmd) - installed_extensions = ext.load_extensions() + extensions_data = ext.load_extensions() - for extension in installed_extensions: - ext_cmd = extension.get_command() - if ext_cmd: - ext_cmd.set(extension=extension) - root_cmd.add_child(extension.ext_name, ext_cmd) + for data in extensions_data: + if data.command: # TODO: check isinstance? + data.command.set(extension=data.extension) + root_cmd.add_child(data.extension.ext_name, data.command) args = root_cmd.parse(mopidy_args) - create_file_structures_and_config(args, installed_extensions) + create_file_structures_and_config(args, extensions_data) check_old_locations() config, config_errors = config_lib.load( - args.config_files, installed_extensions, args.config_overrides) + args.config_files, + [d.config_schema for d in extensions_data], + [d.config_defaults for d in extensions_data], + args.config_overrides) verbosity_level = args.base_verbosity_level if args.verbosity_level: @@ -75,8 +92,11 @@ def main(): extensions = { 'validate': [], 'config': [], 'disabled': [], 'enabled': []} - for extension in installed_extensions: - if not ext.validate_extension(extension): + for data in extensions_data: + extension = data.extension + + # TODO: factor out all of this to a helper that can be tested + if not ext.validate_extension_data(data): config[extension.ext_name] = {'enabled': False} config_errors[extension.ext_name] = { 'enabled': 'extension disabled by self check.'} @@ -94,12 +114,13 @@ def main(): else: extensions['enabled'].append(extension) - log_extension_info(installed_extensions, extensions['enabled']) + log_extension_info([d.extension for d in extensions_data], + extensions['enabled']) # Config and deps commands are simply special cased for now. if args.command == config_cmd: - return args.command.run( - config, config_errors, installed_extensions) + schemas = [d.config_schema for d in extensions_data] + return args.command.run(config, config_errors, schemas) elif args.command == deps_cmd: return args.command.run() @@ -119,10 +140,19 @@ def main(): return 1 for extension in extensions['enabled']: - extension.setup(registry) + try: + extension.setup(registry) + except Exception: + # TODO: would be nice a transactional registry. But sadly this + # is a bit tricky since our current API is giving out a mutable + # list. We might however be able to replace this with a + # collections.Sequence to provide a RO view. + logger.exception('Extension %s failed during setup, this might' + ' have left the registry in a bad state.', + extension.ext_name) # Anything that wants to exit after this point must use - # mopidy.utils.process.exit_process as actors can have been started. + # mopidy.internal.process.exit_process as actors can have been started. try: return args.command.run(args, proxied_config) except NotImplementedError: @@ -136,7 +166,7 @@ def main(): raise -def create_file_structures_and_config(args, extensions): +def create_file_structures_and_config(args, extensions_data): path.get_or_create_dir(b'$XDG_DATA_DIR/mopidy') path.get_or_create_dir(b'$XDG_CONFIG_DIR/mopidy') @@ -146,7 +176,7 @@ def create_file_structures_and_config(args, extensions): return try: - default = config_lib.format_initial(extensions) + default = config_lib.format_initial(extensions_data) path.get_or_create_file(config_file, mkdir=False, content=default) logger.info('Initialized %s with default config', config_file) except IOError as error: diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index fcd1e233..60e88a9d 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -13,10 +13,10 @@ import gst.pbutils # noqa import pykka from mopidy import exceptions -from mopidy.audio import playlists, utils +from mopidy.audio import icy, utils from mopidy.audio.constants import PlaybackState from mopidy.audio.listener import AudioListener -from mopidy.utils import process +from mopidy.internal import deprecation, process logger = logging.getLogger(__name__) @@ -26,8 +26,7 @@ logger = logging.getLogger(__name__) # set_state on a pipeline. gst_logger = logging.getLogger('mopidy.audio.gst') -playlists.register_typefinders() -playlists.register_elements() +icy.register() _GST_STATE_MAPPING = { gst.STATE_PLAYING: PlaybackState.PLAYING, @@ -36,7 +35,9 @@ _GST_STATE_MAPPING = { class _Signals(object): + """Helper for tracking gobject signal registrations""" + def __init__(self): self._ids = {} @@ -65,7 +66,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() @@ -132,6 +135,7 @@ 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, 'outputs') @@ -202,6 +206,7 @@ class SoftwareMixer(object): class _Handler(object): + def __init__(self, audio): self._audio = audio self._element = None @@ -370,6 +375,7 @@ class _Handler(object): # TODO: create a player class which replaces the actors internals class Audio(pykka.ThreadingActor): + """ Audio output through `GStreamer `_. """ @@ -582,6 +588,7 @@ class Audio(pykka.ThreadingActor): .. 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/icy.py b/mopidy/audio/icy.py new file mode 100644 index 00000000..dd59baae --- /dev/null +++ b/mopidy/audio/icy.py @@ -0,0 +1,63 @@ +from __future__ import absolute_import, unicode_literals + +import gobject + +import pygst +pygst.require('0.10') +import gst # noqa + + +class IcySrc(gst.Bin, gst.URIHandler): + __gstdetails__ = ('IcySrc', + 'Src', + 'HTTP src wrapper for icy:// support.', + 'Mopidy') + + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, + gst.caps_new_any()) + + __gsttemplates__ = (srcpad_template,) + + def __init__(self): + super(IcySrc, self).__init__() + self._httpsrc = gst.element_make_from_uri(gst.URI_SRC, 'http://') + try: + self._httpsrc.set_property('iradio-mode', True) + except TypeError: + pass + self.add(self._httpsrc) + + self._srcpad = gst.GhostPad('src', self._httpsrc.get_pad('src')) + self.add_pad(self._srcpad) + + @classmethod + def do_get_type_full(cls): + return gst.URI_SRC + + @classmethod + def do_get_protocols_full(cls): + return [b'icy', b'icyx'] + + def do_set_uri(self, uri): + if uri.startswith('icy://'): + return self._httpsrc.set_uri(b'http://' + uri[len('icy://'):]) + elif uri.startswith('icyx://'): + return self._httpsrc.set_uri(b'https://' + uri[len('icyx://'):]) + else: + return False + + def do_get_uri(self): + uri = self._httpsrc.get_uri() + if uri.startswith('http://'): + return b'icy://' + uri[len('http://'):] + else: + return b'icyx://' + uri[len('https://'):] + + +def register(): + # Only register icy if gst install can't handle it on it's own. + if not gst.element_make_from_uri(gst.URI_SRC, 'icy://'): + gobject.type_register(IcySrc) + gst.element_register( + IcySrc, IcySrc.__name__.lower(), gst.RANK_MARGINAL) 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 deleted file mode 100644 index 61bcb7a1..00000000 --- a/mopidy/audio/playlists.py +++ /dev/null @@ -1,419 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import io - -import gobject - -import pygst -pygst.require('0.10') -import gst # noqa - -from mopidy.compat import configparser - -try: - import xml.etree.cElementTree as elementtree -except ImportError: - import xml.etree.ElementTree as elementtree - - -# TODO: make detect_FOO_header reusable in general mopidy code. -# i.e. give it just a "peek" like function. -def detect_m3u_header(typefind): - return typefind.peek(0, 7).upper() == b'#EXTM3U' - - -def detect_pls_header(typefind): - return typefind.peek(0, 10).lower() == b'[playlist]' - - -def detect_xspf_header(typefind): - data = typefind.peek(0, 150) - if b'xspf' not in data.lower(): - return False - - try: - data = io.BytesIO(data) - for event, element in elementtree.iterparse(data, events=(b'start',)): - return element.tag.lower() == '{http://xspf.org/ns/0/}playlist' - except elementtree.ParseError: - pass - return False - - -def detect_asx_header(typefind): - data = typefind.peek(0, 50) - if b'asx' not in data.lower(): - return False - - try: - data = io.BytesIO(data) - for event, element in elementtree.iterparse(data, events=(b'start',)): - return element.tag.lower() == 'asx' - except elementtree.ParseError: - pass - return False - - -def parse_m3u(data): - # TODO: convert non URIs to file URIs. - found_header = False - for line in data.readlines(): - if found_header or line.startswith(b'#EXTM3U'): - found_header = True - else: - continue - if not line.startswith(b'#') and line.strip(): - yield line.strip() - - -def parse_pls(data): - # TODO: convert non URIs to file URIs. - try: - cp = configparser.RawConfigParser() - cp.readfp(data) - except configparser.Error: - return - - for section in cp.sections(): - if section.lower() != 'playlist': - continue - for i in range(cp.getint(section, 'numberofentries')): - yield cp.get(section, 'file%d' % (i + 1)) - - -def parse_xspf(data): - try: - # Last element will be root. - for event, element in elementtree.iterparse(data): - element.tag = element.tag.lower() # normalize - except elementtree.ParseError: - return - - ns = 'http://xspf.org/ns/0/' - for track in element.iterfind('{%s}tracklist/{%s}track' % (ns, ns)): - yield track.findtext('{%s}location' % ns) - - -def parse_asx(data): - try: - # Last element will be root. - for event, element in elementtree.iterparse(data): - element.tag = element.tag.lower() # normalize - except elementtree.ParseError: - return - - for ref in element.findall('entry/ref[@href]'): - yield ref.get('href', '').strip() - - for entry in element.findall('entry[@href]'): - yield entry.get('href', '').strip() - - -def parse_urilist(data): - for line in data.readlines(): - if not line.startswith('#') and gst.uri_is_valid(line.strip()): - yield line - - -def playlist_typefinder(typefind, func, caps): - if func(typefind): - typefind.suggest(gst.TYPE_FIND_MAXIMUM, caps) - - -def register_typefind(mimetype, func, extensions): - caps = gst.caps_from_string(mimetype) - gst.type_find_register(mimetype, gst.RANK_PRIMARY, playlist_typefinder, - extensions, caps, func, caps) - - -def register_typefinders(): - register_typefind('audio/x-mpegurl', detect_m3u_header, [b'm3u', b'm3u8']) - register_typefind('audio/x-scpls', detect_pls_header, [b'pls']) - register_typefind('application/xspf+xml', detect_xspf_header, [b'xspf']) - # NOTE: seems we can't use video/x-ms-asf which is the correct mime for asx - # as it is shared with asf for streaming videos :/ - register_typefind('audio/x-ms-asx', detect_asx_header, [b'asx']) - - -class BasePlaylistElement(gst.Bin): - """Base class for creating GStreamer elements for playlist support. - - This element performs the following steps: - - 1. Initializes src and sink pads for the element. - 2. Collects data from the sink until EOS is reached. - 3. Passes the collected data to :meth:`convert` to get a list of URIs. - 4. Passes the list of URIs to :meth:`handle`, default handling is to pass - the URIs to the src element as a uri-list. - 5. If handle returned true, the EOS consumed and nothing more happens, if - it is not consumed it flows on to the next element downstream, which is - likely our uri-list consumer which needs the EOS to know we are done - sending URIs. - """ - - sinkpad_template = None - """GStreamer pad template to use for sink, must be overriden.""" - - srcpad_template = None - """GStreamer pad template to use for src, must be overriden.""" - - ghost_srcpad = False - """Indicates if src pad should be ghosted or not.""" - - def __init__(self): - """Sets up src and sink pads plus behaviour.""" - super(BasePlaylistElement, self).__init__() - self._data = io.BytesIO() - self._done = False - - self.sinkpad = gst.Pad(self.sinkpad_template) - self.sinkpad.set_chain_function(self._chain) - self.sinkpad.set_event_function(self._event) - self.add_pad(self.sinkpad) - - if self.ghost_srcpad: - self.srcpad = gst.ghost_pad_new_notarget('src', gst.PAD_SRC) - else: - self.srcpad = gst.Pad(self.srcpad_template) - self.add_pad(self.srcpad) - - def convert(self, data): - """Convert the data we have colleted to URIs. - - :param data: collected data buffer - :type data: :class:`io.BytesIO` - :returns: iterable or generator of URIs - """ - raise NotImplementedError - - def handle(self, uris): - """Do something useful with the URIs. - - :param uris: list of URIs - :type uris: :type:`list` - :returns: boolean indicating if EOS should be consumed - """ - # TODO: handle unicode uris which we can get out of elementtree - self.srcpad.push(gst.Buffer('\n'.join(uris))) - return False - - def _chain(self, pad, buf): - if not self._done: - self._data.write(buf.data) - return gst.FLOW_OK - return gst.FLOW_EOS - - def _event(self, pad, event): - if event.type == gst.EVENT_NEWSEGMENT: - return True - - if event.type == gst.EVENT_EOS: - self._done = True - self._data.seek(0) - if self.handle(list(self.convert(self._data))): - return True - - # Ensure we handle remaining events in a sane way. - return pad.event_default(event) - - -class M3uDecoder(BasePlaylistElement): - __gstdetails__ = ('M3U Decoder', - 'Decoder', - 'Convert .m3u to text/uri-list', - 'Mopidy') - - sinkpad_template = gst.PadTemplate( - 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, - gst.caps_from_string('audio/x-mpegurl')) - - srcpad_template = gst.PadTemplate( - 'src', gst.PAD_SRC, gst.PAD_ALWAYS, - gst.caps_from_string('text/uri-list')) - - __gsttemplates__ = (sinkpad_template, srcpad_template) - - def convert(self, data): - return parse_m3u(data) - - -class PlsDecoder(BasePlaylistElement): - __gstdetails__ = ('PLS Decoder', - 'Decoder', - 'Convert .pls to text/uri-list', - 'Mopidy') - - sinkpad_template = gst.PadTemplate( - 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, - gst.caps_from_string('audio/x-scpls')) - - srcpad_template = gst.PadTemplate( - 'src', gst.PAD_SRC, gst.PAD_ALWAYS, - gst.caps_from_string('text/uri-list')) - - __gsttemplates__ = (sinkpad_template, srcpad_template) - - def convert(self, data): - return parse_pls(data) - - -class XspfDecoder(BasePlaylistElement): - __gstdetails__ = ('XSPF Decoder', - 'Decoder', - 'Convert .pls to text/uri-list', - 'Mopidy') - - sinkpad_template = gst.PadTemplate( - 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, - gst.caps_from_string('application/xspf+xml')) - - srcpad_template = gst.PadTemplate( - 'src', gst.PAD_SRC, gst.PAD_ALWAYS, - gst.caps_from_string('text/uri-list')) - - __gsttemplates__ = (sinkpad_template, srcpad_template) - - def convert(self, data): - return parse_xspf(data) - - -class AsxDecoder(BasePlaylistElement): - __gstdetails__ = ('ASX Decoder', - 'Decoder', - 'Convert .asx to text/uri-list', - 'Mopidy') - - sinkpad_template = gst.PadTemplate( - 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, - gst.caps_from_string('audio/x-ms-asx')) - - srcpad_template = gst.PadTemplate( - 'src', gst.PAD_SRC, gst.PAD_ALWAYS, - gst.caps_from_string('text/uri-list')) - - __gsttemplates__ = (sinkpad_template, srcpad_template) - - def convert(self, data): - return parse_asx(data) - - -class UriListElement(BasePlaylistElement): - __gstdetails__ = ('URIListDemuxer', - 'Demuxer', - 'Convert a text/uri-list to a stream', - 'Mopidy') - - sinkpad_template = gst.PadTemplate( - 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, - gst.caps_from_string('text/uri-list')) - - srcpad_template = gst.PadTemplate( - 'src', gst.PAD_SRC, gst.PAD_ALWAYS, - gst.caps_new_any()) - - ghost_srcpad = True # We need to hook this up to our internal decodebin - - __gsttemplates__ = (sinkpad_template, srcpad_template) - - def __init__(self): - super(UriListElement, self).__init__() - self.uridecodebin = gst.element_factory_make('uridecodebin') - self.uridecodebin.connect('pad-added', self.pad_added) - # Limit to anycaps so we get a single stream out, letting other - # elements downstream figure out actual muxing - self.uridecodebin.set_property('caps', gst.caps_new_any()) - - def pad_added(self, src, pad): - self.srcpad.set_target(pad) - pad.add_event_probe(self.pad_event) - - def pad_event(self, pad, event): - if event.has_name('urilist-played'): - error = gst.GError(gst.RESOURCE_ERROR, gst.RESOURCE_ERROR_FAILED, - b'Nested playlists not supported.') - message = b'Playlists pointing to other playlists is not supported' - self.post_message(gst.message_new_error(self, error, message)) - return 1 # GST_PAD_PROBE_OK - - def handle(self, uris): - struct = gst.Structure('urilist-played') - event = gst.event_new_custom(gst.EVENT_CUSTOM_UPSTREAM, struct) - self.sinkpad.push_event(event) - - # TODO: hookup about to finish and errors to rest of URIs so we - # round robin, only giving up once all have been tried. - # TODO: uris could be empty. - self.add(self.uridecodebin) - self.uridecodebin.set_state(gst.STATE_READY) - self.uridecodebin.set_property('uri', uris[0]) - self.uridecodebin.sync_state_with_parent() - return True # Make sure we consume the EOS that triggered us. - - def convert(self, data): - return parse_urilist(data) - - -class IcySrc(gst.Bin, gst.URIHandler): - __gstdetails__ = ('IcySrc', - 'Src', - 'HTTP src wrapper for icy:// support.', - 'Mopidy') - - srcpad_template = gst.PadTemplate( - 'src', gst.PAD_SRC, gst.PAD_ALWAYS, - gst.caps_new_any()) - - __gsttemplates__ = (srcpad_template,) - - def __init__(self): - super(IcySrc, self).__init__() - self._httpsrc = gst.element_make_from_uri(gst.URI_SRC, 'http://') - try: - self._httpsrc.set_property('iradio-mode', True) - except TypeError: - pass - self.add(self._httpsrc) - - self._srcpad = gst.GhostPad('src', self._httpsrc.get_pad('src')) - self.add_pad(self._srcpad) - - @classmethod - def do_get_type_full(cls): - return gst.URI_SRC - - @classmethod - def do_get_protocols_full(cls): - return [b'icy', b'icyx'] - - def do_set_uri(self, uri): - if uri.startswith('icy://'): - return self._httpsrc.set_uri(b'http://' + uri[len('icy://'):]) - elif uri.startswith('icyx://'): - return self._httpsrc.set_uri(b'https://' + uri[len('icyx://'):]) - else: - return False - - def do_get_uri(self): - uri = self._httpsrc.get_uri() - if uri.startswith('http://'): - return b'icy://' + uri[len('http://'):] - else: - return b'icyx://' + uri[len('https://'):] - - -def register_element(element_class): - gobject.type_register(element_class) - gst.element_register( - element_class, element_class.__name__.lower(), gst.RANK_MARGINAL) - - -def register_elements(): - register_element(M3uDecoder) - register_element(PlsDecoder) - register_element(XspfDecoder) - register_element(AsxDecoder) - register_element(UriListElement) - - # Only register icy if gst install can't handle it on it's own. - if not gst.element_make_from_uri(gst.URI_SRC, 'icy://'): - register_element(IcySrc) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index d1e83407..cf370052 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -10,7 +10,7 @@ import gst.pbutils # noqa from mopidy import exceptions from mopidy.audio import utils -from mopidy.utils import encoding +from mopidy.internal import encoding _missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description @@ -22,6 +22,7 @@ _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. @@ -73,7 +74,8 @@ def _setup_pipeline(uri, proxy_config=None): decodebin = gst.element_factory_make('decodebin2') pipeline = gst.element_factory_make('pipeline') - pipeline.add_many(src, typefind, decodebin) + for e in (src, typefind, decodebin): + pipeline.add(e) gst.element_link_many(src, typefind, decodebin) if proxy_config: @@ -180,7 +182,7 @@ if __name__ == '__main__': import gobject - from mopidy.utils import path + from mopidy.internal import path gobject.threads_init() diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 1a8bf6a7..a4333b5a 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -8,7 +8,7 @@ import pygst pygst.require('0.10') import gst # noqa -from mopidy import compat +from mopidy import compat, httpclient from mopidy.models import Album, Artist, Track logger = logging.getLogger(__name__) @@ -65,15 +65,21 @@ def supported_uri_schemes(uri_schemes): return supported_schemes -def _artists(tags, artist_name, artist_id=None): +def _artists(tags, artist_name, artist_id=None, artist_sortname=None): # Name missing, don't set artist if not tags.get(artist_name): return None - # One artist name and id, provide artist with id. - if len(tags[artist_name]) == 1 and artist_id in tags: - return [Artist(name=tags[artist_name][0], - musicbrainz_id=tags[artist_id][0])] - # Multiple artist, provide artists without id. + # One artist name and either id or sortname, include all available fields + if len(tags[artist_name]) == 1 and \ + (artist_id in tags or artist_sortname in tags): + attrs = {'name': tags[artist_name][0]} + if artist_id in tags: + attrs['musicbrainz_id'] = tags[artist_id][0] + if artist_sortname in tags: + attrs['sortname'] = tags[artist_sortname][0] + return [Artist(**attrs)] + + # Multiple artist, provide artists with name only to avoid ambiguity. return [Artist(name=name) for name in tags[artist_name]] @@ -91,8 +97,9 @@ def convert_tags_to_track(tags): track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER) track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER) - track_kwargs['artists'] = _artists( - tags, gst.TAG_ARTIST, 'musicbrainz-artistid') + track_kwargs['artists'] = _artists(tags, gst.TAG_ARTIST, + 'musicbrainz-artistid', + 'musicbrainz-sortname') album_kwargs['artists'] = _artists( tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid') @@ -142,11 +149,7 @@ def setup_proxy(element, config): 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', httpclient.format_proxy(config, auth=False)) element.set_property('proxy-id', config.get('username')) element.set_property('proxy-pw', config.get('password')) diff --git a/mopidy/backend.py b/mopidy/backend.py index 4503a9ee..8d7a831e 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -1,9 +1,15 @@ from __future__ import absolute_import, unicode_literals +import logging + from mopidy import listener, models +logger = logging.getLogger(__name__) + + class Backend(object): + """Backend API If the backend has problems during initialization it should raise @@ -57,8 +63,13 @@ class Backend(object): def has_playlists(self): return self.playlists is not None + def ping(self): + """Called to check if the actor is still alive.""" + return True + class LibraryProvider(object): + """ :param backend: backend the controller is a part of :type backend: :class:`mopidy.backend.Backend` @@ -151,6 +162,7 @@ class LibraryProvider(object): class PlaybackProvider(object): + """ :param audio: the audio actor :type audio: actor proxy to an instance of :class:`mopidy.audio.Audio` @@ -231,6 +243,9 @@ class PlaybackProvider(object): :rtype: :class:`True` if successful, else :class:`False` """ uri = self.translate_uri(track.uri) + if uri != track.uri: + logger.debug( + 'Backend translated URI from %s to %s', track.uri, uri) if not uri: return False self.audio.set_uri(uri).get() @@ -283,6 +298,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 @@ -394,6 +410,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 ebb2c891..4890c722 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, print_function, unicode_literals import argparse import collections +import contextlib import logging import os import sys @@ -10,10 +11,12 @@ import glib import gobject +import pykka + from mopidy import config as config_lib, exceptions from mopidy.audio import Audio from mopidy.core import Core -from mopidy.utils import deps, process, timer, versioning +from mopidy.internal import deps, process, timer, versioning logger = logging.getLogger(__name__) @@ -38,7 +41,9 @@ def config_override_type(value): class _ParserError(Exception): - pass + + def __init__(self, message): + self.message = message class _HelpError(Exception): @@ -46,11 +51,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, @@ -64,6 +71,7 @@ class _HelpAction(argparse.Action): class Command(object): + """Command parser and runner for building trees of commands. This class provides a wraper around :class:`argparse.ArgumentParser` @@ -225,7 +233,26 @@ class Command(object): raise NotImplementedError +@contextlib.contextmanager +def _actor_error_handling(name): + try: + yield + except exceptions.BackendError as exc: + logger.error( + 'Backend (%s) initialization error: %s', name, exc.message) + except exceptions.FrontendError as exc: + logger.error( + 'Frontend (%s) initialization error: %s', name, exc.message) + except exceptions.MixerError as exc: + logger.error( + 'Mixer (%s) initialization error: %s', name, exc.message) + except Exception: + logger.exception('Got un-handled exception from %s', name) + + +# TODO: move out of this utility class class RootCommand(Command): + def __init__(self): super(RootCommand, self).__init__() self.set(base_verbosity_level=0) @@ -270,9 +297,11 @@ class RootCommand(Command): mixer = None if mixer_class is not None: mixer = self.start_mixer(config, mixer_class) + if mixer: + self.configure_mixer(config, mixer) audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) - core = self.start_core(mixer, backends, audio) + core = self.start_core(config, mixer, backends, audio) self.start_frontends(config, frontend_classes, core) loop.run() except (exceptions.BackendError, @@ -316,16 +345,15 @@ class RootCommand(Command): return selected_mixers[0] def start_mixer(self, config, mixer_class): - try: - logger.info('Starting Mopidy mixer: %s', mixer_class.__name__) + logger.info('Starting Mopidy mixer: %s', mixer_class.__name__) + with _actor_error_handling(mixer_class.__name__): mixer = mixer_class.start(config=config).proxy() - self.configure_mixer(config, mixer) - return mixer - except exceptions.MixerError as exc: - logger.error( - 'Mixer (%s) initialization error: %s', - mixer_class.__name__, exc.message) - raise + try: + mixer.ping().get() + return mixer + except pykka.ActorDeadError as exc: + logger.error('Actor died: %s', exc) + return None def configure_mixer(self, config, mixer): volume = config['audio']['mixer_volume'] @@ -346,22 +374,26 @@ class RootCommand(Command): backends = [] for backend_class in backend_classes: - try: + with _actor_error_handling(backend_class.__name__): with timer.time_logger(backend_class.__name__): backend = backend_class.start( config=config, audio=audio).proxy() - backends.append(backend) - except exceptions.BackendError as exc: - logger.error( - 'Backend (%s) initialization error: %s', - backend_class.__name__, exc.message) - raise + backends.append(backend) + + # Block until all on_starts have finished, letting them run in parallel + for backend in backends[:]: + try: + backend.ping().get() + except pykka.ActorDeadError as exc: + backends.remove(backend) + logger.error('Actor died: %s', exc) return backends - def start_core(self, mixer, backends, audio): + def start_core(self, config, mixer, backends, audio): logger.info('Starting Mopidy core') - return Core.start(mixer=mixer, backends=backends, audio=audio).proxy() + return Core.start( + config=config, mixer=mixer, backends=backends, audio=audio).proxy() def start_frontends(self, config, frontend_classes, core): logger.info( @@ -369,14 +401,9 @@ class RootCommand(Command): ', '.join(f.__name__ for f in frontend_classes) or 'none') for frontend_class in frontend_classes: - try: + with _actor_error_handling(frontend_class.__name__): with timer.time_logger(frontend_class.__name__): frontend_class.start(config=config, core=core) - except exceptions.FrontendError as exc: - logger.error( - 'Frontend (%s) initialization error: %s', - frontend_class.__name__, exc.message) - raise def stop_frontends(self, frontend_classes): logger.info('Stopping Mopidy frontends') @@ -408,8 +435,8 @@ class ConfigCommand(Command): super(ConfigCommand, self).__init__() self.set(base_verbosity_level=-1) - def run(self, config, errors, extensions): - print(config_lib.format(config, extensions, errors)) + def run(self, config, errors, schemas): + print(config_lib.format(config, schemas, errors)) return 0 diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index f6fd2709..042c20d9 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -11,10 +11,17 @@ from mopidy.compat import configparser from mopidy.config import keyring from mopidy.config.schemas import * # noqa from mopidy.config.types import * # noqa -from mopidy.utils import path, versioning +from mopidy.internal import path, versioning logger = logging.getLogger(__name__) +_core_schema = ConfigSchema('core') +_core_schema['cache_dir'] = Path() +_core_schema['config_dir'] = Path() +_core_schema['data_dir'] = Path() +# MPD supports at most 10k tracks, some clients segfault when this is exceeded. +_core_schema['max_tracklist_length'] = Integer(minimum=1, maximum=10000) + _logging_schema = ConfigSchema('logging') _logging_schema['color'] = Boolean() _logging_schema['console_format'] = String() @@ -43,8 +50,9 @@ _proxy_schema['password'] = Secret(optional=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema # _outputs_schema = config.AudioOutputConfigSchema() -_schemas = [_logging_schema, _loglevels_schema, _logcolors_schema, - _audio_schema, _proxy_schema] +_schemas = [ + _core_schema, _logging_schema, _loglevels_schema, _logcolors_schema, + _audio_schema, _proxy_schema] _INITIAL_HELP = """ # For further information about options in this file see: @@ -65,41 +73,40 @@ def read(config_file): return filehandle.read() -def load(files, extensions, overrides): - # Helper to get configs, as the rest of our config system should not need - # to know about extensions. +def load(files, ext_schemas, ext_defaults, overrides): config_dir = os.path.dirname(__file__) defaults = [read(os.path.join(config_dir, 'default.conf'))] - defaults.extend(e.get_default_config() for e in extensions) + defaults.extend(ext_defaults) raw_config = _load(files, defaults, keyring.fetch() + (overrides or [])) schemas = _schemas[:] - schemas.extend(e.get_config_schema() for e in extensions) + schemas.extend(ext_schemas) return _validate(raw_config, schemas) -def format(config, extensions, comments=None, display=True): - # Helper to format configs, as the rest of our config system should not - # need to know about extensions. +def format(config, ext_schemas, comments=None, display=True): schemas = _schemas[:] - schemas.extend(e.get_config_schema() for e in extensions) + schemas.extend(ext_schemas) return _format(config, comments or {}, schemas, display, False) -def format_initial(extensions): +def format_initial(extensions_data): config_dir = os.path.dirname(__file__) defaults = [read(os.path.join(config_dir, 'default.conf'))] - defaults.extend(e.get_default_config() for e in extensions) + defaults.extend(d.extension.get_default_config() for d in extensions_data) raw_config = _load([], defaults, []) schemas = _schemas[:] - schemas.extend(e.get_config_schema() for e in extensions) + schemas.extend(d.extension.get_config_schema() for d in extensions_data) config, errors = _validate(raw_config, schemas) versions = ['Mopidy %s' % versioning.get_version()] - for extension in sorted(extensions, key=lambda ext: ext.dist_name): - versions.append('%s %s' % (extension.dist_name, extension.version)) + extensions_data = sorted( + extensions_data, key=lambda d: d.extension.dist_name) + for data in extensions_data: + versions.append('%s %s' % ( + data.extension.dist_name, data.extension.version)) header = _INITIAL_HELP.strip() % {'versions': '\n# '.join(versions)} formatted_config = _format( @@ -264,6 +271,7 @@ def _postprocess(config_string): class Proxy(collections.Mapping): + def __init__(self, data): self._data = data diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 42edbbbd..675381d9 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -1,3 +1,9 @@ +[core] +cache_dir = $XDG_CACHE_DIR/mopidy +config_dir = $XDG_CONFIG_DIR/mopidy +data_dir = $XDG_DATA_DIR/mopidy +max_tracklist_length = 10000 + [logging] color = true console_format = %(levelname)-8s %(message)s diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 2b055663..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 @@ -95,6 +97,7 @@ class ConfigSchema(collections.OrderedDict): class MapConfigSchema(object): + """Schema for handling multiple unknown keys with the same type. Does not sub-class :class:`ConfigSchema`, but implements the same diff --git a/mopidy/config/types.py b/mopidy/config/types.py index d074458b..9d673c43 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 log, path +from mopidy.internal 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 @@ -198,6 +209,7 @@ class List(ConfigValue): class LogColor(ConfigValue): + def deserialize(self, value): validators.validate_choice(value.lower(), log.COLORS) return value.lower() @@ -209,6 +221,7 @@ class LogColor(ConfigValue): class LogLevel(ConfigValue): + """Log level value. Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``, @@ -235,6 +248,7 @@ class LogLevel(ConfigValue): class Hostname(ConfigValue): + """Network hostname value.""" def __init__(self, optional=False): @@ -252,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: @@ -278,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/actor.py b/mopidy/core/actor.py index b21e9e20..f20b0ba2 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import collections import itertools +import logging import pykka @@ -14,8 +15,11 @@ 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 +from mopidy.internal import versioning +from mopidy.internal.deprecation import deprecated_property + + +logger = logging.getLogger(__name__) class Core( @@ -23,32 +27,28 @@ class Core( mixer.MixerListener): library = None - """The library controller. An instance of - :class:`mopidy.core.LibraryController`.""" + """An instance of :class:`~mopidy.core.LibraryController`""" history = None - """The playback history controller. An instance of - :class:`mopidy.core.HistoryController`.""" + """An instance of :class:`~mopidy.core.HistoryController`""" mixer = None - """The mixer controller. An instance of - :class:`mopidy.core.MixerController`.""" + """An instance of :class:`~mopidy.core.MixerController`""" playback = None - """The playback controller. An instance of - :class:`mopidy.core.PlaybackController`.""" + """An instance of :class:`~mopidy.core.PlaybackController`""" playlists = None - """The playlists controller. An instance of - :class:`mopidy.core.PlaylistsController`.""" + """An instance of :class:`~mopidy.core.PlaylistsController`""" tracklist = None - """The tracklist controller. An instance of - :class:`mopidy.core.TracklistController`.""" + """An instance of :class:`~mopidy.core.TracklistController`""" - def __init__(self, mixer=None, backends=None, audio=None): + def __init__(self, config=None, mixer=None, backends=None, audio=None): super(Core, self).__init__() + self._config = config + self.backends = Backends(backends) self.library = LibraryController(backends=self.backends, core=self) @@ -134,6 +134,7 @@ class Core( class Backends(list): + def __init__(self, backends): super(Backends, self).__init__(backends) @@ -148,10 +149,15 @@ class Backends(list): return b.actor_ref.actor_class.__name__ for b in backends: - has_library = b.has_library().get() - has_library_browse = b.has_library_browse().get() - has_playback = b.has_playback().get() - has_playlists = b.has_playlists().get() + try: + has_library = b.has_library().get() + has_library_browse = b.has_library_browse().get() + has_playback = b.has_playback().get() + has_playlists = b.has_playlists().get() + except Exception: + self.remove(b) + logger.exception('Fetching backend info for %s failed', + b.actor_ref.actor_class.__name__) for scheme in b.uri_schemes.get(): assert scheme not in backends_by_scheme, ( diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 3cfb390c..c300fbb9 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,15 +1,32 @@ from __future__ import absolute_import, unicode_literals import collections +import contextlib import logging import operator import urlparse -import pykka +from mopidy import compat, exceptions, models +from mopidy.internal import deprecation, validation + logger = logging.getLogger(__name__) +@contextlib.contextmanager +def _backend_error_handling(backend, reraise=None): + try: + yield + except exceptions.ValidationError as e: + logger.error('%s backend returned bad data: %s', + backend.actor_ref.actor_class.__name__, e) + except Exception as e: + if reraise and isinstance(e, reraise): + raise + logger.exception('%s backend caused an exception.', + backend.actor_ref.actor_class.__name__) + + class LibraryController(object): pykka_traversable = True @@ -38,8 +55,8 @@ class LibraryController(object): Browse directories and tracks at the given ``uri``. ``uri`` is a string which represents some directory belonging to a - backend. To get the intial root directories for backends pass None as - the URI. + backend. To get the intial root directories for backends pass + :class:`None` as the URI. Returns a list of :class:`mopidy.models.Ref` objects for the directories and tracks at the given ``uri``. @@ -67,15 +84,36 @@ class LibraryController(object): .. versionadded:: 0.18 """ if uri is None: - backends = self.backends.with_library_browse.values() - unique_dirs = {b.library.root_directory.get() for b in backends} - return sorted(unique_dirs, key=operator.attrgetter('name')) + return self._roots() + elif not uri.strip(): + return [] + validation.check_uri(uri) + return self._browse(uri) + def _roots(self): + directories = set() + backends = self.backends.with_library_browse.values() + futures = {b: b.library.root_directory for b in backends} + for backend, future in futures.items(): + with _backend_error_handling(backend): + root = future.get() + validation.check_instance(root, models.Ref) + directories.add(root) + return sorted(directories, key=operator.attrgetter('name')) + + def _browse(self, uri): scheme = urlparse.urlparse(uri).scheme backend = self.backends.with_library_browse.get(scheme) + if not backend: return [] - return backend.library.browse(uri).get() + + with _backend_error_handling(backend): + result = backend.library.browse(uri).get() + validation.check_instances(result, models.Ref) + return result + + return [] def get_distinct(self, field, query=None): """ @@ -86,18 +124,25 @@ class LibraryController(object): recommended to use this method. :param string field: One of ``track``, ``artist``, ``albumartist``, - ``album``, ``composer``, ``performer``, ``date``or ``genre``. + ``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. .. versionadded:: 1.0 """ - futures = [b.library.get_distinct(field, query) - for b in self.backends.with_library.values()] + validation.check_choice(field, validation.DISTINCT_FIELDS) + query is None or validation.check_query(query) # TODO: normalize? + result = set() - for r in pykka.get_all(futures): - result.update(r) + futures = {b: b.library.get_distinct(field, query) + for b in self.backends.with_library.values()} + for backend, future in futures.items(): + with _backend_error_handling(backend): + values = future.get() + if values is not None: + validation.check_instances(values, compat.text_type) + result.update(values) return result def get_images(self, uris): @@ -110,20 +155,31 @@ class LibraryController(object): 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 + :param uris: list of URIs to find images for + :type uris: list of string :rtype: {uri: tuple of :class:`mopidy.models.Image`} .. versionadded:: 1.0 """ - futures = [ - backend.library.get_images(backend_uris) + validation.check_uris(uris) + + futures = { + backend: backend.library.get_images(backend_uris) for (backend, backend_uris) - in self._get_backends_to_uris(uris).items() if backend_uris] + in self._get_backends_to_uris(uris).items() if backend_uris} results = {uri: tuple() for uri in uris} - for r in pykka.get_all(futures): - for uri, images in r.items(): - results[uri] += tuple(images) + for backend, future in futures.items(): + with _backend_error_handling(backend): + if future.get() is None: + continue + validation.check_instance(future.get(), collections.Mapping) + for uri, images in future.get().items(): + if uri not in uris: + raise exceptions.ValidationError( + 'Got unknown image URI: %s' % uri) + validation.check_instances(images, models.Image) + results[uri] += tuple(images) return results def find_exact(self, query=None, uris=None, **kwargs): @@ -132,11 +188,12 @@ class LibraryController(object): .. 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. + Lookup the given URIs. If the URI expands to multiple tracks, the returned list will contain them all. @@ -146,7 +203,7 @@ class LibraryController(object): :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. + {uri: list of :class:`mopidy.models.Track`} if uris was set. .. versionadded:: 1.0 The ``uris`` argument. @@ -154,33 +211,36 @@ class LibraryController(object): .. deprecated:: 1.0 The ``uri`` argument. Use ``uris`` instead. """ - none_set = uri is None and uris is None - both_set = uri is not None and uris is not None + if sum(o is not None for o in [uri, uris]) != 1: + raise ValueError('Exactly one of "uri" or "uris" must be set') - if none_set or both_set: - raise ValueError("One of 'uri' or 'uris' must be set") + uris is None or validation.check_uris(uris) + uri is None or validation.check_uri(uri) + + 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) + results = {u: [] for u in 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 backend, backend_uris in self._get_backends_to_uris(uris).items(): + for u in backend_uris: + futures[(backend, u)] = backend.library.lookup(u) - for u in uris: - if u in futures: - result[u] = futures[u].get() - else: - result[u] = [] + for (backend, u), future in futures.items(): + with _backend_error_handling(backend): + result = future.get() + if result is not None: + validation.check_instances(result, models.Track) + results[u] = result if uri: - return result[uri] - return result + return results[uri] + return results def refresh(self, uri=None): """ @@ -189,25 +249,27 @@ class LibraryController(object): :param uri: directory or track URI :type uri: string """ - if uri is not None: - backend = self._get_backend(uri) - if backend: - backend.library.refresh(uri).get() - else: - futures = [b.library.refresh(uri) - for b in self.backends.with_library.values()] - pykka.get_all(futures) + uri is None or validation.check_uri(uri) + + futures = {} + backends = {} + uri_scheme = urlparse.urlparse(uri).scheme if uri else None + + for backend_scheme, backend in self.backends.with_playlists.items(): + backends.setdefault(backend, set()).add(backend_scheme) + + for backend, backend_schemes in backends.items(): + if uri_scheme is None or uri_scheme in backend_schemes: + futures[backend] = backend.library.refresh(uri) + + for backend, future in futures.items(): + with _backend_error_handling(backend): + future.get() def search(self, query=None, uris=None, exact=False, **kwargs): """ Search the library for tracks where ``field`` contains ``values``. - .. deprecated:: 1.0 - Previously, if the query was empty, and the backend could support - it, all available tracks were returned. This has not changed, but - it is strongly discouraged. No new code should rely on this - behavior. - If ``uris`` is given, the search is limited to results from within the URI roots. For example passing ``uris=['file:']`` will limit the search to the local backend. @@ -216,51 +278,83 @@ class LibraryController(object): # Returns results matching 'a' in any backend search({'any': ['a']}) - search(any=['a']) # Returns results matching artist 'xyz' in any backend search({'artist': ['xyz']}) - search(artist=['xyz']) # Returns results matching 'a' and 'b' and artist 'xyz' in any # backend search({'any': ['a', 'b'], 'artist': ['xyz']}) - search(any=['a', 'b'], artist=['xyz']) # Returns results matching 'a' if within the given URI roots # "file:///media/music" and "spotify:" search({'any': ['a']}, uris=['file:///media/music', 'spotify:']) - search(any=['a'], uris=['file:///media/music', 'spotify:']) + + # Returns results matching artist 'xyz' and 'abc' in any backend + search({'artist': ['xyz', 'abc']}) :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` + :type uris: list of string or :class:`None` + :param exact: if the search should use exact matching + :type exact: :class:`bool` :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 = _normalize_query(query or kwargs) + + uris is None or validation.check_uris(uris) + query is None or validation.check_query(query) + validation.check_boolean(exact) + + 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) + # Some of our tests check for LookupError to catch bad queries. This is + # silly and should be replaced with query validation before passing it + # to the backends. + reraise = (TypeError, LookupError) + results = [] for backend, future in futures.items(): try: - results.append(future.get()) + with _backend_error_handling(backend, reraise=reraise): + result = future.get() + if result is not None: + validation.check_instance(result, models.SearchResult) + results.append(result) 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] + + return results def _normalize_query(query): broken_client = False + # TODO: this breaks if query is not a dictionary like object... for (field, values) in query.items(): if isinstance(values, basestring): broken_client = True diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 3ae03925..d95bd491 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. @@ -122,6 +123,17 @@ class CoreListener(listener.Listener): """ pass + def playlist_deleted(self, uri): + """ + Called whenever a playlist is deleted. + + *MAY* be implemented by actor. + + :param uri: the URI of the deleted playlist + :type uri: string + """ + pass + def options_changed(self): """ Called whenever an option is changed. diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 3388d706..649ff270 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -1,11 +1,27 @@ from __future__ import absolute_import, unicode_literals +import contextlib import logging +from mopidy import exceptions +from mopidy.internal import validation + logger = logging.getLogger(__name__) +@contextlib.contextmanager +def _mixer_error_handling(mixer): + try: + yield + except exceptions.ValidationError as e: + logger.error('%s mixer returned bad data: %s', + mixer.actor_ref.actor_class.__name__, e) + except Exception: + logger.exception('%s mixer caused an exception.', + mixer.actor_ref.actor_class.__name__) + + class MixerController(object): pykka_traversable = True @@ -19,8 +35,15 @@ class MixerController(object): The volume scale is linear. """ - if self._mixer is not None: - return self._mixer.get_volume().get() + if self._mixer is None: + return None + + with _mixer_error_handling(self._mixer): + volume = self._mixer.get_volume().get() + volume is None or validation.check_integer(volume, min=0, max=100) + return volume + + return None def set_volume(self, volume): """Set the volume. @@ -31,10 +54,17 @@ class MixerController(object): Returns :class:`True` if call is successful, otherwise :class:`False`. """ + validation.check_integer(volume, min=0, max=100) + if self._mixer is None: - return False - else: - return self._mixer.set_volume(volume).get() + return False # TODO: 2.0 return None + + with _mixer_error_handling(self._mixer): + result = self._mixer.set_volume(volume).get() + validation.check_instance(result, bool) + return result + + return False def get_mute(self): """Get mute state. @@ -42,8 +72,15 @@ class MixerController(object): :class:`True` if muted, :class:`False` unmuted, :class:`None` if unknown. """ - if self._mixer is not None: - return self._mixer.get_mute().get() + if self._mixer is None: + return None + + with _mixer_error_handling(self._mixer): + mute = self._mixer.get_mute().get() + mute is None or validation.check_instance(mute, bool) + return mute + + return None def set_mute(self, mute): """Set mute state. @@ -52,7 +89,13 @@ class MixerController(object): Returns :class:`True` if call is successful, otherwise :class:`False`. """ + validation.check_boolean(mute) if self._mixer is None: - return False - else: - return self._mixer.set_mute(bool(mute)).get() + return False # TODO: 2.0 return None + + with _mixer_error_handling(self._mixer): + result = self._mixer.set_mute(bool(mute)).get() + validation.check_instance(result, bool) + return result + + return False diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index d13ebdb3..9a11066b 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -2,12 +2,11 @@ from __future__ import absolute_import, unicode_literals import logging import urlparse -import warnings +from mopidy import models from mopidy.audio import PlaybackState from mopidy.core import listener -from mopidy.utils.deprecation import deprecated_property - +from mopidy.internal import deprecation, validation logger = logging.getLogger(__name__) @@ -48,7 +47,7 @@ class PlaybackController(object): """ self._current_tl_track = value - current_tl_track = deprecated_property(get_current_tl_track) + current_tl_track = deprecation.deprecated_property(get_current_tl_track) """ .. deprecated:: 1.0 Use :meth:`get_current_tl_track` instead. @@ -62,16 +61,26 @@ class PlaybackController(object): 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 + return getattr(self.get_current_tl_track(), 'track', None) - current_track = deprecated_property(get_current_track) + current_track = deprecation.deprecated_property(get_current_track) """ .. deprecated:: 1.0 Use :meth:`get_current_track` instead. """ + def get_current_tlid(self): + """ + Get the currently playing or selected TLID. + + Extracted from :meth:`get_current_tl_track` for convenience. + + Returns a :class:`int` or :class:`None`. + + .. versionadded:: 1.1 + """ + return getattr(self.get_current_tl_track(), 'tlid', None) + def get_stream_title(self): """Get the current stream title or :class:`None`.""" return self._stream_title @@ -98,12 +107,14 @@ class PlaybackController(object): "PAUSED" -> "PLAYING" [ label="resume" ] "PAUSED" -> "STOPPED" [ label="stop" ] """ + validation.check_choice(new_state, validation.PLAYBACK_STATES) + (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 = deprecated_property(get_state, set_state) + state = deprecation.deprecated_property(get_state, set_state) """ .. deprecated:: 1.0 Use :meth:`get_state` and :meth:`set_state` instead. @@ -117,7 +128,7 @@ class PlaybackController(object): else: return 0 - time_position = deprecated_property(get_time_position) + time_position = deprecation.deprecated_property(get_time_position) """ .. deprecated:: 1.0 Use :meth:`get_time_position` instead. @@ -129,8 +140,7 @@ class PlaybackController(object): Use :meth:`core.mixer.get_volume() ` instead. """ - warnings.warn( - 'playback.get_volume() is deprecated', DeprecationWarning) + deprecation.warn('core.playback.get_volume') return self.core.mixer.get_volume() def set_volume(self, volume): @@ -139,11 +149,10 @@ class PlaybackController(object): Use :meth:`core.mixer.set_volume() ` instead. """ - warnings.warn( - 'playback.set_volume() is deprecated', DeprecationWarning) + deprecation.warn('core.playback.set_volume') return self.core.mixer.set_volume(volume) - volume = deprecated_property(get_volume, set_volume) + volume = deprecation.deprecated_property(get_volume, set_volume) """ .. deprecated:: 1.0 Use :meth:`core.mixer.get_volume() @@ -158,7 +167,7 @@ class PlaybackController(object): Use :meth:`core.mixer.get_mute() ` instead. """ - warnings.warn('playback.get_mute() is deprecated', DeprecationWarning) + deprecation.warn('core.playback.get_mute') return self.core.mixer.get_mute() def set_mute(self, mute): @@ -167,10 +176,10 @@ class PlaybackController(object): Use :meth:`core.mixer.set_mute() ` instead. """ - warnings.warn('playback.set_mute() is deprecated', DeprecationWarning) + deprecation.warn('core.playback.set_mute') return self.core.mixer.set_mute(mute) - mute = deprecated_property(get_mute, set_mute) + mute = deprecation.deprecated_property(get_mute, set_mute) """ .. deprecated:: 1.0 Use :meth:`core.mixer.get_mute() @@ -272,17 +281,37 @@ class PlaybackController(object): self.set_state(PlaybackState.PAUSED) self._trigger_track_playback_paused() - def play(self, tl_track=None): + def play(self, tl_track=None, tlid=None): """ - Play the given track, or if the given track is :class:`None`, play the - currently active track. + Play the given track, or if the given tl_track and tlid is + :class:`None`, play the currently active track. + + Note that the track **must** already be in the tracklist. :param tl_track: track to play :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` + :param tlid: TLID of the track to play + :type tlid: :class:`int` or :class:`None` """ - self._play(tl_track, on_error_step=1) + if sum(o is not None for o in [tl_track, tlid]) > 1: + raise ValueError('At most one of "tl_track" and "tlid" may be set') + + tl_track is None or validation.check_instance(tl_track, models.TlTrack) + tlid is None or validation.check_integer(tlid, min=0) + + if tl_track: + deprecation.warn('core.playback.play:tl_track_kwarg', pending=True) + + self._play(tl_track=tl_track, tlid=tlid, on_error_step=1) + + def _play(self, tl_track=None, tlid=None, on_error_step=1): + if tl_track is None and tlid is not None: + for tl_track in self.core.tracklist.get_tl_tracks(): + if tl_track.tlid == tlid: + break + else: + tl_track = None - def _play(self, tl_track=None, on_error_step=1): if tl_track is None: if self.get_state() == PlaybackState.PAUSED: return self.resume() @@ -319,8 +348,11 @@ class PlaybackController(object): 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) + logger.error( + '%s needs to be updated to work with this ' + 'version of Mopidy.', + backend.actor_ref.actor_class.__name__) + logger.debug('Backend exception', exc_info=True) if success: self.core.tracklist._mark_playing(tl_track) @@ -370,6 +402,13 @@ class PlaybackController(object): :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ + validation.check_integer(time_position) + + if time_position < 0: + logger.debug( + 'Client seeked to negative position. Seeking to zero.') + time_position = 0 + if not self.core.tracklist.tracks: return False diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 1b4c2692..0ea78f26 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -1,18 +1,31 @@ from __future__ import absolute_import, unicode_literals +import contextlib import logging import urlparse -import pykka - +from mopidy import exceptions from mopidy.core import listener -from mopidy.models import Playlist -from mopidy.utils.deprecation import deprecated_property - +from mopidy.internal import deprecation, validation +from mopidy.models import Playlist, Ref logger = logging.getLogger(__name__) +@contextlib.contextmanager +def _backend_error_handling(backend, reraise=None): + try: + yield + except exceptions.ValidationError as e: + logger.error('%s backend returned bad data: %s', + backend.actor_ref.actor_class.__name__, e) + except Exception as e: + if reraise and isinstance(e, reraise): + raise + logger.exception('%s backend caused an exception.', + backend.actor_ref.actor_class.__name__) + + class PlaylistsController(object): pykka_traversable = True @@ -33,14 +46,19 @@ class PlaylistsController(object): .. versionadded:: 1.0 """ futures = { - b.actor_ref.actor_class.__name__: b.playlists.as_list() - for b in set(self.backends.with_playlists.values())} + backend: backend.playlists.as_list() + for backend in set(self.backends.with_playlists.values())} results = [] - for backend_name, future in futures.items(): + for b, future in futures.items(): try: - results.extend(future.get()) + with _backend_error_handling(b, reraise=NotImplementedError): + playlists = future.get() + if playlists is not None: + validation.check_instances(playlists, Ref) + results.extend(playlists) except NotImplementedError: + backend_name = b.actor_ref.actor_class.__name__ logger.warning( '%s does not implement playlists.as_list(). ' 'Please upgrade it.', backend_name) @@ -61,10 +79,20 @@ class PlaylistsController(object): .. versionadded:: 1.0 """ + validation.check_uri(uri) + uri_scheme = urlparse.urlparse(uri).scheme backend = self.backends.with_playlists.get(uri_scheme, None) - if backend: - return backend.playlists.get_items(uri).get() + + if not backend: + return None + + with _backend_error_handling(backend): + items = backend.playlists.get_items(uri).get() + items is None or validation.check_instances(items, Ref) + return items + + return None def get_playlists(self, include_tracks=True): """ @@ -80,6 +108,8 @@ class PlaylistsController(object): .. 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: @@ -87,13 +117,13 @@ class PlaylistsController(object): # 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) + playlists[r.uri].replace(name=r.name) for r in playlist_refs if playlists[r.uri] is not None] else: return [ Playlist(uri=r.uri, name=r.name) for r in playlist_refs] - playlists = deprecated_property(get_playlists) + playlists = deprecation.deprecated_property(get_playlists) """ .. deprecated:: 1.0 Use :meth:`as_list` and :meth:`get_items` instead. @@ -115,22 +145,23 @@ class PlaylistsController(object): :type name: string :param uri_scheme: use the backend matching the URI scheme :type uri_scheme: string - :rtype: :class:`mopidy.models.Playlist` + :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ if uri_scheme in self.backends.with_playlists: backends = [self.backends.with_playlists[uri_scheme]] else: backends = self.backends.with_playlists.values() + for backend in backends: - try: - playlist = backend.playlists.create(name).get() - except Exception: - playlist = None - # Workaround for playlist providers that return None from create() - if not playlist: - continue - listener.CoreListener.send('playlist_changed', playlist=playlist) - return playlist + with _backend_error_handling(backend): + result = backend.playlists.create(name).get() + if result is None: + continue + validation.check_instance(result, Playlist) + listener.CoreListener.send('playlist_changed', playlist=result) + return result + + return None def delete(self, uri): """ @@ -142,10 +173,19 @@ class PlaylistsController(object): :param uri: URI of the playlist to delete :type uri: string """ + validation.check_uri(uri) + uri_scheme = urlparse.urlparse(uri).scheme backend = self.backends.with_playlists.get(uri_scheme, None) - if backend: + if not backend: + return None # TODO: error reporting to user + + with _backend_error_handling(backend): backend.playlists.delete(uri).get() + # TODO: error detection and reporting to user + listener.CoreListener.send('playlist_deleted', uri=uri) + + # TODO: return value? def filter(self, criteria=None, **kwargs): """ @@ -155,15 +195,12 @@ class PlaylistsController(object): # Returns track with name 'a' filter({'name': 'a'}) - filter(name='a') # Returns track with URI 'xyz' filter({'uri': 'xyz'}) - filter(uri='xyz') # Returns track with name 'a' and URI 'xyz' filter({'name': 'a', 'uri': 'xyz'}) - filter(name='a', uri='xyz') :param criteria: one or more criteria to match by :type criteria: dict @@ -172,8 +209,13 @@ class PlaylistsController(object): .. deprecated:: 1.0 Use :meth:`as_list` and filter yourself. """ + deprecation.warn('core.playlists.filter') + criteria = criteria or kwargs - matches = self.playlists + validation.check_query( + criteria, validation.PLAYLIST_FIELDS, list_values=False) + + matches = self.playlists # TODO: stop using self playlists for (key, value) in criteria.iteritems(): matches = filter(lambda p: getattr(p, key) == value, matches) return matches @@ -189,11 +231,18 @@ class PlaylistsController(object): """ uri_scheme = urlparse.urlparse(uri).scheme backend = self.backends.with_playlists.get(uri_scheme, None) - if backend: - return backend.playlists.lookup(uri).get() - else: + if not backend: return None + with _backend_error_handling(backend): + playlist = backend.playlists.lookup(uri).get() + playlist is None or validation.check_instance(playlist, Playlist) + return playlist + + return None + + # TODO: there is an inconsistency between library.refresh(uri) and this + # call, not sure how to sort this out. def refresh(self, uri_scheme=None): """ Refresh the playlists in :attr:`playlists`. @@ -206,16 +255,26 @@ class PlaylistsController(object): :param uri_scheme: limit to the backend matching the URI scheme :type uri_scheme: string """ - if uri_scheme is None: - futures = [b.playlists.refresh() - for b in self.backends.with_playlists.values()] - pykka.get_all(futures) + # TODO: check: uri_scheme is None or uri_scheme? + + futures = {} + backends = {} + playlists_loaded = False + + for backend_scheme, backend in self.backends.with_playlists.items(): + backends.setdefault(backend, set()).add(backend_scheme) + + for backend, backend_schemes in backends.items(): + if uri_scheme is None or uri_scheme in backend_schemes: + futures[backend] = backend.playlists.refresh() + + for backend, future in futures.items(): + with _backend_error_handling(backend): + future.get() + playlists_loaded = True + + if playlists_loaded: listener.CoreListener.send('playlists_loaded') - else: - backend = self.backends.with_playlists.get(uri_scheme, None) - if backend: - backend.playlists.refresh().get() - listener.CoreListener.send('playlists_loaded') def save(self, playlist): """ @@ -239,11 +298,23 @@ class PlaylistsController(object): :type playlist: :class:`mopidy.models.Playlist` :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ + validation.check_instance(playlist, Playlist) + if playlist.uri is None: - return + return # TODO: log this problem? + uri_scheme = urlparse.urlparse(playlist.uri).scheme backend = self.backends.with_playlists.get(uri_scheme, None) - if backend: + if not backend: + return None + + # TODO: we let AssertionError error through due to legacy tests :/ + with _backend_error_handling(backend, reraise=AssertionError): playlist = backend.playlists.save(playlist).get() - listener.CoreListener.send('playlist_changed', playlist=playlist) + playlist is None or validation.check_instance(playlist, Playlist) + if playlist: + listener.CoreListener.send( + 'playlist_changed', playlist=playlist) return playlist + + return None diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 9186ae42..db4e2a69 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -1,14 +1,12 @@ from __future__ import absolute_import, unicode_literals -import collections import logging import random -from mopidy import compat +from mopidy import exceptions from mopidy.core import listener -from mopidy.models import TlTrack -from mopidy.utils.deprecation import deprecated_property - +from mopidy.internal import deprecation, validation +from mopidy.models import TlTrack, Track logger = logging.getLogger(__name__) @@ -30,7 +28,7 @@ class TracklistController(object): """Get tracklist as list of :class:`mopidy.models.TlTrack`.""" return self._tl_tracks[:] - tl_tracks = deprecated_property(get_tl_tracks) + tl_tracks = deprecation.deprecated_property(get_tl_tracks) """ .. deprecated:: 1.0 Use :meth:`get_tl_tracks` instead. @@ -40,7 +38,7 @@ class TracklistController(object): """Get tracklist as list of :class:`mopidy.models.Track`.""" return [tl_track.track for tl_track in self._tl_tracks] - tracks = deprecated_property(get_tracks) + tracks = deprecation.deprecated_property(get_tracks) """ .. deprecated:: 1.0 Use :meth:`get_tracks` instead. @@ -50,7 +48,7 @@ class TracklistController(object): """Get length of the tracklist.""" return len(self._tl_tracks) - length = deprecated_property(get_length) + length = deprecation.deprecated_property(get_length) """ .. deprecated:: 1.0 Use :meth:`get_length` instead. @@ -70,7 +68,7 @@ class TracklistController(object): self.core.playback._on_tracklist_change() self._trigger_tracklist_changed() - version = deprecated_property(get_version) + version = deprecation.deprecated_property(get_version) """ .. deprecated:: 1.0 Use :meth:`get_version` instead. @@ -94,11 +92,12 @@ class TracklistController(object): :class:`False` Tracks are not removed from the tracklist. """ + validation.check_boolean(value) if self.get_consume() != value: self._trigger_options_changed() return setattr(self, '_consume', value) - consume = deprecated_property(get_consume, set_consume) + consume = deprecation.deprecated_property(get_consume, set_consume) """ .. deprecated:: 1.0 Use :meth:`get_consume` and :meth:`set_consume` instead. @@ -122,7 +121,7 @@ class TracklistController(object): :class:`False` Tracks are played in the order of the tracklist. """ - + validation.check_boolean(value) if self.get_random() != value: self._trigger_options_changed() if value: @@ -130,7 +129,7 @@ class TracklistController(object): random.shuffle(self._shuffled) return setattr(self, '_random', value) - random = deprecated_property(get_random, set_random) + random = deprecation.deprecated_property(get_random, set_random) """ .. deprecated:: 1.0 Use :meth:`get_random` and :meth:`set_random` instead. @@ -158,12 +157,12 @@ class TracklistController(object): :class:`False` The tracklist is played once. """ - + validation.check_boolean(value) if self.get_repeat() != value: self._trigger_options_changed() return setattr(self, '_repeat', value) - repeat = deprecated_property(get_repeat, set_repeat) + repeat = deprecation.deprecated_property(get_repeat, set_repeat) """ .. deprecated:: 1.0 Use :meth:`get_repeat` and :meth:`set_repeat` instead. @@ -189,11 +188,12 @@ class TracklistController(object): :class:`False` Playback continues after current song. """ + validation.check_boolean(value) if self.get_single() != value: self._trigger_options_changed() return setattr(self, '_single', value) - single = deprecated_property(get_single, set_single) + single = deprecation.deprecated_property(get_single, set_single) """ .. deprecated:: 1.0 Use :meth:`get_single` and :meth:`set_single` instead. @@ -201,18 +201,52 @@ class TracklistController(object): # Methods - def index(self, tl_track): + def index(self, tl_track=None, tlid=None): """ The position of the given track in the tracklist. + If neither *tl_track* or *tlid* is given we return the index of + the currently playing track. + :param tl_track: the track to find the index of - :type tl_track: :class:`mopidy.models.TlTrack` + :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` + :param tlid: TLID of the track to find the index of + :type tlid: :class:`int` or :class:`None` :rtype: :class:`int` or :class:`None` + + .. versionadded:: 1.1 + The *tlid* parameter """ - try: - return self._tl_tracks.index(tl_track) - except ValueError: - return None + tl_track is None or validation.check_instance(tl_track, TlTrack) + tlid is None or validation.check_integer(tlid, min=0) + + if tl_track is None and tlid is None: + tl_track = self.core.playback.get_current_tl_track() + + if tl_track is not None: + try: + return self._tl_tracks.index(tl_track) + except ValueError: + pass + elif tlid is not None: + for i, tl_track in enumerate(self._tl_tracks): + if tl_track.tlid == tlid: + return i + return None + + def get_eot_tlid(self): + """ + The TLID of the track that will be played after the given track. + + Not necessarily the same TLID as returned by :meth:`get_next_tlid`. + + :rtype: :class:`int` or :class:`None` + + .. versionadded:: 1.1 + """ + + current_tl_track = self.core.playback.get_current_tl_track() + return getattr(self.eot_track(current_tl_track), 'tlid', None) def eot_track(self, tl_track): """ @@ -224,6 +258,8 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ + deprecation.warn('core.tracklist.eot_track', pending=True) + tl_track is None or validation.check_instance(tl_track, TlTrack) if self.get_single() and self.get_repeat(): return tl_track elif self.get_single(): @@ -234,6 +270,23 @@ class TracklistController(object): # shared. return self.next_track(tl_track) + def get_next_tlid(self): + """ + The tlid of the track that will be played if calling + :meth:`mopidy.core.PlaybackController.next()`. + + For normal playback this is the next track in the tracklist. If repeat + is enabled the next track can loop around the tracklist. When random is + enabled this should be a random track, all tracks should be played once + before the tracklist repeats. + + :rtype: :class:`int` or :class:`None` + + .. versionadded:: 1.1 + """ + current_tl_track = self.core.playback.get_current_tl_track() + return getattr(self.next_track(current_tl_track), 'tlid', None) + def next_track(self, tl_track): """ The track that will be played if calling @@ -248,34 +301,51 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ + deprecation.warn('core.tracklist.next_track', pending=True) + tl_track is None or validation.check_instance(tl_track, TlTrack) - if not self.get_tl_tracks(): + if not self._tl_tracks: return None if self.get_random() and not self._shuffled: if self.get_repeat() or not tl_track: logger.debug('Shuffling tracks') - self._shuffled = self.get_tl_tracks() + self._shuffled = self._tl_tracks[:] random.shuffle(self._shuffled) if self.get_random(): - try: + if self._shuffled: return self._shuffled[0] - except IndexError: - return None + return None if tl_track is None: - return self.get_tl_tracks()[0] + next_index = 0 + else: + next_index = self.index(tl_track) + 1 - next_index = self.index(tl_track) + 1 if self.get_repeat(): - next_index %= len(self.get_tl_tracks()) - - try: - return self.get_tl_tracks()[next_index] - except IndexError: + next_index %= len(self._tl_tracks) + elif next_index >= len(self._tl_tracks): return None + return self._tl_tracks[next_index] + + def get_previous_tlid(self): + """ + Returns the TLID of the track that will be played if calling + :meth:`mopidy.core.PlaybackController.previous()`. + + For normal playback this is the previous track in the tracklist. If + random and/or consume is enabled it should return the current track + instead. + + :rtype: :class:`int` or :class:`None` + + .. versionadded:: 1.1 + """ + current_tl_track = self.core.playback.get_current_tl_track() + return getattr(self.previous_track(current_tl_track), 'tlid', None) + def previous_track(self, tl_track): """ Returns the track that will be played if calling @@ -289,6 +359,9 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ + deprecation.warn('core.tracklist.previous_track', pending=True) + tl_track is None or validation.check_instance(tl_track, TlTrack) + if self.get_repeat() or self.get_consume() or self.get_random(): return tl_track @@ -297,30 +370,35 @@ class TracklistController(object): if position in (None, 0): return None - return self.get_tl_tracks()[position - 1] + # Since we know we are not at zero we have to be somewhere in the range + # 1 - len(tracks) Thus 'position - 1' will always be within the list. + return self._tl_tracks[position - 1] def add(self, tracks=None, at_position=None, uri=None, uris=None): """ - Add the track or list of tracks to the tracklist. + Add 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 ``uris`` is given instead of ``uri`` or ``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. + If ``at_position`` is given, the tracks are inserted at the given + position in the tracklist. If ``at_position`` is not given, the tracks + are appended to the end of the tracklist. Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. :param tracks: tracks to add - :type tracks: list of :class:`mopidy.models.Track` - :param at_position: position in tracklist to add track + :type tracks: list of :class:`mopidy.models.Track` or :class:`None` + :param at_position: position in tracklist to add tracks :type at_position: int or :class:`None` :param uri: URI for tracks to add - :type uri: string + :type uri: string or :class:`None` + :param uris: list of URIs for tracks to add + :type uris: list of string or :class:`None` :rtype: list of :class:`mopidy.models.TlTrack` .. versionadded:: 1.0 @@ -329,21 +407,38 @@ class TracklistController(object): .. deprecated:: 1.0 The ``tracks`` and ``uri`` arguments. Use ``uris``. """ - assert tracks is not None or uri is not None or uris is not None, \ - 'tracks, uri or uris must be provided' + if sum(o is not None for o in [tracks, uri, uris]) != 1: + raise ValueError( + 'Exactly one of "tracks", "uri" or "uris" must be set') + + tracks is None or validation.check_instances(tracks, Track) + uri is None or validation.check_uri(uri) + uris is None or validation.check_uris(uris) + validation.check_integer(at_position or 0) + + 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: - tracks = self.core.library.lookup(uri=uri) - elif uris is not None: - tracks = [] - track_map = self.core.library.lookup(uris=uris) - for uri in uris: - tracks.extend(track_map[uri]) + uris = [uri] + + tracks = [] + track_map = self.core.library.lookup(uris=uris) + for uri in uris: + tracks.extend(track_map[uri]) tl_tracks = [] + max_length = self.core._config['core']['max_tracklist_length'] for track in tracks: + if self.get_length() >= max_length: + raise exceptions.TracklistFull( + 'Tracklist may contain at most %d tracks.' % max_length) + tl_track = TlTrack(self._next_tlid, track) self._next_tlid += 1 if at_position is not None: @@ -381,41 +476,35 @@ class TracklistController(object): # Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID) filter({'tlid': [1, 2, 3, 4]}) - filter(tlid=[1, 2, 3, 4]) - - # Returns track with IDs 1, 5, or 7 - filter({'id': [1, 5, 7]}) - filter(id=[1, 5, 7]) # Returns track with URIs 'xyz' or 'abc' filter({'uri': ['xyz', 'abc']}) - filter(uri=['xyz', 'abc']) - # Returns tracks with ID 1 and URI 'xyz' - filter({'id': [1], 'uri': ['xyz']}) - filter(id=[1], uri=['xyz']) - - # Returns track with a matching ID (1, 3 or 6) and a matching URI - # ('xyz' or 'abc') - filter({'id': [1, 3, 6], 'uri': ['xyz', 'abc']}) - filter(id=[1, 3, 6], uri=['xyz', 'abc']) + # Returns track with a matching TLIDs (1, 3 or 6) and a + # matching URI ('xyz' or 'abc') + filter({'tlid': [1, 3, 6], 'uri': ['xyz', 'abc']}) :param criteria: on or more criteria to match by :type criteria: dict, of (string, list) pairs :rtype: list of :class:`mopidy.models.TlTrack` + + .. deprecated:: 1.1 + Providing the criteria via ``kwargs``. """ + if kwargs: + deprecation.warn('core.tracklist.filter:kwargs_criteria') + criteria = criteria or kwargs + tlids = criteria.pop('tlid', []) + validation.check_query(criteria, validation.TRACKLIST_FIELDS) + validation.check_instances(tlids, int) + matches = self._tl_tracks for (key, values) in criteria.items(): - if (not isinstance(values, collections.Iterable) or - isinstance(values, compat.string_types)): - # Fail hard if anyone is using the <0.17 calling style - raise ValueError('Filter values must be iterable: %r' % values) - if key == 'tlid': - matches = [ct for ct in matches if ct.tlid in values] - else: - matches = [ - ct for ct in matches if getattr(ct.track, key) in values] + matches = [ + ct for ct in matches if getattr(ct.track, key) in values] + if tlids: + matches = [ct for ct in matches if ct.tlid in tlids] return matches def move(self, start, end, to_position): @@ -436,6 +525,7 @@ class TracklistController(object): tl_tracks = self._tl_tracks + # TODO: use validation helpers? assert start < end, 'start must be smaller than end' assert start >= 0, 'start must be at least zero' assert end <= len(tl_tracks), \ @@ -462,8 +552,14 @@ class TracklistController(object): :param criteria: on or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.TlTrack` that was removed + + .. deprecated:: 1.1 + Providing the criteria via ``kwargs``. """ - tl_tracks = self.filter(criteria, **kwargs) + if kwargs: + deprecation.warn('core.tracklist.remove:kwargs_criteria') + + tl_tracks = self.filter(criteria or kwargs) for tl_track in tl_tracks: position = self._tl_tracks.index(tl_track) del self._tl_tracks[position] @@ -484,6 +580,7 @@ class TracklistController(object): """ tl_tracks = self._tl_tracks + # TOOD: use validation helpers? if start is not None and end is not None: assert start < end, 'start must be smaller than end' @@ -512,6 +609,7 @@ class TracklistController(object): :type end: int :rtype: :class:`mopidy.models.TlTrack` """ + # TODO: validate slice? return self._tl_tracks[start:end] def _mark_playing(self, tl_track): @@ -528,13 +626,13 @@ class TracklistController(object): 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]) + self.remove({'tlid': [tl_track.tlid]}) return True return False def _trigger_tracklist_changed(self): if self.get_random(): - self._shuffled = self.get_tl_tracks() + self._shuffled = self._tl_tracks[:] random.shuffle(self._shuffled) else: self._shuffled = [] diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 4c4a0f6d..4aa66e63 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 @@ -20,11 +21,19 @@ class BackendError(MopidyException): pass +class CoreError(MopidyException): + + def __init__(self, message, errno=None): + super(CoreError, self).__init__(message, errno) + self.errno = errno + + class ExtensionError(MopidyException): pass class FindError(MopidyException): + def __init__(self, message, errno=None): super(FindError, self).__init__(message, errno) self.errno = errno @@ -42,5 +51,16 @@ class ScannerError(MopidyException): pass +class TracklistFull(CoreError): + + def __init__(self, message, errno=None): + super(TracklistFull, self).__init__(message, errno) + self.errno = errno + + class AudioException(MopidyException): pass + + +class ValidationError(ValueError): + pass diff --git a/mopidy/ext.py b/mopidy/ext.py index 2f02c43b..908b6d5d 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -2,16 +2,25 @@ from __future__ import absolute_import, unicode_literals import collections import logging +import os import pkg_resources from mopidy import config as config_lib, exceptions +from mopidy.internal import path logger = logging.getLogger(__name__) +_extension_data_fields = ['extension', 'entry_point', 'config_schema', + 'config_defaults', 'command'] + +ExtensionData = collections.namedtuple('ExtensionData', _extension_data_fields) + + class Extension(object): + """Base class for Mopidy extensions""" dist_name = None @@ -51,6 +60,42 @@ class Extension(object): schema['enabled'] = config_lib.Boolean() return schema + def get_cache_dir(self, config): + """Get or create cache directory for the extension. + + :param config: the Mopidy config object + :return: string + """ + assert self.ext_name is not None + cache_dir_path = bytes(os.path.join(config['core']['cache_dir'], + self.ext_name)) + path.get_or_create_dir(cache_dir_path) + return cache_dir_path + + def get_config_dir(self, config): + """Get or create configuration directory for the extension. + + :param config: the Mopidy config object + :return: string + """ + assert self.ext_name is not None + config_dir_path = bytes(os.path.join(config['core']['config_dir'], + self.ext_name)) + path.get_or_create_dir(config_dir_path) + return config_dir_path + + def get_data_dir(self, config): + """Get or create data directory for the extension. + + :param config: the Mopidy config object + :returns: string + """ + assert self.ext_name is not None + data_dir_path = bytes(os.path.join(config['core']['data_dir'], + self.ext_name)) + path.get_or_create_dir(data_dir_path) + return data_dir_path + def get_command(self): """Command to expose to command line users running ``mopidy``. @@ -88,14 +133,7 @@ class Extension(object): the ``frontend`` and ``backend`` registry keys. This method can also be used for other setup tasks not involving the - extension registry. For example, to register custom GStreamer - elements:: - - def setup(self, registry): - from .mixer import SoundspotMixer - gobject.type_register(SoundspotMixer) - gst.element_register( - SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) + extension registry. :param registry: the extension registry :type registry: :class:`Registry` @@ -104,6 +142,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 @@ -153,55 +192,100 @@ def load_extensions(): for entry_point in pkg_resources.iter_entry_points('mopidy.ext'): logger.debug('Loading entry point: %s', entry_point) extension_class = entry_point.load(require=False) - extension = extension_class() - extension.entry_point = entry_point - installed_extensions.append(extension) + + try: + if not issubclass(extension_class, Extension): + raise TypeError # issubclass raises TypeError on non-class + except TypeError: + logger.error('Entry point %s did not contain a valid extension' + 'class: %r', entry_point.name, extension_class) + continue + + try: + extension = extension_class() + config_schema = extension.get_config_schema() + default_config = extension.get_default_config() + command = extension.get_command() + except Exception: + logger.exception('Setup of extension from entry point %s failed, ' + 'ignoring extension.', entry_point.name) + continue + + installed_extensions.append(ExtensionData( + extension, entry_point, config_schema, default_config, command)) + logger.debug( 'Loaded extension: %s %s', extension.dist_name, extension.version) - names = (e.ext_name for e in installed_extensions) + names = (ed.extension.ext_name for ed in installed_extensions) logger.debug('Discovered extensions: %s', ', '.join(names)) return installed_extensions -def validate_extension(extension): +def validate_extension_data(data): """Verify extension's dependencies and environment. :param extensions: an extension to check :returns: if extension should be run """ - logger.debug('Validating extension: %s', extension.ext_name) + logger.debug('Validating extension: %s', data.extension.ext_name) - if extension.ext_name != extension.entry_point.name: + if data.extension.ext_name != data.entry_point.name: logger.warning( 'Disabled extension %(ep)s: entry point name (%(ep)s) ' 'does not match extension name (%(ext)s)', - {'ep': extension.entry_point.name, 'ext': extension.ext_name}) + {'ep': data.entry_point.name, 'ext': data.extension.ext_name}) return False try: - extension.entry_point.require() + data.entry_point.require() except pkg_resources.DistributionNotFound as ex: logger.info( 'Disabled extension %s: Dependency %s not found', - extension.ext_name, ex) + data.extension.ext_name, ex) return False except pkg_resources.VersionConflict as ex: if len(ex.args) == 2: found, required = ex.args logger.info( 'Disabled extension %s: %s required, but found %s at %s', - extension.ext_name, required, found, found.location) + data.extension.ext_name, required, found, found.location) else: - logger.info('Disabled extension %s: %s', extension.ext_name, ex) + logger.info( + 'Disabled extension %s: %s', data.extension.ext_name, ex) return False try: - extension.validate_environment() + data.extension.validate_environment() except exceptions.ExtensionError as ex: logger.info( - 'Disabled extension %s: %s', extension.ext_name, ex.message) + 'Disabled extension %s: %s', data.extension.ext_name, ex.message) + return False + except Exception: + logger.exception('Validating extension %s failed with an exception.', + data.extension.ext_name) + return False + + if not data.config_schema: + logger.error('Extension %s does not have a config schema, disabling.', + data.extension.ext_name) + return False + elif not isinstance(data.config_schema.get('enabled'), config_lib.Boolean): + logger.error('Extension %s does not have the required "enabled" config' + ' option, disabling.', data.extension.ext_name) + return False + + for key, value in data.config_schema.items(): + if not isinstance(value, config_lib.ConfigValue): + logger.error('Extension %s config schema contains an invalid value' + ' for the option "%s", disabling.', + data.extension.ext_name, key) + return False + + if not data.config_defaults: + logger.error('Extension %s does not have a default config, disabling.', + data.extension.ext_name) return False return True diff --git a/mopidy/file/__init__.py b/mopidy/file/__init__.py new file mode 100644 index 00000000..ea4dea12 --- /dev/null +++ b/mopidy/file/__init__.py @@ -0,0 +1,32 @@ +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-File' + ext_name = 'file' + 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['media_dirs'] = config.List(optional=True) + schema['show_dotfiles'] = config.Boolean(optional=True) + schema['follow_symlinks'] = config.Boolean(optional=True) + schema['metadata_timeout'] = config.Integer(optional=True) + return schema + + def setup(self, registry): + from .backend import FileBackend + registry.add('backend', FileBackend) diff --git a/mopidy/file/backend.py b/mopidy/file/backend.py new file mode 100644 index 00000000..bc5af48b --- /dev/null +++ b/mopidy/file/backend.py @@ -0,0 +1,21 @@ +from __future__ import absolute_import, unicode_literals + +import logging + +import pykka + +from mopidy import backend +from mopidy.file import library + + +logger = logging.getLogger(__name__) + + +class FileBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes = ['file'] + + def __init__(self, config, audio): + super(FileBackend, self).__init__() + self.library = library.FileLibraryProvider(backend=self, config=config) + self.playback = backend.PlaybackProvider(audio=audio, backend=self) + self.playlists = None diff --git a/mopidy/file/ext.conf b/mopidy/file/ext.conf new file mode 100644 index 00000000..486619a1 --- /dev/null +++ b/mopidy/file/ext.conf @@ -0,0 +1,8 @@ +[file] +enabled = true +media_dirs = + $XDG_MUSIC_DIR|Music + ~/|Home +show_dotfiles = false +follow_symlinks = false +metadata_timeout = 1000 diff --git a/mopidy/file/library.py b/mopidy/file/library.py new file mode 100644 index 00000000..10586561 --- /dev/null +++ b/mopidy/file/library.py @@ -0,0 +1,149 @@ +from __future__ import unicode_literals + +import logging +import operator +import os +import sys +import urllib2 + +from mopidy import backend, exceptions, models +from mopidy.audio import scan, utils +from mopidy.internal import path + + +logger = logging.getLogger(__name__) +FS_ENCODING = sys.getfilesystemencoding() + + +class FileLibraryProvider(backend.LibraryProvider): + """Library for browsing local files.""" + + # TODO: get_images that can pull from metadata and/or .folder.png etc? + # TODO: handle playlists? + + @property + def root_directory(self): + if not self._media_dirs: + return None + elif len(self._media_dirs) == 1: + uri = path.path_to_uri(self._media_dirs[0]['path']) + else: + uri = 'file:root' + return models.Ref.directory(name='Files', uri=uri) + + def __init__(self, backend, config): + super(FileLibraryProvider, self).__init__(backend) + self._media_dirs = list(self._get_media_dirs(config)) + self._follow_symlinks = config['file']['follow_symlinks'] + self._show_dotfiles = config['file']['show_dotfiles'] + self._scanner = scan.Scanner( + timeout=config['file']['metadata_timeout']) + + def browse(self, uri): + logger.debug('Browsing files at: %s', uri) + result = [] + local_path = path.uri_to_path(uri) + + if local_path == 'root': + return list(self._get_media_dirs_refs()) + + if not self._is_in_basedir(os.path.realpath(local_path)): + logger.warning( + 'Rejected attempt to browse path (%s) outside dirs defined ' + 'in file/media_dirs config.', uri) + return [] + + for dir_entry in os.listdir(local_path): + child_path = os.path.join(local_path, dir_entry) + uri = path.path_to_uri(child_path) + + if not self._show_dotfiles and dir_entry.startswith(b'.'): + continue + + if os.path.islink(child_path) and not self._follow_symlinks: + logger.debug('Ignoring symlink: %s', uri) + continue + + if not self._is_in_basedir(os.path.realpath(child_path)): + logger.debug('Ignoring symlink to outside base dir: %s', uri) + continue + + name = dir_entry.decode(FS_ENCODING, 'replace') + if os.path.isdir(child_path): + result.append(models.Ref.directory(name=name, uri=uri)) + elif os.path.isfile(child_path) and self._is_audio_file(uri): + result.append(models.Ref.track(name=name, uri=uri)) + + result.sort(key=operator.attrgetter('name')) + return result + + def lookup(self, uri): + logger.debug('Looking up file URI: %s', uri) + local_path = path.uri_to_path(uri) + + if not self._is_in_basedir(local_path): + logger.warning('Ignoring URI outside base dir: %s', local_path) + return [] + + try: + 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('Failed looking up %s: %s', uri, e) + track = models.Track(uri=uri) + + if not track.name: + filename = os.path.basename(local_path) + name = urllib2.unquote(filename).decode(FS_ENCODING, 'replace') + track = track.copy(name=name) + + return [track] + + def _get_media_dirs(self, config): + for entry in config['file']['media_dirs']: + media_dir = {} + media_dir_split = entry.split('|', 1) + local_path = path.expand_path( + media_dir_split[0].encode(FS_ENCODING)) + + if not local_path: + logger.warning('Failed expanding path (%s) from' + 'file/media_dirs config value.', + media_dir_split[0]) + continue + elif not os.path.isdir(local_path): + logger.warning('%s is not a directory', local_path) + continue + + media_dir['path'] = local_path + if len(media_dir_split) == 2: + media_dir['name'] = media_dir_split[1] + else: + # TODO Mpd client should accept / in dir name + media_dir['name'] = media_dir_split[0].replace(os.sep, '+') + + yield media_dir + + def _get_media_dirs_refs(self): + for media_dir in self._media_dirs: + yield models.Ref.directory( + name=media_dir['name'], + uri=path.path_to_uri(media_dir['path'])) + + def _is_audio_file(self, uri): + try: + result = self._scanner.scan(uri) + if result.playable: + logger.debug('Playable file: %s', result.uri) + else: + logger.debug('Unplayable file: %s (not audio)', result.uri) + return result.playable + except exceptions.ScannerError as e: + logger.debug('Unplayable file: %s (%s)', uri, e) + return False + + def _is_in_basedir(self, local_path): + return any( + path.is_path_inside_base_dir(local_path, media_dir['path']) + for media_dir in self._media_dirs) diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py index 200ef833..5fe29134 100644 --- a/mopidy/http/actor.py +++ b/mopidy/http/actor.py @@ -16,7 +16,7 @@ import tornado.websocket from mopidy import exceptions, models, zeroconf from mopidy.core import CoreListener from mopidy.http import handlers -from mopidy.utils import encoding, formatting, network +from mopidy.internal import encoding, formatting, network logger = logging.getLogger(__name__) diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 6bbfcbab..a752a4f0 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -12,7 +12,7 @@ import tornado.websocket import mopidy from mopidy import core, models -from mopidy.utils import encoding, jsonrpc +from mopidy.internal import encoding, jsonrpc logger = logging.getLogger(__name__) @@ -157,6 +157,7 @@ def set_mopidy_headers(request_handler): class JsonRpcHandler(tornado.web.RequestHandler): + def initialize(self, core): self.jsonrpc = make_jsonrpc_wrapper(core) @@ -191,6 +192,7 @@ class JsonRpcHandler(tornado.web.RequestHandler): class ClientListHandler(tornado.web.RequestHandler): + def initialize(self, apps, statics): self.apps = apps self.statics = statics @@ -212,6 +214,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/httpclient.py b/mopidy/httpclient.py new file mode 100644 index 00000000..682a78bd --- /dev/null +++ b/mopidy/httpclient.py @@ -0,0 +1,52 @@ +from __future__ import unicode_literals + +import platform + +import mopidy + +"Helpers for configuring HTTP clients used in Mopidy extensions." + + +def format_proxy(proxy_config, auth=True): + """Convert a Mopidy proxy config to the commonly used proxy string format. + + Outputs ``scheme://host:port``, ``scheme://user:pass@host:port`` or + :class:`None` depending on the proxy config provided. + + You can also opt out of getting the basic auth by setting ``auth`` to + :class:`False`. + + .. versionadded:: 1.1 + """ + if not proxy_config.get('hostname'): + return None + + port = proxy_config.get('port', 80) + if port < 0: + port = 80 + + if proxy_config.get('username') and proxy_config.get('password') and auth: + template = '{scheme}://{username}:{password}@{hostname}:{port}' + else: + template = '{scheme}://{hostname}:{port}' + + return template.format(scheme=proxy_config.get('scheme') or 'http', + username=proxy_config.get('username'), + password=proxy_config.get('password'), + hostname=proxy_config['hostname'], port=port) + + +def format_user_agent(name=None): + """Construct a User-Agent suitable for use in client code. + + This will identify use by the provided ``name`` (which should be on the + format ``dist_name/version``), Mopidy version and Python version. + + .. versionadded:: 1.1 + """ + parts = ['Mopidy/%s' % (mopidy.__version__), + '%s/%s' % (platform.python_implementation(), + platform.python_version())] + if name: + parts.insert(0, name) + return ' '.join(parts) diff --git a/mopidy/utils/__init__.py b/mopidy/internal/__init__.py similarity index 100% rename from mopidy/utils/__init__.py rename to mopidy/internal/__init__.py diff --git a/mopidy/internal/deprecation.py b/mopidy/internal/deprecation.py new file mode 100644 index 00000000..7b1b915e --- /dev/null +++ b/mopidy/internal/deprecation.py @@ -0,0 +1,100 @@ +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', + 'core.playback.play:tl_track_kwargs': + 'playback.play() with "tl_track" argument is pending deprecation use ' + '"tlid" instead', + + # 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', + 'core.tracklist.filter:kwargs_criteria': + 'tracklist.filter() with "kwargs" as criteria is deprecated', + 'core.tracklist.remove:kwargs_criteria': + 'tracklist.remove() with "kwargs" as criteria is deprecated', + + 'core.tracklist.eot_track': + 'tracklist.eot_track() is pending deprecation, use ' + 'tracklist.get_eot_tlid()', + 'core.tracklist.next_track': + 'tracklist.next_track() is pending deprecation, use ' + 'tracklist.get_next_tlid()', + 'core.tracklist.previous_track': + 'tracklist.previous_track() is pending deprecation, use ' + 'tracklist.get_previous_tlid()', + + 'models.immutable.copy': + 'ImmutableObject.copy() is deprecated, use ImmutableObject.replace()', +} + + +def warn(msg_id, pending=False): + if pending: + category = PendingDeprecationWarning + else: + category = DeprecationWarning + warnings.warn(_MESSAGES.get(msg_id, msg_id), category) + + +@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/internal/deps.py similarity index 99% rename from mopidy/utils/deps.py rename to mopidy/internal/deps.py index aafede9d..1f363657 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/internal/deps.py @@ -11,7 +11,7 @@ import pygst pygst.require('0.10') import gst # noqa -from mopidy.utils import formatting +from mopidy.internal import formatting def format_dependency_list(adapters=None): diff --git a/mopidy/utils/encoding.py b/mopidy/internal/encoding.py similarity index 100% rename from mopidy/utils/encoding.py rename to mopidy/internal/encoding.py diff --git a/mopidy/utils/formatting.py b/mopidy/internal/formatting.py similarity index 100% rename from mopidy/utils/formatting.py rename to mopidy/internal/formatting.py diff --git a/mopidy/internal/http.py b/mopidy/internal/http.py new file mode 100644 index 00000000..6ff59590 --- /dev/null +++ b/mopidy/internal/http.py @@ -0,0 +1,16 @@ +from __future__ import absolute_import, unicode_literals + +import requests + +from mopidy import httpclient + + +def get_requests_session(proxy_config, user_agent): + proxy = httpclient.format_proxy(proxy_config) + full_user_agent = httpclient.format_user_agent(user_agent) + + session = requests.Session() + session.proxies.update({'http': proxy, 'https': proxy}) + session.headers.update({'user-agent': full_user_agent}) + + return session diff --git a/mopidy/utils/jsonrpc.py b/mopidy/internal/jsonrpc.py similarity index 99% rename from mopidy/utils/jsonrpc.py rename to mopidy/internal/jsonrpc.py index 13199b26..e567ef87 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/internal/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/internal/log.py similarity index 99% rename from mopidy/utils/log.py rename to mopidy/internal/log.py index d2dcca70..9c40da4f 100644 --- a/mopidy/utils/log.py +++ b/mopidy/internal/log.py @@ -21,6 +21,7 @@ logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE') class DelayedHandler(logging.Handler): + def __init__(self): logging.Handler.__init__(self) self._released = False @@ -101,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 @@ -123,6 +125,7 @@ COLORS = [b'black', b'red', b'green', b'yellow', b'blue', b'magenta', b'cyan', class ColorizingStreamHandler(logging.StreamHandler): + """ Stream handler which colorizes the log using ANSI escape sequences. diff --git a/mopidy/utils/network.py b/mopidy/internal/network.py similarity index 99% rename from mopidy/utils/network.py rename to mopidy/internal/network.py index f55649e3..4b8b35fe 100644 --- a/mopidy/utils/network.py +++ b/mopidy/internal/network.py @@ -11,13 +11,14 @@ import gobject import pykka -from mopidy.utils import encoding +from mopidy.internal import encoding 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, @@ -305,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/internal/path.py similarity index 84% rename from mopidy/utils/path.py rename to mopidy/internal/path.py index 8bca275d..8c560187 100644 --- a/mopidy/utils/path.py +++ b/mopidy/internal/path.py @@ -8,25 +8,15 @@ import threading import urllib import urlparse -import glib - from mopidy import compat, exceptions from mopidy.compat import queue -from mopidy.utils import encoding +from mopidy.internal import encoding, xdg logger = logging.getLogger(__name__) -XDG_DIRS = { - 'XDG_CACHE_DIR': glib.get_user_cache_dir(), - 'XDG_CONFIG_DIR': glib.get_user_config_dir(), - 'XDG_DATA_DIR': glib.get_user_data_dir(), - 'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC), -} - -# XDG_MUSIC_DIR can be none, so filter out any bad data. -XDG_DIRS = dict((k, v) for k, v in XDG_DIRS.items() if v is not None) +XDG_DIRS = xdg.get_dirs() def get_or_create_dir(dir_path): @@ -202,31 +192,33 @@ 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 * 1000)) for f, st in results.items()) + # return the mtimes as integer milliseconds + mtimes = {f: int(st.st_mtime * 1000) for f, st in results.items()} return mtimes, errors -def check_file_path_is_inside_base_dir(file_path, base_path): - assert not file_path.endswith(os.sep), ( - 'File path %s cannot end with a path separator' % file_path) - +def is_path_inside_base_dir(path, base_path): + if path.endswith(os.sep): + raise ValueError('Path %s cannot end with a path separator' + % path) # Expand symlinks real_base_path = os.path.realpath(base_path) - real_file_path = os.path.realpath(file_path) + real_path = os.path.realpath(path) - # Use dir of file for prefix comparision, so we don't accept - # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a - # common prefix, /tmp/foo, which matches the base path, /tmp/foo. - real_dir_path = os.path.dirname(real_file_path) + if os.path.isfile(path): + # Use dir of file for prefix comparision, so we don't accept + # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a + # common prefix, /tmp/foo, which matches the base path, /tmp/foo. + real_path = os.path.dirname(real_path) # Check if dir of file is the base path or a subdir - common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) - assert common_prefix == real_base_path, ( - 'File path %s must be in %s' % (real_file_path, real_base_path)) + common_prefix = os.path.commonprefix([real_base_path, real_path]) + return common_prefix == real_base_path # FIXME replace with mock usage in tests. class Mtime(object): + def __init__(self): self.fake = None diff --git a/mopidy/internal/playlists.py b/mopidy/internal/playlists.py new file mode 100644 index 00000000..219d3ec6 --- /dev/null +++ b/mopidy/internal/playlists.py @@ -0,0 +1,132 @@ +from __future__ import absolute_import, unicode_literals + +import io + +import pygst +pygst.require('0.10') +import gst # noqa + +from mopidy.compat import configparser +from mopidy.internal import validation + +try: + import xml.etree.cElementTree as elementtree +except ImportError: + import xml.etree.ElementTree as elementtree + + +def parse(data): + handlers = { + detect_extm3u_header: parse_extm3u, + detect_pls_header: parse_pls, + detect_asx_header: parse_asx, + detect_xspf_header: parse_xspf, + } + for detector, parser in handlers.items(): + if detector(data): + return list(parser(data)) + return parse_urilist(data) # Fallback + + +def detect_extm3u_header(data): + return data[0:7].upper() == b'#EXTM3U' + + +def detect_pls_header(data): + return data[0:10].lower() == b'[playlist]' + + +def detect_xspf_header(data): + data = data[0:150] + if b'xspf' not in data.lower(): + return False + + try: + data = io.BytesIO(data) + for event, element in elementtree.iterparse(data, events=(b'start',)): + return element.tag.lower() == '{http://xspf.org/ns/0/}playlist' + except elementtree.ParseError: + pass + return False + + +def detect_asx_header(data): + data = data[0:50] + if b'asx' not in data.lower(): + return False + + try: + data = io.BytesIO(data) + for event, element in elementtree.iterparse(data, events=(b'start',)): + return element.tag.lower() == 'asx' + except elementtree.ParseError: + pass + return False + + +def parse_extm3u(data): + # TODO: convert non URIs to file URIs. + found_header = False + for line in data.splitlines(): + if found_header or line.startswith(b'#EXTM3U'): + found_header = True + else: + continue + if not line.startswith(b'#') and line.strip(): + yield line.strip() + + +def parse_pls(data): + # TODO: convert non URIs to file URIs. + try: + cp = configparser.RawConfigParser() + cp.readfp(io.BytesIO(data)) + except configparser.Error: + return + + for section in cp.sections(): + if section.lower() != 'playlist': + continue + for i in range(cp.getint(section, 'numberofentries')): + yield cp.get(section, 'file%d' % (i + 1)) + + +def parse_xspf(data): + try: + # Last element will be root. + for event, element in elementtree.iterparse(io.BytesIO(data)): + element.tag = element.tag.lower() # normalize + except elementtree.ParseError: + return + + ns = 'http://xspf.org/ns/0/' + for track in element.iterfind('{%s}tracklist/{%s}track' % (ns, ns)): + yield track.findtext('{%s}location' % ns) + + +def parse_asx(data): + try: + # Last element will be root. + for event, element in elementtree.iterparse(io.BytesIO(data)): + element.tag = element.tag.lower() # normalize + except elementtree.ParseError: + return + + for ref in element.findall('entry/ref[@href]'): + yield ref.get('href', '').strip() + + for entry in element.findall('entry[@href]'): + yield entry.get('href', '').strip() + + +def parse_urilist(data): + result = [] + for line in data.splitlines(): + if not line.strip() or line.startswith('#'): + continue + try: + validation.check_uri(line) + except ValueError: + return [] + result.append(line) + return result diff --git a/mopidy/utils/process.py b/mopidy/internal/process.py similarity index 99% rename from mopidy/utils/process.py rename to mopidy/internal/process.py index 5b2bb9c0..e826e43c 100644 --- a/mopidy/utils/process.py +++ b/mopidy/internal/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/internal/timer.py similarity index 100% rename from mopidy/utils/timer.py rename to mopidy/internal/timer.py diff --git a/mopidy/internal/validation.py b/mopidy/internal/validation.py new file mode 100644 index 00000000..52acc64f --- /dev/null +++ b/mopidy/internal/validation.py @@ -0,0 +1,105 @@ +from __future__ import absolute_import, unicode_literals + +import collections +import urlparse + +from mopidy import compat, exceptions + +PLAYBACK_STATES = {'paused', 'stopped', 'playing'} + +SEARCH_FIELDS = { + 'uri', 'track_name', 'album', 'artist', 'albumartist', 'composer', + 'performer', 'track_no', 'genre', 'date', 'comment', 'any'} + +PLAYLIST_FIELDS = {'uri', 'name'} # TODO: add length and last_modified? + +TRACKLIST_FIELDS = { # TODO: add bitrate, length, disc_no, track_no, modified? + 'uri', 'name', 'genre', 'date', 'comment', 'musicbrainz_id'} + +DISTINCT_FIELDS = { + 'track', 'artist', 'albumartist', 'album', 'composer', 'performer', 'date', + 'genre'} + + +# TODO: _check_iterable(check, msg, **kwargs) + [check(a) for a in arg]? +def _check_iterable(arg, msg, **kwargs): + """Ensure we have an iterable which is not a string or an iterator""" + if isinstance(arg, compat.string_types): + raise exceptions.ValidationError(msg.format(arg=arg, **kwargs)) + elif not isinstance(arg, collections.Iterable): + raise exceptions.ValidationError(msg.format(arg=arg, **kwargs)) + elif iter(arg) is iter(arg): + raise exceptions.ValidationError(msg.format(arg=arg, **kwargs)) + + +def check_choice(arg, choices, msg='Expected one of {choices}, not {arg!r}'): + if arg not in choices: + raise exceptions.ValidationError(msg.format( + arg=arg, choices=tuple(choices))) + + +def check_boolean(arg, msg='Expected a boolean, not {arg!r}'): + check_instance(arg, bool, msg=msg) + + +def check_instance(arg, cls, msg='Expected a {name} instance, not {arg!r}'): + if not isinstance(arg, cls): + raise exceptions.ValidationError( + msg.format(arg=arg, name=cls.__name__)) + + +def check_instances(arg, cls, msg='Expected a list of {name}, not {arg!r}'): + _check_iterable(arg, msg, name=cls.__name__) + if not all(isinstance(instance, cls) for instance in arg): + raise exceptions.ValidationError( + msg.format(arg=arg, name=cls.__name__)) + + +def check_integer(arg, min=None, max=None): + if not isinstance(arg, (int, long)): + raise exceptions.ValidationError('Expected an integer, not %r' % arg) + elif min is not None and arg < min: + raise exceptions.ValidationError( + 'Expected number larger or equal to %d, not %r' % (min, arg)) + elif max is not None and arg > max: + raise exceptions.ValidationError( + 'Expected number smaller or equal to %d, not %r' % (max, arg)) + + +def check_query(arg, fields=SEARCH_FIELDS, list_values=True): + # TODO: normalize name -> track_name + # TODO: normalize value -> [value] + # TODO: normalize blank -> [] or just remove field? + # TODO: remove list_values? + + if not isinstance(arg, collections.Mapping): + raise exceptions.ValidationError( + 'Expected a query dictionary, not {arg!r}'.format(arg=arg)) + + for key, value in arg.items(): + check_choice(key, fields, msg='Expected query field to be one of ' + '{choices}, not {arg!r}') + if list_values: + msg = 'Expected "{key}" to be list of strings, not {arg!r}' + _check_iterable(value, msg, key=key) + [_check_query_value(key, v, msg) for v in value] + else: + _check_query_value( + key, value, 'Expected "{key}" to be a string, not {arg!r}') + + +def _check_query_value(key, arg, msg): + if not isinstance(arg, compat.string_types) or not arg.strip(): + raise exceptions.ValidationError(msg.format(arg=arg, key=key)) + + +def check_uri(arg, msg='Expected a valid URI, not {arg!r}'): + if not isinstance(arg, compat.string_types): + raise exceptions.ValidationError(msg.format(arg=arg)) + elif urlparse.urlparse(arg).scheme == '': + raise exceptions.ValidationError(msg.format(arg=arg)) + + +def check_uris(arg, msg='Expected a list of URIs, not {arg!r}'): + _check_iterable(arg, msg) + [check_uri(a, msg) for a in arg] diff --git a/mopidy/utils/versioning.py b/mopidy/internal/versioning.py similarity index 100% rename from mopidy/utils/versioning.py rename to mopidy/internal/versioning.py diff --git a/mopidy/internal/xdg.py b/mopidy/internal/xdg.py new file mode 100644 index 00000000..adb43f39 --- /dev/null +++ b/mopidy/internal/xdg.py @@ -0,0 +1,66 @@ +from __future__ import absolute_import, unicode_literals + +import ConfigParser as configparser +import io +import os + + +def get_dirs(): + """Returns a dict of all the known XDG Base Directories for the current user. + + The keys ``XDG_CACHE_DIR``, ``XDG_CONFIG_DIR``, and ``XDG_DATA_DIR`` is + always available. + + Additional keys, like ``XDG_MUSIC_DIR``, may be available if the + ``$XDG_CONFIG_DIR/user-dirs.dirs`` file exists and is parseable. + + See http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html + for the XDG Base Directory specification. + """ + + dirs = { + 'XDG_CACHE_DIR': ( + os.environ.get('XDG_CACHE_HOME') or + os.path.expanduser(b'~/.cache')), + 'XDG_CONFIG_DIR': ( + os.environ.get('XDG_CONFIG_HOME') or + os.path.expanduser(b'~/.config')), + 'XDG_DATA_DIR': ( + os.environ.get('XDG_DATA_HOME') or + os.path.expanduser(b'~/.local/share')), + } + + dirs.update(_get_user_dirs(dirs['XDG_CONFIG_DIR'])) + + return dirs + + +def _get_user_dirs(xdg_config_dir): + """Returns a dict of XDG dirs read from + ``$XDG_CONFIG_HOME/user-dirs.dirs``. + + This is used at import time for most users of :mod:`mopidy`. By rolling our + own implementation instead of using :meth:`glib.get_user_special_dir` we + make it possible for many extensions to run their test suites, which are + importing parts of :mod:`mopidy`, in a virtualenv with global site-packages + disabled, and thus no :mod:`glib` available. + """ + + dirs_file = os.path.join(xdg_config_dir, 'user-dirs.dirs') + + if not os.path.exists(dirs_file): + return {} + + with open(dirs_file, 'rb') as fh: + data = fh.read() + + data = b'[XDG_USER_DIRS]\n' + data + data = data.replace(b'$HOME', os.path.expanduser(b'~')) + data = data.replace(b'"', b'') + + config = configparser.RawConfigParser() + config.readfp(io.BytesIO(data)) + + return { + k.decode('utf-8').upper(): os.path.abspath(v) + for k, v in config.items('XDG_USER_DIRS') if v is not None} diff --git a/mopidy/listener.py b/mopidy/listener.py index 286466a5..35bd8b73 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -2,14 +2,18 @@ from __future__ import absolute_import, unicode_literals import logging -import gobject - import pykka logger = logging.getLogger(__name__) def send_async(cls, event, **kwargs): + # This file is imported by mopidy.backends, which again is imported by all + # backend extensions. By importing modules that are not easily installable + # close to their use, we make some extensions able to run their tests in a + # virtualenv with global site-packages disabled. + import gobject + gobject.idle_add(lambda: send(cls, event, **kwargs)) @@ -35,6 +39,7 @@ def send(cls, event, **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 dedb8632..ff61c17c 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -48,6 +48,7 @@ class Extension(ext.Extension): class Library(object): + """ Local library interface. diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 4383decb..7033f3aa 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -7,8 +7,8 @@ import time from mopidy import commands, compat, exceptions from mopidy.audio import scan, utils +from mopidy.internal import path from mopidy.local import translator -from mopidy.utils import path logger = logging.getLogger(__name__) @@ -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()) @@ -142,7 +143,7 @@ class ScanCommand(commands.Command): uri, MIN_DURATION_MS) else: mtime = file_mtimes.get(os.path.join(media_dir, relpath)) - track = utils.convert_tags_to_track(tags).copy( + track = utils.convert_tags_to_track(tags).replace( uri=uri, length=duration, last_modified=mtime) if library.add_supports_tags_and_duration: library.add(track, tags=tags, duration=duration) @@ -164,6 +165,7 @@ class ScanCommand(commands.Command): class _Progress(object): + def __init__(self, batch_size, total): self.count = 0 self.batch_size = batch_size diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 0945f86f..0be5e99e 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -11,8 +11,8 @@ import tempfile import mopidy from mopidy import compat, local, models +from mopidy.internal import encoding, timer from mopidy.local import search, storage, translator -from mopidy.utils import encoding, timer logger = logging.getLogger(__name__) @@ -174,7 +174,7 @@ class JsonLibrary(local.Library): search_result = search.search(self._tracks.values(), query, limit=None) for track in search_result.tracks: distinct_result.update(distinct(track)) - return distinct_result + return distinct_result - {None} def search(self, query=None, limit=100, offset=0, uris=None, exact=False): tracks = self._tracks.values() diff --git a/mopidy/local/library.py b/mopidy/local/library.py index 5e98964c..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( diff --git a/mopidy/local/playback.py b/mopidy/local/playback.py index 82f27fdd..a851239d 100644 --- a/mopidy/local/playback.py +++ b/mopidy/local/playback.py @@ -5,6 +5,7 @@ from mopidy.local import translator class LocalPlaybackProvider(backend.PlaybackProvider): + def translate_uri(self, uri): - return translator.local_track_uri_to_file_uri( + return translator.local_uri_to_file_uri( uri, self.backend.config['local']['media_dir']) diff --git a/mopidy/local/search.py b/mopidy/local/search.py index fdbe871c..322cdd1e 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -43,8 +43,8 @@ def find_exact(tracks, query=None, limit=100, offset=0, uris=None): 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', [])]) + 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', [])]) @@ -150,8 +150,8 @@ def search(tracks, query=None, limit=100, offset=0, uris=None): 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)) + 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() diff --git a/mopidy/local/storage.py b/mopidy/local/storage.py index 21d278e5..1808c4a2 100644 --- a/mopidy/local/storage.py +++ b/mopidy/local/storage.py @@ -3,7 +3,7 @@ from __future__ import absolute_import, unicode_literals import logging import os -from mopidy.utils import encoding, path +from mopidy.internal import encoding, path logger = logging.getLogger(__name__) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 92b20a7b..6fc53f63 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -5,25 +5,41 @@ import os import urllib from mopidy import compat -from mopidy.utils.path import path_to_uri, uri_to_path +from mopidy.internal import path logger = logging.getLogger(__name__) -def local_track_uri_to_file_uri(uri, media_dir): - return path_to_uri(local_track_uri_to_path(uri, media_dir)) +def local_uri_to_file_uri(uri, media_dir): + """Convert local track or directory URI to file URI.""" + return path_to_file_uri(local_uri_to_path(uri, media_dir)) -def local_track_uri_to_path(uri, media_dir): - if not uri.startswith('local:track:'): +def local_uri_to_path(uri, media_dir): + """Convert local track or directory URI to absolute path.""" + if ( + not uri.startswith('local:directory:') and + not uri.startswith('local:track:')): raise ValueError('Invalid URI.') - file_path = uri_to_path(uri).split(b':', 1)[1] + file_path = path.uri_to_path(uri).split(b':', 1)[1] return os.path.join(media_dir, file_path) +def local_track_uri_to_path(uri, media_dir): + # Deprecated version to keep old versions of Mopidy-Local-Sqlite working. + return local_uri_to_path(uri, media_dir) + + +def path_to_file_uri(abspath): + """Convert absolute path to file URI.""" + # Re-export internal method for use by Mopidy-Local-* extensions. + return path.path_to_uri(abspath) + + def path_to_local_track_uri(relpath): - """Convert path relative to media_dir to local track URI.""" + """Convert path relative to :confval:`local/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) diff --git a/mopidy/m3u/actor.py b/mopidy/m3u/actor.py index 3908d938..fe959d86 100644 --- a/mopidy/m3u/actor.py +++ b/mopidy/m3u/actor.py @@ -5,9 +5,9 @@ import logging import pykka from mopidy import backend +from mopidy.internal import encoding, path from mopidy.m3u.library import M3ULibraryProvider from mopidy.m3u.playlists import M3UPlaylistsProvider -from mopidy.utils import encoding, path logger = logging.getLogger(__name__) diff --git a/mopidy/m3u/library.py b/mopidy/m3u/library.py index 3b5bded1..291a6194 100644 --- a/mopidy/m3u/library.py +++ b/mopidy/m3u/library.py @@ -8,6 +8,7 @@ logger = logging.getLogger(__name__) class M3ULibraryProvider(backend.LibraryProvider): + """Library for looking up M3U playlists.""" def __init__(self, backend): diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index 33281129..af92e062 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -51,10 +51,12 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): if os.path.exists(path): os.remove(path) else: - logger.warn('Trying to delete missing playlist file %s', path) + logger.warning( + 'Trying to delete missing playlist file %s', path) del self._playlists[uri] + logger.info('Deleted playlist %s', uri) else: - logger.warn('Trying to delete unknown playlist %s', uri) + logger.warning('Trying to delete unknown playlist %s', uri) def lookup(self, uri): return self._playlists.get(uri) @@ -63,7 +65,7 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): playlists = {} encoding = sys.getfilesystemencoding() - for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')): + 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, 'replace') @@ -76,6 +78,8 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): 'Loaded %d M3U playlists from %s', len(playlists), self._playlists_dir) + # TODO Trigger playlists_loaded event? + def save(self, playlist): assert playlist.uri, 'Cannot save playlist without URI' assert playlist.uri in self._playlists, \ @@ -110,4 +114,4 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): raise ValueError('M3U playlist needs name or URI') translator.save_m3u(path, playlist.tracks, 'latin1') # assert playlist name matches file name/uri - return playlist.copy(uri=uri, name=name) + return playlist.replace(uri=uri, name=name) diff --git a/mopidy/m3u/translator.py b/mopidy/m3u/translator.py index a6e006b1..0055e56d 100644 --- a/mopidy/m3u/translator.py +++ b/mopidy/m3u/translator.py @@ -8,9 +8,8 @@ import urllib import urlparse from mopidy import compat +from mopidy.internal import encoding, path 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+),(.*)') @@ -21,7 +20,7 @@ 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) + file_path = path.uri_to_path(uri) return os.path.join(playlists_dir, file_path) @@ -74,38 +73,42 @@ def parse_m3u(file_path, media_dir=None): - 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. + - m3u8 files are utf-8 """ # TODO: uris as bytes + file_encoding = 'utf-8' if file_path.endswith(b'.m3u8') else 'latin1' + tracks = [] try: - with open(file_path) as m3u: + with codecs.open(file_path, 'rb', file_encoding, 'replace') as m3u: contents = m3u.readlines() except IOError as error: - logger.warning('Couldn\'t open m3u: %s', locale_decode(error)) + logger.warning('Couldn\'t open m3u: %s', encoding.locale_decode(error)) return tracks if not contents: return tracks - extended = contents[0].decode('latin1').startswith('#EXTM3U') + # Strip newlines left by codecs + contents = [line.strip() for line in contents] + + extended = contents[0].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)) + tracks.append(track.replace(uri=line)) elif os.path.normpath(line) == os.path.abspath(line): - path = path_to_uri(line) - tracks.append(track.copy(uri=path)) + uri = path.path_to_uri(line) + tracks.append(track.replace(uri=uri)) elif media_dir is not None: - path = path_to_uri(os.path.join(media_dir, line)) - tracks.append(track.copy(uri=path)) + uri = path.path_to_uri(os.path.join(media_dir, line)) + tracks.append(track.replace(uri=uri)) track = Track() return tracks diff --git a/mopidy/mixer.py b/mopidy/mixer.py index e277fe55..eb43d810 100644 --- a/mopidy/mixer.py +++ b/mopidy/mixer.py @@ -9,6 +9,7 @@ logger = logging.getLogger(__name__) class Mixer(object): + """ Audio mixer API @@ -109,8 +110,13 @@ class Mixer(object): logger.debug('Mixer event: mute_changed(mute=%s)', mute) MixerListener.send('mute_changed', mute=mute) + def ping(self): + """Called to check if the actor is still alive.""" + return True + class MixerListener(listener.Listener): + """ Marker interface for recipients of events sent by the mixer actor. diff --git a/mopidy/models.py b/mopidy/models/__init__.py similarity index 55% rename from mopidy/models.py rename to mopidy/models/__init__.py index f79b70e4..7afa2db8 100644 --- a/mopidy/models.py +++ b/mopidy/models/__init__.py @@ -1,148 +1,17 @@ from __future__ import absolute_import, unicode_literals -import json +from mopidy.models import fields +from mopidy.models.immutable import ImmutableObject, ValidatedImmutableObject +from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder + +__all__ = [ + 'ImmutableObject', 'Ref', 'Image', 'Artist', 'Album', 'track', 'TlTrack', + 'Playlist', 'SearchResult', 'model_json_decoder', 'ModelJSONEncoder', + 'ValidatedImmutableObject'] -class ImmutableObject(object): - """ - Superclass for immutable objects whose fields can only be modified via the - constructor. +class Ref(ValidatedImmutableObject): - :param kwargs: kwargs to set as fields on the object - :type kwargs: any - """ - - def __init__(self, *args, **kwargs): - for key, value in kwargs.items(): - if not hasattr(self, key) or callable(getattr(self, key)): - raise TypeError( - '__init__() got an unexpected keyword argument "%s"' % - key) - if value == getattr(self, key): - continue # Don't explicitly set default values - self.__dict__[key] = value - - def __setattr__(self, name, value): - if name.startswith('_'): - return super(ImmutableObject, self).__setattr__(name, value) - raise AttributeError('Object is immutable.') - - def __repr__(self): - kwarg_pairs = [] - for (key, value) in sorted(self.__dict__.items()): - if isinstance(value, (frozenset, tuple)): - if not value: - continue - value = list(value) - kwarg_pairs.append('%s=%s' % (key, repr(value))) - return '%(classname)s(%(kwargs)s)' % { - 'classname': self.__class__.__name__, - 'kwargs': ', '.join(kwarg_pairs), - } - - def __hash__(self): - hash_sum = 0 - for key, value in self.__dict__.items(): - hash_sum += hash(key) + hash(value) - return hash_sum - - def __eq__(self, other): - if not isinstance(other, self.__class__): - return False - - return self.__dict__ == other.__dict__ - - def __ne__(self, other): - return not self.__eq__(other) - - def copy(self, **values): - """ - Copy the model with ``field`` updated to new value. - - Examples:: - - # Returns a track with a new name - Track(name='foo').copy(name='bar') - # Return an album with a new number of tracks - Album(num_tracks=2).copy(num_tracks=5) - - :param values: the model fields to modify - :type values: dict - :rtype: new instance of the model being copied - """ - data = {} - for key in self.__dict__.keys(): - public_key = key.lstrip('_') - value = values.pop(public_key, self.__dict__[key]) - data[public_key] = value - for key in values.keys(): - if hasattr(self, key): - value = values.pop(key) - data[key] = value - if values: - raise TypeError( - 'copy() got an unexpected keyword argument "%s"' % key) - return self.__class__(**data) - - def serialize(self): - data = {} - data['__model__'] = self.__class__.__name__ - for key in self.__dict__.keys(): - public_key = key.lstrip('_') - value = self.__dict__[key] - if isinstance(value, (set, frozenset, list, tuple)): - value = [ - v.serialize() if isinstance(v, ImmutableObject) else v - for v in value] - elif isinstance(value, ImmutableObject): - value = value.serialize() - if not (isinstance(value, list) and len(value) == 0): - data[public_key] = value - return data - - -class ModelJSONEncoder(json.JSONEncoder): - """ - Automatically serialize Mopidy models to JSON. - - Usage:: - - >>> import json - >>> json.dumps({'a_track': Track(name='name')}, cls=ModelJSONEncoder) - '{"a_track": {"__model__": "Track", "name": "name"}}' - - """ - def default(self, obj): - if isinstance(obj, ImmutableObject): - return obj.serialize() - return json.JSONEncoder.default(self, obj) - - -def model_json_decoder(dct): - """ - Automatically deserialize Mopidy models from JSON. - - Usage:: - - >>> import json - >>> json.loads( - ... '{"a_track": {"__model__": "Track", "name": "name"}}', - ... object_hook=model_json_decoder) - {u'a_track': Track(artists=[], name=u'name')} - - """ - if '__model__' in dct: - model_name = dct.pop('__model__') - cls = globals().get(model_name, None) - if issubclass(cls, ImmutableObject): - kwargs = {} - for key, value in dct.items(): - kwargs[key] = value - return cls(**kwargs) - return 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 @@ -157,14 +26,15 @@ class Ref(ImmutableObject): """ #: The object URI. Read-only. - uri = None + uri = fields.URI() #: The object name. Read-only. - name = None + name = fields.String() #: The object type, e.g. "artist", "album", "track", "playlist", #: "directory". Read-only. - type = None + type = fields.Identifier() # TODO: consider locking this down. + # type = fields.Field(choices=(ALBUM, ARTIST, DIRECTORY, PLAYLIST, TRACK)) #: Constant used for comparison with the :attr:`type` field. ALBUM = 'album' @@ -212,7 +82,8 @@ class Ref(ImmutableObject): return cls(**kwargs) -class Image(ImmutableObject): +class Image(ValidatedImmutableObject): + """ :param string uri: URI of the image :param int width: Optional width of image or :class:`None` @@ -220,36 +91,43 @@ class Image(ImmutableObject): """ #: The image URI. Read-only. - uri = None + uri = fields.URI() #: Optional width of the image or :class:`None`. Read-only. - width = None + width = fields.Integer(min=0) #: Optional height of the image or :class:`None`. Read-only. - height = None + height = fields.Integer(min=0) -class Artist(ImmutableObject): +class Artist(ValidatedImmutableObject): + """ :param uri: artist URI :type uri: string :param name: artist name :type name: string + :param sortname: artist name for sorting + :type sortname: string :param musicbrainz_id: MusicBrainz ID :type musicbrainz_id: string """ #: The artist URI. Read-only. - uri = None + uri = fields.URI() #: The artist name. Read-only. - name = None + name = fields.String() + + #: Artist name for better sorting, e.g. with articles stripped + sortname = fields.String() #: The MusicBrainz ID of the artist. Read-only. - musicbrainz_id = None + musicbrainz_id = fields.Identifier() -class Album(ImmutableObject): +class Album(ValidatedImmutableObject): + """ :param uri: album URI :type uri: string @@ -270,39 +148,35 @@ class Album(ImmutableObject): """ #: The album URI. Read-only. - uri = None + uri = fields.URI() #: The album name. Read-only. - name = None + name = fields.String() #: A set of album artists. Read-only. - artists = frozenset() + artists = fields.Collection(type=Artist, container=frozenset) #: The number of tracks in the album. Read-only. - num_tracks = None + num_tracks = fields.Integer(min=0) #: The number of discs in the album. Read-only. - num_discs = None + num_discs = fields.Integer(min=0) #: The album release date. Read-only. - date = None + date = fields.Date() #: The MusicBrainz ID of the album. Read-only. - musicbrainz_id = None + musicbrainz_id = fields.Identifier() #: The album image URIs. Read-only. - images = frozenset() + images = fields.Collection(type=basestring, container=frozenset) # XXX If we want to keep the order of images we shouldn't use frozenset() # as it doesn't preserve order. I'm deferring this issue until we got # actual usage of this field with more than one image. - def __init__(self, *args, **kwargs): - self.__dict__['artists'] = frozenset(kwargs.pop('artists', None) or []) - self.__dict__['images'] = frozenset(kwargs.pop('images', None) or []) - super(Album, self).__init__(*args, **kwargs) +class Track(ValidatedImmutableObject): -class Track(ImmutableObject): """ :param uri: track URI :type uri: string @@ -337,64 +211,56 @@ class Track(ImmutableObject): """ #: The track URI. Read-only. - uri = None + uri = fields.URI() #: The track name. Read-only. - name = None + name = fields.String() #: A set of track artists. Read-only. - artists = frozenset() + artists = fields.Collection(type=Artist, container=frozenset) #: The track :class:`Album`. Read-only. - album = None + album = fields.Field(type=Album) #: A set of track composers. Read-only. - composers = frozenset() + composers = fields.Collection(type=Artist, container=frozenset) #: A set of track performers`. Read-only. - performers = frozenset() + performers = fields.Collection(type=Artist, container=frozenset) #: The track genre. Read-only. - genre = None + genre = fields.String() #: The track number in the album. Read-only. - track_no = None + track_no = fields.Integer(min=0) #: The disc number in the album. Read-only. - disc_no = None + disc_no = fields.Integer(min=0) #: The track release date. Read-only. - date = None + date = fields.Date() #: The track length in milliseconds. Read-only. - length = None + length = fields.Integer(min=0) #: The track's bitrate in kbit/s. Read-only. - bitrate = None + bitrate = fields.Integer(min=0) #: The track comment. Read-only. - comment = None + comment = fields.String() #: The MusicBrainz ID of the track. Read-only. - musicbrainz_id = None + musicbrainz_id = fields.Identifier() #: 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): - 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') - super(Track, self).__init__(*args, **kwargs) + last_modified = fields.Integer(min=0) -class TlTrack(ImmutableObject): +class TlTrack(ValidatedImmutableObject): + """ A tracklist track. Wraps a regular track and it's tracklist ID. @@ -416,10 +282,10 @@ class TlTrack(ImmutableObject): """ #: The tracklist ID. Read-only. - tlid = None + tlid = fields.Integer(min=0) #: The track. Read-only. - track = None + track = fields.Field(type=Track) def __init__(self, *args, **kwargs): if len(args) == 2 and len(kwargs) == 0: @@ -432,7 +298,8 @@ class TlTrack(ImmutableObject): return iter([self.tlid, self.track]) -class Playlist(ImmutableObject): +class Playlist(ValidatedImmutableObject): + """ :param uri: playlist URI :type uri: string @@ -446,23 +313,19 @@ class Playlist(ImmutableObject): """ #: The playlist URI. Read-only. - uri = None + uri = fields.URI() #: The playlist name. Read-only. - name = None + name = fields.String() #: The playlist's tracks. Read-only. - tracks = tuple() + tracks = fields.Collection(type=Track, container=tuple) #: The playlist modification time in milliseconds since Unix epoch. #: Read-only. #: #: Integer, or :class:`None` if unknown. - last_modified = None - - def __init__(self, *args, **kwargs): - self.__dict__['tracks'] = tuple(kwargs.pop('tracks', None) or []) - super(Playlist, self).__init__(*args, **kwargs) + last_modified = fields.Integer(min=0) # TODO: def insert(self, pos, track): ... ? @@ -472,7 +335,8 @@ class Playlist(ImmutableObject): return len(self.tracks) -class SearchResult(ImmutableObject): +class SearchResult(ValidatedImmutableObject): + """ :param uri: search result URI :type uri: string @@ -485,19 +349,13 @@ class SearchResult(ImmutableObject): """ # The search result URI. Read-only. - uri = None + uri = fields.URI() # The tracks matching the search query. Read-only. - tracks = tuple() + tracks = fields.Collection(type=Track, container=tuple) # The artists matching the search query. Read-only. - artists = tuple() + artists = fields.Collection(type=Artist, container=tuple) # The albums matching the search query. Read-only. - albums = tuple() - - def __init__(self, *args, **kwargs): - self.__dict__['tracks'] = tuple(kwargs.pop('tracks', None) or []) - self.__dict__['artists'] = tuple(kwargs.pop('artists', None) or []) - self.__dict__['albums'] = tuple(kwargs.pop('albums', None) or []) - super(SearchResult, self).__init__(*args, **kwargs) + albums = fields.Collection(type=Album, container=tuple) diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py new file mode 100644 index 00000000..1f3935b4 --- /dev/null +++ b/mopidy/models/fields.py @@ -0,0 +1,154 @@ +from __future__ import absolute_import, unicode_literals + + +class Field(object): + + """ + Base field for use in + :class:`~mopidy.models.immutable.ValidatedImmutableObject`. These fields + are responsible for type checking and other data sanitation in our models. + + For simplicity fields use the Python descriptor protocol to store the + values in the instance dictionary. Also note that fields are mutable if + the object they are attached to allow it. + + Default values will be validated with the exception of :class:`None`. + + :param default: default value for field + :param type: if set the field value must be of this type + :param choices: if set the field value must be one of these + """ + + def __init__(self, default=None, type=None, choices=None): + self._name = None # Set by ValidatedImmutableObjectMeta + self._choices = choices + self._default = default + self._type = type + + if self._default is not None: + self.validate(self._default) + + def validate(self, value): + """Validate and possibly modify the field value before assignment""" + if self._type and not isinstance(value, self._type): + raise TypeError('Expected %s to be a %s, not %r' % + (self._name, self._type, value)) + if self._choices and value not in self._choices: + raise TypeError('Expected %s to be a one of %s, not %r' % + (self._name, self._choices, value)) + return value + + def __get__(self, instance, owner): + if not instance: + return self + return getattr(instance, '_' + self._name, self._default) + + def __set__(self, instance, value): + if value is not None: + value = self.validate(value) + + if value is None or value == self._default: + self.__delete__(instance) + else: + setattr(instance, '_' + self._name, value) + + def __delete__(self, instance): + if hasattr(instance, '_' + self._name): + delattr(instance, '_' + self._name) + + +class String(Field): + + """ + Specialized :class:`Field` which is wired up for bytes and unicode. + + :param default: default value for field + """ + + def __init__(self, default=None): + # TODO: normalize to unicode? + # TODO: only allow unicode? + # TODO: disallow empty strings? + super(String, self).__init__(type=basestring, default=default) + + +class Date(String): + """ + :class:`Field` for storing ISO 8601 dates as a string. + + Supported formats are ``YYYY-MM-DD``, ``YYYY-MM`` and ``YYYY``, currently + not validated. + + :param default: default value for field + """ + pass # TODO: make this check for YYYY-MM-DD, YYYY-MM, YYYY using strptime. + + +class Identifier(String): + """ + :class:`Field` for storing ASCII values such as GUIDs or other identifiers. + + Values will be interned. + + :param default: default value for field + """ + def validate(self, value): + return intern(str(super(Identifier, self).validate(value))) + + +class URI(Identifier): + """ + :class:`Field` for storing URIs + + Values will be interned, currently not validated. + + :param default: default value for field + """ + pass # TODO: validate URIs? + + +class Integer(Field): + """ + :class:`Field` for storing integer numbers. + + :param default: default value for field + :param min: field value must be larger or equal to this value when set + :param max: field value must be smaller or equal to this value when set + """ + + def __init__(self, default=None, min=None, max=None): + self._min = min + self._max = max + super(Integer, self).__init__(type=(int, long), default=default) + + def validate(self, value): + value = super(Integer, self).validate(value) + if self._min is not None and value < self._min: + raise ValueError('Expected %s to be at least %d, not %d' % + (self._name, self._min, value)) + if self._max is not None and value > self._max: + raise ValueError('Expected %s to be at most %d, not %d' % + (self._name, self._max, value)) + return value + + +class Collection(Field): + """ + :class:`Field` for storing collections of a given type. + + :param type: all items stored in the collection must be of this type + :param container: the type to store the items in + """ + + def __init__(self, type, container=tuple): + super(Collection, self).__init__(type=type, default=container()) + + def validate(self, value): + if isinstance(value, basestring): + raise TypeError('Expected %s to be a collection of %s, not %r' + % (self._name, self._type.__name__, value)) + for v in value: + if not isinstance(v, self._type): + raise TypeError('Expected %s to be a collection of %s, not %r' + % (self._name, self._type.__name__, value)) + return self._default.__class__(value) or None diff --git a/mopidy/models/immutable.py b/mopidy/models/immutable.py new file mode 100644 index 00000000..8bbf568b --- /dev/null +++ b/mopidy/models/immutable.py @@ -0,0 +1,217 @@ +from __future__ import absolute_import, unicode_literals + +import copy +import itertools +import weakref + +from mopidy.internal import deprecation +from mopidy.models.fields import Field + + +class ImmutableObject(object): + """ + Superclass for immutable objects whose fields can only be modified via the + constructor. + + This version of this class has been retained to avoid breaking any clients + relying on it's behavior. Internally in Mopidy we now use + :class:`ValidatedImmutableObject` for type safety and it's much smaller + memory footprint. + + :param kwargs: kwargs to set as fields on the object + :type kwargs: any + """ + + # Any sub-classes that don't set slots won't be effected by the base using + # slots as they will still get an instance dict. + __slots__ = ['__weakref__'] + + def __init__(self, *args, **kwargs): + for key, value in kwargs.items(): + if not self._is_valid_field(key): + raise TypeError( + '__init__() got an unexpected keyword argument "%s"' % key) + self._set_field(key, value) + + def __setattr__(self, name, value): + if name.startswith('_'): + object.__setattr__(self, name, value) + else: + raise AttributeError('Object is immutable.') + + def __delattr__(self, name): + if name.startswith('_'): + object.__delattr__(self, name) + else: + raise AttributeError('Object is immutable.') + + def _is_valid_field(self, name): + return hasattr(self, name) and not callable(getattr(self, name)) + + def _set_field(self, name, value): + if value == getattr(self.__class__, name): + self.__dict__.pop(name, None) + else: + self.__dict__[name] = value + + def _items(self): + return self.__dict__.iteritems() + + def __repr__(self): + kwarg_pairs = [] + for key, value in sorted(self._items()): + if isinstance(value, (frozenset, tuple)): + if not value: + continue + value = list(value) + kwarg_pairs.append('%s=%s' % (key, repr(value))) + return '%(classname)s(%(kwargs)s)' % { + 'classname': self.__class__.__name__, + 'kwargs': ', '.join(kwarg_pairs), + } + + def __hash__(self): + hash_sum = 0 + for key, value in self._items(): + hash_sum += hash(key) + hash(value) + return hash_sum + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + return all(a == b for a, b in itertools.izip_longest( + self._items(), other._items(), fillvalue=object())) + + def __ne__(self, other): + return not self.__eq__(other) + + def copy(self, **values): + """ + .. deprecated:: 1.1 + Use :meth:`replace` instead. + """ + deprecation.warn('model.immutable.copy') + return self.replace(**values) + + def replace(self, **kwargs): + """ + Replace the fields in the model and return a new instance + + Examples:: + + # Returns a track with a new name + Track(name='foo').replace(name='bar') + # Return an album with a new number of tracks + Album(num_tracks=2).replace(num_tracks=5) + + :param kwargs: kwargs to set as fields on the object + :type kwargs: any + :rtype: instance of the model with replaced fields + """ + other = copy.copy(self) + for key, value in kwargs.items(): + if not self._is_valid_field(key): + raise TypeError( + 'copy() got an unexpected keyword argument "%s"' % key) + other._set_field(key, value) + return other + + def serialize(self): + data = {} + data['__model__'] = self.__class__.__name__ + for key, value in self._items(): + if isinstance(value, (set, frozenset, list, tuple)): + value = [ + v.serialize() if isinstance(v, ImmutableObject) else v + for v in value] + elif isinstance(value, ImmutableObject): + value = value.serialize() + if not (isinstance(value, list) and len(value) == 0): + data[key] = value + return data + + +class _ValidatedImmutableObjectMeta(type): + + """Helper that initializes fields, slots and memoizes instance creation.""" + + def __new__(cls, name, bases, attrs): + fields = {} + + for base in bases: # Copy parent fields over to our state + fields.update(getattr(base, '_fields', {})) + + for key, value in attrs.items(): # Add our own fields + if isinstance(value, Field): + fields[key] = '_' + key + value._name = key + + attrs['_fields'] = fields + attrs['_instances'] = weakref.WeakValueDictionary() + attrs['__slots__'] = list(attrs.get('__slots__', [])) + fields.values() + + return super(_ValidatedImmutableObjectMeta, cls).__new__( + cls, name, bases, attrs) + + def __call__(cls, *args, **kwargs): # noqa: N805 + instance = super(_ValidatedImmutableObjectMeta, cls).__call__( + *args, **kwargs) + return cls._instances.setdefault(weakref.ref(instance), instance) + + +class ValidatedImmutableObject(ImmutableObject): + """ + Superclass for immutable objects whose fields can only be modified via the + constructor. Fields should be :class:`Field` instances to ensure type + safety in our models. + + Note that since these models can not be changed, we heavily memoize them + to save memory. So constructing a class with the same arguments twice will + give you the same instance twice. + """ + + __metaclass__ = _ValidatedImmutableObjectMeta + __slots__ = ['_hash'] + + def __hash__(self): + if not hasattr(self, '_hash'): + hash_sum = super(ValidatedImmutableObject, self).__hash__() + object.__setattr__(self, '_hash', hash_sum) + return self._hash + + def _is_valid_field(self, name): + return name in self._fields + + def _set_field(self, name, value): + object.__setattr__(self, name, value) + + def _items(self): + for field, key in self._fields.items(): + if hasattr(self, key): + yield field, getattr(self, key) + + def replace(self, **kwargs): + """ + Replace the fields in the model and return a new instance + + Examples:: + + # Returns a track with a new name + Track(name='foo').replace(name='bar') + # Return an album with a new number of tracks + Album(num_tracks=2).replace(num_tracks=5) + + Note that internally we memoize heavily to keep memory usage down given + our overly repetitive data structures. So you might get an existing + instance if it contains the same values. + + :param kwargs: kwargs to set as fields on the object + :type kwargs: any + :rtype: instance of the model with replaced fields + """ + if not kwargs: + return self + other = super(ValidatedImmutableObject, self).replace(**kwargs) + if hasattr(self, '_hash'): + object.__delattr__(other, '_hash') + return self._instances.setdefault(weakref.ref(other), other) diff --git a/mopidy/models/serialize.py b/mopidy/models/serialize.py new file mode 100644 index 00000000..5002a8f7 --- /dev/null +++ b/mopidy/models/serialize.py @@ -0,0 +1,47 @@ +from __future__ import absolute_import, unicode_literals + +import json + +from mopidy.models import immutable + +_MODELS = ['Ref', 'Artist', 'Album', 'Track', 'TlTrack', 'Playlist'] + + +class ModelJSONEncoder(json.JSONEncoder): + + """ + Automatically serialize Mopidy models to JSON. + + Usage:: + + >>> import json + >>> json.dumps({'a_track': Track(name='name')}, cls=ModelJSONEncoder) + '{"a_track": {"__model__": "Track", "name": "name"}}' + + """ + + def default(self, obj): + if isinstance(obj, immutable.ImmutableObject): + return obj.serialize() + return json.JSONEncoder.default(self, obj) + + +def model_json_decoder(dct): + """ + Automatically deserialize Mopidy models from JSON. + + Usage:: + + >>> import json + >>> json.loads( + ... '{"a_track": {"__model__": "Track", "name": "name"}}', + ... object_hook=model_json_decoder) + {u'a_track': Track(artists=[], name=u'name')} + + """ + if '__model__' in dct: + from mopidy import models + model_name = dct.pop('__model__') + if model_name in _MODELS: + return getattr(models, model_name)(**dct) + return dct diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 2aecb6d1..8eb59c1f 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -6,13 +6,14 @@ import pykka from mopidy import exceptions, zeroconf from mopidy.core import CoreListener +from mopidy.internal import encoding, network, process 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__() diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index d156b891..a8e2c05c 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 @@ -166,7 +167,8 @@ class MpdDispatcher(object): # 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]) + logger.warning( + 'MPD client used blacklisted command: %s', tokens[0]) raise exceptions.MpdDisabled(command=tokens[0]) try: return protocol.commands.call(tokens, context=self.context) @@ -209,6 +211,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. @@ -267,10 +270,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`. @@ -302,7 +305,8 @@ class MpdContext(object): 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 6fc925a3..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) diff --git a/mopidy/mpd/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py index ff04d435..99294f4d 100644 --- a/mopidy/mpd/protocol/__init__.py +++ b/mopidy/mpd/protocol/__init__.py @@ -22,8 +22,8 @@ ENCODING = 'UTF-8' #: The MPD protocol uses ``\n`` as line terminator. LINE_TERMINATOR = '\n' -#: The MPD protocol version is 0.17.0. -VERSION = '0.17.0' +#: The MPD protocol version is 0.19.0. +VERSION = '0.19.0' def load_protocol_modules(): @@ -33,7 +33,8 @@ def load_protocol_modules(): """ from . import ( # noqa audio_output, channels, command_list, connection, current_playlist, - music_db, playback, reflection, status, stickers, stored_playlists) + mount, music_db, playback, reflection, status, stickers, + stored_playlists) def INT(value): # noqa: N802 @@ -83,6 +84,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 565ea3d0..059c505d 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -8,7 +8,7 @@ def disableoutput(context, outputid): """ *musicpd.org, audio output section:* - ``disableoutput`` + ``disableoutput {ID}`` Turns an output off. """ @@ -25,7 +25,7 @@ def enableoutput(context, outputid): """ *musicpd.org, audio output section:* - ``enableoutput`` + ``enableoutput {ID}`` Turns an output on. """ diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index d8e1a9d8..0d07452c 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -1,7 +1,8 @@ from __future__ import absolute_import, unicode_literals -import warnings +import urlparse +from mopidy.internal import deprecation from mopidy.mpd import exceptions, protocol, translator @@ -22,21 +23,24 @@ def add(context, uri): if not uri.strip('/'): return - if context.core.tracklist.add(uri=uri).get(): - return + # If we have an URI just try and add it directly without bothering with + # jumping through browse... + if urlparse.urlparse(uri).scheme != '': + 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) @@ -60,16 +64,21 @@ def addid(context, uri, songpos=None): """ if not uri: raise exceptions.MpdNoExistError('No such song') - if songpos is not None and songpos > context.core.tracklist.length.get(): + + length = context.core.tracklist.get_length() + if songpos is not None and songpos > 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) -@protocol.commands.add('delete', position=protocol.RANGE) -def delete(context, position): +@protocol.commands.add('delete', songrange=protocol.RANGE) +def delete(context, songrange): """ *musicpd.org, current playlist section:* @@ -77,15 +86,15 @@ def delete(context, position): Deletes a song from the playlist. """ - start = position.start - end = position.stop + start = songrange.start + end = songrange.stop if end is None: - end = context.core.tracklist.length.get() + end = context.core.tracklist.get_length().get() tl_tracks = context.core.tracklist.slice(start, end).get() if not tl_tracks: raise exceptions.MpdArgError('Bad song index', command='delete') for (tlid, _) in tl_tracks: - context.core.tracklist.remove(tlid=[tlid]) + context.core.tracklist.remove({'tlid': [tlid]}) @protocol.commands.add('deleteid', tlid=protocol.UINT) @@ -97,7 +106,7 @@ def deleteid(context, tlid): Deletes the song ``SONGID`` from the playlist """ - tl_tracks = context.core.tracklist.remove(tlid=[tlid]).get() + tl_tracks = context.core.tracklist.remove({'tlid': [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') @@ -114,8 +123,8 @@ def clear(context): context.core.tracklist.clear() -@protocol.commands.add('move', position=protocol.RANGE, to=protocol.UINT) -def move_range(context, position, to): +@protocol.commands.add('move', songrange=protocol.RANGE, to=protocol.UINT) +def move_range(context, songrange, to): """ *musicpd.org, current playlist section:* @@ -124,10 +133,10 @@ def move_range(context, position, to): Moves the song at ``FROM`` or range of songs at ``START:END`` to ``TO`` in the playlist. """ - start = position.start - end = position.stop + start = songrange.start + end = songrange.stop if end is None: - end = context.core.tracklist.length.get() + end = context.core.tracklist.get_length().get() context.core.tracklist.move(start, end, to) @@ -142,7 +151,7 @@ def moveid(context, tlid, to): the playlist. If ``TO`` is negative, it is relative to the current song in the playlist (if there is one). """ - tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() + tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') position = context.core.tracklist.index(tl_tracks[0]).get() @@ -162,8 +171,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) @@ -175,13 +183,9 @@ def playlistfind(context, tag, needle): ``playlistfind {TAG} {NEEDLE}`` Finds songs in the current playlist with strict matching. - - *GMPC:* - - - does not add quotes around the tag. """ if tag == 'filename': - tl_tracks = context.core.tracklist.filter(uri=[needle]).get() + tl_tracks = context.core.tracklist.filter({'uri': [needle]}).get() if not tl_tracks: return None position = context.core.tracklist.index(tl_tracks[0]).get() @@ -200,14 +204,14 @@ def playlistid(context, tlid=None): and specifies a single song to display info for. """ if tlid is not None: - tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() + tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') position = context.core.tracklist.index(tl_tracks[0]).get() return translator.track_to_mpd_format(tl_tracks[0], position=position) else: return translator.tracks_to_mpd_format( - context.core.tracklist.tl_tracks.get()) + context.core.tracklist.get_tl_tracks().get()) @protocol.commands.add('playlistinfo') @@ -232,7 +236,7 @@ def playlistinfo(context, parameter=None): tracklist_slice = protocol.RANGE(parameter) start, end = tracklist_slice.start, tracklist_slice.stop - tl_tracks = context.core.tracklist.tl_tracks.get() + tl_tracks = context.core.tracklist.get_tl_tracks().get() if start and start > len(tl_tracks): raise exceptions.MpdArgError('Bad song index') if end and end > len(tl_tracks): @@ -252,7 +256,6 @@ def playlistsearch(context, tag, needle): *GMPC:* - - does not add quotes around the tag - uses ``filename`` and ``any`` as tags """ raise exceptions.MpdNotImplemented # TODO @@ -275,10 +278,10 @@ 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 - tracklist_version = context.core.tracklist.version.get() + tracklist_version = context.core.tracklist.get_version().get() if version < tracklist_version: return translator.tracks_to_mpd_format( - context.core.tracklist.tl_tracks.get()) + context.core.tracklist.get_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. @@ -286,7 +289,7 @@ def plchanges(context, version): if stream_title is None: return None - tl_track = context.core.playback.current_tl_track.get() + tl_track = context.core.playback.get_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) @@ -307,73 +310,17 @@ def plchangesposid(context, version): ``playlistlength`` returned by status command. """ # XXX Naive implementation that returns all tracks as changed - if int(version) != context.core.tracklist.version.get(): + if int(version) != context.core.tracklist.get_version().get(): result = [] for (position, (tlid, _)) in enumerate( - context.core.tracklist.tl_tracks.get()): + context.core.tracklist.get_tl_tracks().get()): result.append(('cpos', position)) result.append(('Id', tlid)) return result -@protocol.commands.add('shuffle', position=protocol.RANGE) -def shuffle(context, position=None): - """ - *musicpd.org, current playlist section:* - - ``shuffle [START:END]`` - - Shuffles the current playlist. ``START:END`` is optional and - specifies a range of songs. - """ - if position is None: - start, end = None, None - else: - start, end = position.start, position.stop - context.core.tracklist.shuffle(start, end) - - -@protocol.commands.add('swap', songpos1=protocol.UINT, songpos2=protocol.UINT) -def swap(context, songpos1, songpos2): - """ - *musicpd.org, current playlist section:* - - ``swap {SONG1} {SONG2}`` - - Swaps the positions of ``SONG1`` and ``SONG2``. - """ - tracks = context.core.tracklist.tracks.get() - song1 = tracks[songpos1] - song2 = tracks[songpos2] - del tracks[songpos1] - tracks.insert(songpos1, song2) - del tracks[songpos2] - tracks.insert(songpos2, song1) - context.core.tracklist.clear() - context.core.tracklist.add(tracks) - - -@protocol.commands.add('swapid', tlid1=protocol.UINT, tlid2=protocol.UINT) -def swapid(context, tlid1, tlid2): - """ - *musicpd.org, current playlist section:* - - ``swapid {SONG1} {SONG2}`` - - Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). - """ - tl_tracks1 = context.core.tracklist.filter(tlid=[tlid1]).get() - tl_tracks2 = context.core.tracklist.filter(tlid=[tlid2]).get() - if not tl_tracks1 or not tl_tracks2: - raise exceptions.MpdNoExistError('No such song') - position1 = context.core.tracklist.index(tl_tracks1[0]).get() - position2 = context.core.tracklist.index(tl_tracks2[0]).get() - swap(context, position1, position2) - - -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add( -# 'prio', priority=protocol.UINT, position=protocol.RANGE) +@protocol.commands.add( + 'prio', priority=protocol.UINT, position=protocol.RANGE) def prio(context, priority, position): """ *musicpd.org, current playlist section:* @@ -386,11 +333,10 @@ def prio(context, priority, position): A priority is an integer between 0 and 255. The default priority of new songs is 0. """ - pass + raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('prioid') +@protocol.commands.add('prioid') def prioid(context, *args): """ *musicpd.org, current playlist section:* @@ -399,11 +345,88 @@ def prioid(context, *args): Same as prio, but address the songs with their id. """ - pass + raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('addtagid', tlid=protocol.UINT) +@protocol.commands.add('rangeid', tlid=protocol.UINT, songrange=protocol.RANGE) +def rangeid(context, tlid, songrange): + """ + *musicpd.org, current playlist section:* + + ``rangeid {ID} {START:END}`` + + Specifies the portion of the song that shall be played. START and END + are offsets in seconds (fractional seconds allowed); both are optional. + Omitting both (i.e. sending just ":") means "remove the range, play + everything". A song that is currently playing cannot be manipulated + this way. + + .. versionadded:: 0.19 + New in MPD protocol version 0.19 + """ + raise exceptions.MpdNotImplemented # TODO + + +@protocol.commands.add('shuffle', songrange=protocol.RANGE) +def shuffle(context, songrange=None): + """ + *musicpd.org, current playlist section:* + + ``shuffle [START:END]`` + + Shuffles the current playlist. ``START:END`` is optional and + specifies a range of songs. + """ + if songrange is None: + start, end = None, None + else: + start, end = songrange.start, songrange.stop + context.core.tracklist.shuffle(start, end) + + +@protocol.commands.add('swap', songpos1=protocol.UINT, songpos2=protocol.UINT) +def swap(context, songpos1, songpos2): + """ + *musicpd.org, current playlist section:* + + ``swap {SONG1} {SONG2}`` + + Swaps the positions of ``SONG1`` and ``SONG2``. + """ + tracks = context.core.tracklist.get_tracks().get() + song1 = tracks[songpos1] + song2 = tracks[songpos2] + del tracks[songpos1] + tracks.insert(songpos1, song2) + del tracks[songpos2] + tracks.insert(songpos2, song1) + + # TODO: do we need a tracklist.replace() + context.core.tracklist.clear() + + 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) +def swapid(context, tlid1, tlid2): + """ + *musicpd.org, current playlist section:* + + ``swapid {SONG1} {SONG2}`` + + Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). + """ + tl_tracks1 = context.core.tracklist.filter({'tlid': [tlid1]}).get() + tl_tracks2 = context.core.tracklist.filter({'tlid': [tlid2]}).get() + if not tl_tracks1 or not tl_tracks2: + raise exceptions.MpdNoExistError('No such song') + position1 = context.core.tracklist.index(tl_tracks1[0]).get() + position2 = context.core.tracklist.index(tl_tracks2[0]).get() + swap(context, position1, position2) + + +@protocol.commands.add('addtagid', tlid=protocol.UINT) def addtagid(context, tlid, tag, value): """ *musicpd.org, current playlist section:* @@ -414,12 +437,14 @@ def addtagid(context, tlid, tag, value): for remote songs. This change is volatile: it may be overwritten by tags received from the server, and the data is gone when the song gets removed from the queue. + + .. versionadded:: 0.19 + New in MPD protocol version 0.19 """ - pass + raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('cleartagid', tlid=protocol.UINT) +@protocol.commands.add('cleartagid', tlid=protocol.UINT) def cleartagid(context, tlid, tag): """ *musicpd.org, current playlist section:* @@ -429,5 +454,8 @@ def cleartagid(context, tlid, tag): Removes tags from the specified song. If TAG is not specified, then all tag values will be removed. Editing song tags is only possible for remote songs. + + .. versionadded:: 0.19 + New in MPD protocol version 0.19 """ - pass + raise exceptions.MpdNotImplemented # TODO diff --git a/mopidy/mpd/protocol/mount.py b/mopidy/mpd/protocol/mount.py new file mode 100644 index 00000000..f9a0d75f --- /dev/null +++ b/mopidy/mpd/protocol/mount.py @@ -0,0 +1,82 @@ +from __future__ import absolute_import, unicode_literals + +from mopidy.mpd import exceptions, protocol + + +@protocol.commands.add('mount') +def mount(context, path, uri): + """ + *musicpd.org, mounts and neighbors section:* + + ``mount {PATH} {URI}`` + + Mount the specified remote storage URI at the given path. Example:: + + mount foo nfs://192.168.1.4/export/mp3 + + .. versionadded:: 0.19 + New in MPD protocol version 0.19 + """ + raise exceptions.MpdNotImplemented # TODO + + +@protocol.commands.add('unmount') +def unmount(context, path): + """ + *musicpd.org, mounts and neighbors section:* + + ``unmount {PATH}`` + + Unmounts the specified path. Example:: + + unmount foo + + .. versionadded:: 0.19 + New in MPD protocol version 0.19 + """ + raise exceptions.MpdNotImplemented # TODO + + +@protocol.commands.add('listmounts') +def listmounts(context): + """ + *musicpd.org, mounts and neighbors section:* + + ``listmounts`` + + Queries a list of all mounts. By default, this contains just the + configured music_directory. Example:: + + listmounts + mount: + storage: /home/foo/music + mount: foo + storage: nfs://192.168.1.4/export/mp3 + OK + + .. versionadded:: 0.19 + New in MPD protocol version 0.19 + """ + raise exceptions.MpdNotImplemented # TODO + + +@protocol.commands.add('listneighbors') +def listneighbors(context): + """ + *musicpd.org, mounts and neighbors section:* + + ``listneighbors`` + + Queries a list of "neighbors" (e.g. accessible file servers on the + local net). Items on that list may be used with the mount command. + Example:: + + listneighbors + neighbor: smb://FOO + name: FOO (Samba 4.1.11-Debian) + OK + + .. versionadded:: 0.19 + New in MPD protocol version 0.19 + """ + raise exceptions.MpdNotImplemented # TODO diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 1e80f2a0..00db0218 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals import functools import itertools +from mopidy.internal import deprecation from mopidy.models import Track from mopidy.mpd import exceptions, protocol, translator @@ -95,18 +96,17 @@ def count(context, *args): *GMPC:* - - does not add quotes around the tag argument. - use multiple tag-needle pairs to make more specific searches. """ try: 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)), - ('playtime', sum(track.length for track in result_tracks) / 1000), + ('playtime', sum(t.length for t in result_tracks if t.length) / 1000), ] @@ -124,13 +124,11 @@ def find(context, *args): *GMPC:* - - does not add quotes around the field argument. - also uses ``find album "[ALBUM]" artist "[ARTIST]"`` to list album tracks. *ncmpc:* - - does not add quotes around the field argument. - capitalizes the type argument. *ncmpcpp:* @@ -143,7 +141,8 @@ def find(context, *args): except ValueError: return - results = context.core.library.find_exact(**query).get() + with deprecation.ignore('core.library.search:empty_query'): + results = context.core.library.search(query=query, exact=True).get() result_tracks = [] if ('artist' not in query and 'albumartist' not in query and @@ -170,8 +169,13 @@ 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 deprecation.ignore('core.tracklist.add:tracks_arg'): + # TODO: for now just use tracks as other wise we have to lookup the + # tracks we just got from the search. + context.core.tracklist.add(tracks=_get_tracks(results)).get() @protocol.commands.add('list') @@ -248,13 +252,8 @@ def list_(context, *args): Genre: Rock OK - *GMPC:* - - - does not add quotes around the field argument. - *ncmpc:* - - does not add quotes around the field argument. - capitalizes the field argument. """ params = list(args) @@ -266,10 +265,12 @@ def list_(context, *args): if field is None: raise exceptions.MpdArgError('incorrect arguments') + query = None if len(params) == 1: if field != 'album': raise exceptions.MpdArgError('should be "Album" for 3 arguments') - query = {'artist': params} + if params[0].strip(): + query = {'artist': params} else: try: query = _query_from_mpd_search_parameters(params, _LIST_MAPPING) @@ -334,11 +335,36 @@ def listallinfo(context, uri=None): 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 +@protocol.commands.add('listfiles') +def listfiles(context, uri=None): + """ + *musicpd.org, music database section:* + + ``listfiles [URI]`` + + Lists the contents of the directory URI, including files are not + recognized by MPD. URI can be a path relative to the music directory or + an URI understood by one of the storage plugins. The response contains + at least one line for each directory entry with the prefix "file: " or + "directory: ", and may be followed by file attributes such as + "Last-Modified" and "size". + + For example, "smb://SERVER" returns a list of all shares on the given + SMB/CIFS server; "nfs://servername/path" obtains a directory listing + from the NFS server. + + .. versionadded:: 0.19 + New in MPD protocol version 0.19 + """ + raise exceptions.MpdNotImplemented # TODO + + @protocol.commands.add('lsinfo') def lsinfo(context, uri=None): """ @@ -361,9 +387,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)) @@ -395,7 +421,6 @@ def search(context, *args): *GMPC:* - - does not add quotes around the field argument. - uses the undocumented field ``any``. - searches for multiple words like this:: @@ -403,7 +428,6 @@ def search(context, *args): *ncmpc:* - - does not add quotes around the field argument. - capitalizes the field argument. *ncmpcpp:* @@ -415,7 +439,8 @@ def search(context, *args): query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return - results = context.core.library.search(**query).get() + with deprecation.ignore('core.library.search:empty_query'): + 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) @@ -439,8 +464,13 @@ 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 deprecation.ignore('core.tracklist.add:tracks_arg'): + # TODO: for now just use tracks as other wise we have to lookup the + # tracks we just got from the search. + context.core.tracklist.add(_get_tracks(results)).get() @protocol.commands.add('searchaddpl') @@ -466,14 +496,14 @@ 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() 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) - playlist = playlist.copy(tracks=tracks) + playlist = playlist.replace(tracks=tracks) context.core.playlists.save(playlist) diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 86f2e36b..333e1ccb 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -1,8 +1,7 @@ from __future__ import absolute_import, unicode_literals -import warnings - from mopidy.core import PlaybackState +from mopidy.internal import deprecation from mopidy.mpd import exceptions, protocol @@ -17,7 +16,7 @@ def consume(context, state): 1. When consume is activated, each song played is removed from playlist. """ - context.core.tracklist.consume = state + context.core.tracklist.set_consume(state) @protocol.commands.add('crossfade', seconds=protocol.UINT) @@ -134,13 +133,12 @@ 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): + playback_state = context.core.playback.get_state().get() + if (playback_state == PlaybackState.PLAYING): context.core.playback.pause() - elif (context.core.playback.state.get() == PlaybackState.PAUSED): + elif (playback_state == PlaybackState.PAUSED): context.core.playback.resume() elif state: context.core.playback.pause() @@ -148,8 +146,8 @@ def pause(context, state=None): context.core.playback.resume() -@protocol.commands.add('play', tlid=protocol.INT) -def play(context, tlid=None): +@protocol.commands.add('play', songpos=protocol.INT) +def play(context, songpos=None): """ *musicpd.org, playback section:* @@ -173,31 +171,34 @@ def play(context, tlid=None): - issues ``play 6`` without quotes around the argument. """ - if tlid is None: + if songpos is None: return context.core.playback.play().get() - elif tlid == -1: + elif songpos == -1: return _play_minus_one(context) try: - tl_track = context.core.tracklist.slice(tlid, tlid + 1).get()[0] + tl_track = context.core.tracklist.slice(songpos, songpos + 1).get()[0] return context.core.playback.play(tl_track).get() except IndexError: raise exceptions.MpdArgError('Bad song index') def _play_minus_one(context): - if (context.core.playback.state.get() == PlaybackState.PLAYING): + playback_state = context.core.playback.get_state().get() + if playback_state == PlaybackState.PLAYING: return # Nothing to do - elif (context.core.playback.state.get() == PlaybackState.PAUSED): + elif playback_state == PlaybackState.PAUSED: return context.core.playback.resume().get() - elif context.core.playback.current_tl_track.get() is not None: - tl_track = context.core.playback.current_tl_track.get() - return context.core.playback.play(tl_track).get() - elif context.core.tracklist.slice(0, 1).get(): - tl_track = context.core.tracklist.slice(0, 1).get()[0] - return context.core.playback.play(tl_track).get() - else: - return # Fail silently + + current_tl_track = context.core.playback.get_current_tl_track().get() + if current_tl_track is not None: + return context.core.playback.play(current_tl_track).get() + + tl_tracks = context.core.tracklist.slice(0, 1).get() + if tl_tracks: + return context.core.playback.play(tl_tracks[0]).get() + + return # Fail silently @protocol.commands.add('playid', tlid=protocol.INT) @@ -220,7 +221,7 @@ def playid(context, tlid): """ if tlid == -1: return _play_minus_one(context) - tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() + tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') return context.core.playback.play(tl_tracks[0]).get() @@ -282,7 +283,7 @@ def random(context, state): Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ - context.core.tracklist.random = state + context.core.tracklist.set_random(state) @protocol.commands.add('repeat', state=protocol.BOOL) @@ -294,7 +295,7 @@ def repeat(context, state): Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ - context.core.tracklist.repeat = state + context.core.tracklist.set_repeat(state) @protocol.commands.add('replay_gain_mode') @@ -327,8 +328,8 @@ def replay_gain_status(context): return 'off' # TODO -@protocol.commands.add('seek', tlid=protocol.UINT, seconds=protocol.UINT) -def seek(context, tlid, seconds): +@protocol.commands.add('seek', songpos=protocol.UINT, seconds=protocol.UINT) +def seek(context, songpos, seconds): """ *musicpd.org, playback section:* @@ -341,9 +342,9 @@ def seek(context, tlid, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - tl_track = context.core.playback.current_tl_track.get() - if context.core.tracklist.index(tl_track).get() != tlid: - play(context, tlid) + tl_track = context.core.playback.get_current_tl_track().get() + if context.core.tracklist.index(tl_track).get() != songpos: + play(context, songpos) context.core.playback.seek(seconds * 1000).get() @@ -356,7 +357,7 @@ def seekid(context, tlid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ - tl_track = context.core.playback.current_tl_track.get() + tl_track = context.core.playback.get_current_tl_track().get() if not tl_track or tl_track.tlid != tlid: playid(context, tlid) context.core.playback.seek(seconds * 1000).get() @@ -373,7 +374,7 @@ def seekcur(context, time): '+' or '-', then the time is relative to the current playing position. """ if time.startswith(('+', '-')): - position = context.core.playback.time_position.get() + position = context.core.playback.get_time_position().get() position += protocol.INT(time) * 1000 context.core.playback.seek(position).get() else: @@ -412,7 +413,7 @@ def single(context, state): single is activated, playback is stopped after current song, or song is repeated if the ``repeat`` mode is enabled. """ - context.core.tracklist.single = state + context.core.tracklist.set_single(state) @protocol.commands.add('stop') diff --git a/mopidy/mpd/protocol/reflection.py b/mopidy/mpd/protocol/reflection.py index 7feccca1..2f96be48 100644 --- a/mopidy/mpd/protocol/reflection.py +++ b/mopidy/mpd/protocol/reflection.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals from mopidy.mpd import exceptions, protocol +from mopidy.mpd.protocol import tagtype_list @protocol.commands.add('config', list_command=False) @@ -93,7 +94,9 @@ def tagtypes(context): Shows a list of available song metadata. """ - pass # TODO + return [ + ('tagtype', tagtype) for tagtype in tagtype_list.TAGTYPE_LIST + ] @protocol.commands.add('urlhandlers') @@ -107,4 +110,4 @@ def urlhandlers(context): """ return [ ('handler', uri_scheme) - for uri_scheme in context.core.uri_schemes.get()] + for uri_scheme in context.core.get_uri_schemes().get()] diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index aa78b387..16e9d013 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -34,7 +34,7 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - tl_track = context.core.playback.current_tl_track.get() + tl_track = context.core.playback.get_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() @@ -172,20 +172,20 @@ def status(context): - ``elapsed``: Higher resolution means time in seconds with three decimal places for millisecond precision. """ + tl_track = context.core.playback.get_current_tl_track() + futures = { - 'tracklist.length': context.core.tracklist.length, - 'tracklist.version': context.core.tracklist.version, + 'tracklist.length': context.core.tracklist.get_length(), + 'tracklist.version': context.core.tracklist.get_version(), '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, - 'tracklist.single': context.core.tracklist.single, - 'playback.state': context.core.playback.state, - 'playback.current_tl_track': context.core.playback.current_tl_track, - 'tracklist.index': ( - context.core.tracklist.index( - context.core.playback.current_tl_track.get())), - 'playback.time_position': context.core.playback.time_position, + 'tracklist.consume': context.core.tracklist.get_consume(), + 'tracklist.random': context.core.tracklist.get_random(), + 'tracklist.repeat': context.core.tracklist.get_repeat(), + 'tracklist.single': context.core.tracklist.get_single(), + 'playback.state': context.core.playback.get_state(), + 'playback.current_tl_track': tl_track, + 'tracklist.index': context.core.tracklist.index(tl_track.get()), + 'playback.time_position': context.core.playback.get_time_position(), } pykka.get_all(futures.values()) result = [ @@ -199,6 +199,7 @@ def status(context): ('xfade', _status_xfade(futures)), ('state', _status_state(futures)), ] + # TODO: add nextsong and nextsongid if futures['playback.current_tl_track'].get() is not None: result.append(('song', _status_songpos(futures))) result.append(('songid', _status_songid(futures))) diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index 9d9f66e0..bf31fa10 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 @@ -74,29 +75,29 @@ def listplaylists(context): - ncmpcpp 0.5.10 segfaults if we return 'playlist: ' on a line, so we must ignore playlists without names, which isn't very useful anyway. """ + last_modified = _get_last_modified() result = [] - for playlist in context.core.playlists.get_playlists().get(): - if not playlist.name: + for playlist_ref in context.core.playlists.as_list().get(): + if not playlist_ref.name: continue - name = context.lookup_playlist_name_from_uri(playlist.uri) + name = context.lookup_playlist_name_from_uri(playlist_ref.uri) result.append(('playlist', name)) - result.append(('Last-Modified', _get_last_modified(playlist))) + result.append(('Last-Modified', last_modified)) return result # TODO: move to translators? -def _get_last_modified(playlist): +def _get_last_modified(last_modified=None): """Formats last modified timestamp of a playlist for MPD. Time in UTC with second precision, formatted in the ISO 8601 format, with the "Z" time zone marker for UTC. For example, "1970-01-01T00:00:00Z". """ - if playlist.last_modified is None: + if last_modified is None: # If unknown, assume the playlist is modified dt = datetime.datetime.utcnow() else: - dt = datetime.datetime.utcfromtimestamp( - playlist.last_modified / 1000.0) + dt = datetime.datetime.utcfromtimestamp(last_modified / 1000.0) dt = dt.replace(microsecond=0) return '%sZ' % dt.isoformat() @@ -127,7 +128,10 @@ def load(context, name, playlist_slice=slice(0, None)): 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/protocol/tagtype_list.py b/mopidy/mpd/protocol/tagtype_list.py new file mode 100644 index 00000000..d9dee145 --- /dev/null +++ b/mopidy/mpd/protocol/tagtype_list.py @@ -0,0 +1,24 @@ +from __future__ import unicode_literals + + +TAGTYPE_LIST = [ + 'Artist', + 'ArtistSort', + 'Album', + 'AlbumArtist', + 'AlbumArtistSort', + 'Title', + 'Track', + 'Name', + 'Genre', + 'Date', + 'Composer', + 'Performer', + 'Disc', + 'MUSICBRAINZ_ARTISTID', + 'MUSICBRAINZ_ALBUMID', + 'MUSICBRAINZ_ALBUMARTISTID', + 'MUSICBRAINZ_TRACKID', + 'X-AlbumUri', + 'X-AlbumImage', +] diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index 9f7fabeb..68550f3b 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -2,13 +2,14 @@ from __future__ import absolute_import, unicode_literals import logging +from mopidy.internal import formatting, network from mopidy.mpd import dispatcher, protocol -from mopidy.utils import formatting, network 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. diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index b1c139b7..4aa4bdb9 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -1,8 +1,10 @@ from __future__ import absolute_import, unicode_literals +import datetime import re from mopidy.models import TlTrack +from mopidy.mpd.protocol import tagtype_list # TODO: special handling of local:// uri scheme normalize_path_re = re.compile(r'[^/]+') @@ -34,10 +36,8 @@ def track_to_mpd_format(track, position=None, stream_title=None): 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)), + ('Artist', concat_multi_values(track.artists, 'name')), ('Album', track.album and track.album.name or ''), ] @@ -61,26 +61,27 @@ def track_to_mpd_format(track, position=None, stream_title=None): result.append(('Id', tlid)) if track.album is not None and track.album.musicbrainz_id is not None: result.append(('MUSICBRAINZ_ALBUMID', track.album.musicbrainz_id)) - # FIXME don't use first and best artist? - # FIXME don't duplicate following code? + if track.album is not None and track.album.artists: - artists = artists_to_mpd_format(track.album.artists) - result.append(('AlbumArtist', artists)) - artists = [ - a for a in track.album.artists if a.musicbrainz_id is not None] - if artists: - result.append( - ('MUSICBRAINZ_ALBUMARTISTID', artists[0].musicbrainz_id)) + result.append( + ('AlbumArtist', concat_multi_values(track.album.artists, 'name'))) + musicbrainz_ids = concat_multi_values( + track.album.artists, 'musicbrainz_id') + if musicbrainz_ids: + result.append(('MUSICBRAINZ_ALBUMARTISTID', musicbrainz_ids)) + if track.artists: - artists = [a for a in track.artists if a.musicbrainz_id is not None] - if artists: - result.append(('MUSICBRAINZ_ARTISTID', artists[0].musicbrainz_id)) + musicbrainz_ids = concat_multi_values(track.artists, 'musicbrainz_id') + if musicbrainz_ids: + result.append(('MUSICBRAINZ_ARTISTID', musicbrainz_ids)) if track.composers: - result.append(('Composer', artists_to_mpd_format(track.composers))) + result.append( + ('Composer', concat_multi_values(track.composers, 'name'))) if track.performers: - result.append(('Performer', artists_to_mpd_format(track.performers))) + result.append( + ('Performer', concat_multi_values(track.performers, 'name'))) if track.genre: result.append(('Genre', track.genre)) @@ -88,22 +89,57 @@ def track_to_mpd_format(track, position=None, stream_title=None): if track.disc_no: result.append(('Disc', track.disc_no)) + if track.last_modified: + datestring = datetime.datetime.utcfromtimestamp( + track.last_modified // 1000).isoformat() + result.append(('Last-Modified', datestring + 'Z')) + if track.musicbrainz_id is not None: result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id)) + + if track.album and track.album.uri: + result.append(('X-AlbumUri', track.album.uri)) + if track.album and track.album.images: + images = ';'.join(i for i in track.album.images if i is not '') + result.append(('X-AlbumImage', images)) + + result = [element for element in result if _has_value(*element)] + return result -def artists_to_mpd_format(artists): +def _has_value(tagtype, value): """ - Format track artists for output to MPD client. + Determine whether to add the tagtype to the output or not. - :param artists: the artists - :type track: array of :class:`mopidy.models.Artist` + :param tagtype: the MPD tagtype + :type tagtype: string + :param value: the tag value + :rtype: bool + """ + if tagtype in tagtype_list.TAGTYPE_LIST: + return bool(value) + return True + + +def concat_multi_values(models, attribute): + """ + Format Mopidy model values for output to MPD client. + + :param models: the models + :type models: array of :class:`mopidy.models.Artist`, + :class:`mopidy.models.Album` or :class:`mopidy.models.Track` + :param attribute: the attribute to use + :type attribute: string :rtype: string """ - artists = list(artists) - artists.sort(key=lambda a: a.name) - return ', '.join([a.name for a in artists if a.name]) + # Don't sort the values. MPD doesn't appear to (or if it does it's not + # strict alphabetical). If we just use them in the order in which they come + # in then the musicbrainz ids have a higher chance of staying in sync + return ';'.join( + getattr(m, attribute) + for m in models if getattr(m, attribute, None) is not None + ) def tracks_to_mpd_format(tracks, start=0, end=None): diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 47bfd58f..ae5be8e0 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -3,26 +3,34 @@ from __future__ import absolute_import, unicode_literals import fnmatch import logging import re +import time import urlparse import pykka -from mopidy import audio as audio_lib, backend, exceptions +import requests + +from mopidy import audio as audio_lib, backend, exceptions, stream from mopidy.audio import scan, utils +from mopidy.internal import http, playlists from mopidy.models import Track logger = logging.getLogger(__name__) class StreamBackend(pykka.ThreadingActor, backend.Backend): + def __init__(self, config, audio): super(StreamBackend, self).__init__() + self._scanner = scan.Scanner( + timeout=config['stream']['timeout'], + proxy_config=config['proxy']) + self.library = StreamLibraryProvider( - backend=self, timeout=config['stream']['timeout'], - blacklist=config['stream']['metadata_blacklist'], - proxy=config['proxy']) - self.playback = backend.PlaybackProvider(audio=audio, backend=self) + backend=self, blacklist=config['stream']['metadata_blacklist']) + self.playback = StreamPlaybackProvider( + audio=audio, backend=self, config=config) self.playlists = None self.uri_schemes = audio_lib.supported_uri_schemes( @@ -30,9 +38,10 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend): class StreamLibraryProvider(backend.LibraryProvider): - def __init__(self, backend, timeout, blacklist, proxy): + + def __init__(self, backend, blacklist): super(StreamLibraryProvider, self).__init__(backend) - self._scanner = scan.Scanner(timeout=timeout, proxy_config=proxy) + self._scanner = backend._scanner self._blacklist_re = re.compile( r'^(%s)$' % '|'.join(fnmatch.translate(u) for u in blacklist)) @@ -46,10 +55,74 @@ class StreamLibraryProvider(backend.LibraryProvider): try: result = self._scanner.scan(uri) - track = utils.convert_tags_to_track(result.tags).copy( + track = utils.convert_tags_to_track(result.tags).replace( uri=uri, length=result.duration) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) track = Track(uri=uri) return [track] + + +class StreamPlaybackProvider(backend.PlaybackProvider): + + def __init__(self, audio, backend, config): + super(StreamPlaybackProvider, self).__init__(audio, backend) + self._config = config + self._scanner = backend._scanner + + def translate_uri(self, uri): + try: + scan_result = self._scanner.scan(uri) + except exceptions.ScannerError as e: + logger.warning( + 'Problem scanning URI %s: %s', uri, e) + return None + + if not (scan_result.mime.startswith('text/') or + scan_result.mime.startswith('application/')): + return uri + + content = self._download(uri) + if content is None: + return None + + tracks = list(playlists.parse(content)) + if tracks: + # TODO Test streams and return first that seems to be playable + return tracks[0] + + def _download(self, uri): + timeout = self._config['stream']['timeout'] / 1000.0 + + session = http.get_requests_session( + proxy_config=self._config['proxy'], + user_agent='%s/%s' % ( + stream.Extension.dist_name, stream.Extension.version)) + + try: + response = session.get( + uri, stream=True, timeout=timeout) + except requests.exceptions.Timeout: + logger.warning( + 'Download of stream playlist (%s) failed due to connection ' + 'timeout after %.3fs', uri, timeout) + return None + + deadline = time.time() + timeout + content = [] + for chunk in response.iter_content(4096): + content.append(chunk) + if time.time() > deadline: + logger.warning( + 'Download of stream playlist (%s) failed due to download ' + 'taking more than %.3fs', uri, timeout) + return None + + if not response.ok: + logger.warning( + 'Problem downloading stream playlist %s: %s', + uri, response.reason) + return None + + return b''.join(content) diff --git a/mopidy/stream/ext.conf b/mopidy/stream/ext.conf index cedb3085..928ccc63 100644 --- a/mopidy/stream/ext.conf +++ b/mopidy/stream/ext.conf @@ -1,7 +1,6 @@ [stream] enabled = true protocols = - file http https mms diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py deleted file mode 100644 index bf4756d7..00000000 --- a/mopidy/utils/deprecation.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import unicode_literals - - -def deprecated_property( - getter=None, setter=None, message='Property is deprecated'): - - # During development, this is a convenient place to add logging, emit - # warnings, or ``assert False`` to ensure you are not using any of the - # deprecated properties. - # - # Using inspect to find the call sites to emit proper warnings makes - # parallel execution of our test suite slower than serial execution. Thus, - # we don't want to add any extra overhead here by default. - - return property(getter, setter) diff --git a/mopidy/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.py b/setup.py index 9f33236f..ba74179c 100644 --- a/setup.py +++ b/setup.py @@ -24,8 +24,9 @@ setup( zip_safe=False, include_package_data=True, install_requires=[ - 'setuptools', 'Pykka >= 1.1', + 'requests', + 'setuptools', 'tornado >= 2.3', ], extras_require={'http': []}, @@ -36,6 +37,7 @@ setup( 'mopidy.ext': [ 'http = mopidy.http:Extension', 'local = mopidy.local:Extension', + 'file = mopidy.file:Extension', 'm3u = mopidy.m3u:Extension', 'mpd = mopidy.mpd:Extension', 'softwaremixer = mopidy.softwaremixer:Extension', diff --git a/tests/__init__.py b/tests/__init__.py index 4283e604..c76c48f0 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 @@ -31,6 +32,6 @@ class IsA(object): return str(self.klass) -any_int = IsA(int) +any_int = IsA((int, long)) any_str = IsA(str) any_unicode = IsA(compat.text_type) diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index b00646bc..046971a8 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -16,7 +16,7 @@ import pykka from mopidy import audio from mopidy.audio.constants import PlaybackState -from mopidy.utils.path import path_to_uri +from mopidy.internal import path from tests import dummy_audio, path_to_data_dir @@ -36,8 +36,8 @@ class BaseTest(unittest.TestCase): } } - uris = [path_to_uri(path_to_data_dir('song1.wav')), - path_to_uri(path_to_data_dir('song2.wav'))] + uris = [path.path_to_uri(path_to_data_dir('song1.wav')), + path.path_to_uri(path_to_data_dir('song2.wav'))] audio_class = audio.Audio @@ -53,7 +53,7 @@ class BaseTest(unittest.TestCase): 'hostname': '', }, } - self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) + self.song_uri = path.path_to_uri(path_to_data_dir('song1.wav')) self.audio = self.audio_class.start(config=config, mixer=None).proxy() def tearDown(self): # noqa @@ -79,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]) @@ -134,6 +135,7 @@ class AudioDummyTest(DummyMixin, AudioTest): @mock.patch.object(audio.AudioListener, 'send') class AudioEventTest(BaseTest): + def setUp(self): # noqa: N802 super(AudioEventTest, self).setUp() self.audio.enable_sync_handler().get() @@ -435,11 +437,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): @@ -460,6 +464,7 @@ class MixerTest(BaseTest): class AudioStateTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.audio = audio.Audio(config=None, mixer=None) @@ -505,6 +510,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_scan.py b/tests/audio/test_scan.py index f58b2202..c558835e 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -8,12 +8,13 @@ gobject.threads_init() from mopidy import exceptions from mopidy.audio import scan -from mopidy.utils import path as path_lib +from mopidy.internal import path as path_lib from tests import path_to_data_dir class ScannerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.errors = {} self.result = {} @@ -38,17 +39,26 @@ class ScannerTest(unittest.TestCase): name = path_to_data_dir(name) self.assertEqual(self.result[name].tags[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.result.values()[0].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() + ogg = path_to_data_dir('scanner/simple/song1.ogg') mp3 = path_to_data_dir('scanner/simple/song1.mp3') self.assertEqual(self.result[mp3].duration, 4680) @@ -56,16 +66,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']) @@ -79,6 +98,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() + log = path_to_data_dir('scanner/example.log') self.assertLess(self.result[log].duration, 100) diff --git a/tests/audio/test_utils.py b/tests/audio/test_utils.py index f1f15761..0b497dad 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'], @@ -30,11 +31,13 @@ class TagsToTrackTest(unittest.TestCase): 'musicbrainz-trackid': ['trackid'], 'musicbrainz-albumid': ['albumid'], 'musicbrainz-artistid': ['artistid'], + 'musicbrainz-sortname': ['sortname'], 'musicbrainz-albumartistid': ['albumartistid'], 'bitrate': [1000], } - artist = Artist(name='artist', musicbrainz_id='artistid') + artist = Artist(name='artist', musicbrainz_id='artistid', + sortname='sortname') composer = Artist(name='composer') performer = Artist(name='performer') albumartist = Artist(name='albumartist', @@ -58,7 +61,7 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_no(self): del self.tags['track-number'] - self.check(self.track.copy(track_no=None)) + self.check(self.track.replace(track_no=None)) def test_multiple_track_no(self): self.tags['track-number'].append(9) @@ -66,7 +69,7 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_disc_no(self): del self.tags['album-disc-number'] - self.check(self.track.copy(disc_no=None)) + self.check(self.track.replace(disc_no=None)) def test_multiple_track_disc_no(self): self.tags['album-disc-number'].append(9) @@ -74,15 +77,15 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_name(self): del self.tags['title'] - self.check(self.track.copy(name=None)) + self.check(self.track.replace(name=None)) def test_multiple_track_name(self): self.tags['title'] = ['name1', 'name2'] - self.check(self.track.copy(name='name1; name2')) + self.check(self.track.replace(name='name1; name2')) def test_missing_track_musicbrainz_id(self): del self.tags['musicbrainz-trackid'] - self.check(self.track.copy(musicbrainz_id=None)) + self.check(self.track.replace(musicbrainz_id=None)) def test_multiple_track_musicbrainz_id(self): self.tags['musicbrainz-trackid'].append('id') @@ -90,7 +93,7 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_bitrate(self): del self.tags['bitrate'] - self.check(self.track.copy(bitrate=None)) + self.check(self.track.replace(bitrate=None)) def test_multiple_track_bitrate(self): self.tags['bitrate'].append(1234) @@ -98,15 +101,15 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_genre(self): del self.tags['genre'] - self.check(self.track.copy(genre=None)) + self.check(self.track.replace(genre=None)) def test_multiple_track_genre(self): self.tags['genre'] = ['genre1', 'genre2'] - self.check(self.track.copy(genre='genre1; genre2')) + self.check(self.track.replace(genre='genre1; genre2')) def test_missing_track_date(self): del self.tags['date'] - self.check(self.track.copy(date=None)) + self.check(self.track.replace(date=None)) def test_multiple_track_date(self): self.tags['date'].append(datetime.date(2030, 1, 1)) @@ -114,25 +117,25 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_comment(self): del self.tags['comment'] - self.check(self.track.copy(comment=None)) + self.check(self.track.replace(comment=None)) def test_multiple_track_comment(self): self.tags['comment'] = ['comment1', 'comment2'] - self.check(self.track.copy(comment='comment1; comment2')) + self.check(self.track.replace(comment='comment1; comment2')) def test_missing_track_artist_name(self): del self.tags['artist'] - self.check(self.track.copy(artists=[])) + self.check(self.track.replace(artists=[])) def test_multiple_track_artist_name(self): self.tags['artist'] = ['name1', 'name2'] artists = [Artist(name='name1'), Artist(name='name2')] - self.check(self.track.copy(artists=artists)) + self.check(self.track.replace(artists=artists)) def test_missing_track_artist_musicbrainz_id(self): del self.tags['musicbrainz-artistid'] - artist = list(self.track.artists)[0].copy(musicbrainz_id=None) - self.check(self.track.copy(artists=[artist])) + artist = list(self.track.artists)[0].replace(musicbrainz_id=None) + self.check(self.track.replace(artists=[artist])) def test_multiple_track_artist_musicbrainz_id(self): self.tags['musicbrainz-artistid'].append('id') @@ -140,25 +143,25 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_composer_name(self): del self.tags['composer'] - self.check(self.track.copy(composers=[])) + self.check(self.track.replace(composers=[])) def test_multiple_track_composer_name(self): self.tags['composer'] = ['composer1', 'composer2'] composers = [Artist(name='composer1'), Artist(name='composer2')] - self.check(self.track.copy(composers=composers)) + self.check(self.track.replace(composers=composers)) def test_missing_track_performer_name(self): del self.tags['performer'] - self.check(self.track.copy(performers=[])) + self.check(self.track.replace(performers=[])) def test_multiple_track_performe_name(self): self.tags['performer'] = ['performer1', 'performer2'] performers = [Artist(name='performer1'), Artist(name='performer2')] - self.check(self.track.copy(performers=performers)) + self.check(self.track.replace(performers=performers)) def test_missing_album_name(self): del self.tags['album'] - self.check(self.track.copy(album=None)) + self.check(self.track.replace(album=None)) def test_multiple_album_name(self): self.tags['album'].append('album2') @@ -166,9 +169,9 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_album_musicbrainz_id(self): del self.tags['musicbrainz-albumid'] - album = self.track.album.copy(musicbrainz_id=None, - images=[]) - self.check(self.track.copy(album=album)) + album = self.track.album.replace(musicbrainz_id=None, + images=[]) + self.check(self.track.replace(album=album)) def test_multiple_album_musicbrainz_id(self): self.tags['musicbrainz-albumid'].append('id') @@ -176,8 +179,8 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_album_num_tracks(self): del self.tags['track-count'] - album = self.track.album.copy(num_tracks=None) - self.check(self.track.copy(album=album)) + album = self.track.album.replace(num_tracks=None) + self.check(self.track.replace(album=album)) def test_multiple_album_num_tracks(self): self.tags['track-count'].append(9) @@ -185,8 +188,8 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_album_num_discs(self): del self.tags['album-disc-count'] - album = self.track.album.copy(num_discs=None) - self.check(self.track.copy(album=album)) + album = self.track.album.replace(num_discs=None) + self.check(self.track.replace(album=album)) def test_multiple_album_num_discs(self): self.tags['album-disc-count'].append(9) @@ -194,21 +197,21 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_album_artist_name(self): del self.tags['album-artist'] - album = self.track.album.copy(artists=[]) - self.check(self.track.copy(album=album)) + album = self.track.album.replace(artists=[]) + self.check(self.track.replace(album=album)) def test_multiple_album_artist_name(self): self.tags['album-artist'] = ['name1', 'name2'] artists = [Artist(name='name1'), Artist(name='name2')] - album = self.track.album.copy(artists=artists) - self.check(self.track.copy(album=album)) + album = self.track.album.replace(artists=artists) + self.check(self.track.replace(album=album)) def test_missing_album_artist_musicbrainz_id(self): del self.tags['musicbrainz-albumartistid'] albumartist = list(self.track.album.artists)[0] - albumartist = albumartist.copy(musicbrainz_id=None) - album = self.track.album.copy(artists=[albumartist]) - self.check(self.track.copy(album=album)) + albumartist = albumartist.replace(musicbrainz_id=None) + album = self.track.album.replace(artists=[albumartist]) + self.check(self.track.replace(album=album)) def test_multiple_album_artist_musicbrainz_id(self): self.tags['musicbrainz-albumartistid'].append('id') @@ -217,30 +220,42 @@ class TagsToTrackTest(unittest.TestCase): def test_stream_organization_track_name(self): del self.tags['title'] self.tags['organization'] = ['organization'] - self.check(self.track.copy(name='organization')) + self.check(self.track.replace(name='organization')) def test_multiple_organization_track_name(self): del self.tags['title'] self.tags['organization'] = ['organization1', 'organization2'] - self.check(self.track.copy(name='organization1; organization2')) + self.check(self.track.replace(name='organization1; organization2')) # TODO: combine all comment types? def test_stream_location_track_comment(self): del self.tags['comment'] self.tags['location'] = ['location'] - self.check(self.track.copy(comment='location')) + self.check(self.track.replace(comment='location')) def test_multiple_location_track_comment(self): del self.tags['comment'] self.tags['location'] = ['location1', 'location2'] - self.check(self.track.copy(comment='location1; location2')) + self.check(self.track.replace(comment='location1; location2')) def test_stream_copyright_track_comment(self): del self.tags['comment'] self.tags['copyright'] = ['copyright'] - self.check(self.track.copy(comment='copyright')) + self.check(self.track.replace(comment='copyright')) def test_multiple_copyright_track_comment(self): del self.tags['comment'] self.tags['copyright'] = ['copyright1', 'copyright2'] - self.check(self.track.copy(comment='copyright1; copyright2')) + self.check(self.track.replace(comment='copyright1; copyright2')) + + def test_sortname(self): + self.tags['musicbrainz-sortname'] = ['another_sortname'] + artist = Artist(name='artist', sortname='another_sortname', + musicbrainz_id='artistid') + self.check(self.track.replace(artists=[artist])) + + def test_missing_sortname(self): + del self.tags['musicbrainz-sortname'] + artist = Artist(name='artist', sortname=None, + musicbrainz_id='artistid') + self.check(self.track.replace(artists=[artist])) 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 8ee91d0d..fa2285b8 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -6,12 +6,13 @@ import unittest import mock -from mopidy import config +from mopidy import config, ext from tests import path_to_data_dir class LoadConfigTest(unittest.TestCase): + def test_load_nothing(self): self.assertEqual({}, config._load([], [], [])) @@ -96,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() @@ -290,3 +292,23 @@ class PostProcessorTest(unittest.TestCase): def test_conversion(self): result = config._postprocess(PROCESSED_CONFIG) self.assertEqual(result, INPUT_CONFIG) + + +def test_format_initial(): + extension = ext.Extension() + extension.ext_name = 'foo' + extension.get_default_config = lambda: None + extensions_data = [ + ext.ExtensionData( + extension=extension, + entry_point=None, + config_schema=None, + config_defaults=None, + command=None, + ), + ] + + result = config.format_initial(extensions_data) + + assert '# For further information' in result + assert '[foo]\n' in result diff --git a/tests/config/test_defaults.py b/tests/config/test_defaults.py new file mode 100644 index 00000000..0cf78f6f --- /dev/null +++ b/tests/config/test_defaults.py @@ -0,0 +1,26 @@ +from __future__ import absolute_import, unicode_literals + +from mopidy import config + + +def test_core_schema_has_cache_dir(): + assert 'cache_dir' in config._core_schema + assert isinstance(config._core_schema['cache_dir'], config.Path) + + +def test_core_schema_has_config_dir(): + assert 'config_dir' in config._core_schema + assert isinstance(config._core_schema['config_dir'], config.Path) + + +def test_core_schema_has_data_dir(): + assert 'data_dir' in config._core_schema + assert isinstance(config._core_schema['data_dir'], config.Path) + + +def test_core_schema_has_max_tracklist_length(): + assert 'max_tracklist_length' in config._core_schema + max_tracklist_length_schema = config._core_schema['max_tracklist_length'] + assert isinstance(max_tracklist_length_schema, config.Integer) + assert max_tracklist_length_schema._minimum == 1 + assert max_tracklist_length_schema._maximum == 10000 diff --git a/tests/config/test_schemas.py b/tests/config/test_schemas.py index 502bf61c..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() @@ -87,6 +88,7 @@ class ConfigSchemaTest(unittest.TestCase): class MapConfigSchemaTest(unittest.TestCase): + def test_conversion(self): schema = schemas.MapConfigSchema('test', types.LogLevel()) result, errors = schema.deserialize( @@ -97,6 +99,7 @@ class MapConfigSchemaTest(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 365fa9e0..40226c51 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'): @@ -312,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() @@ -339,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')) @@ -356,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) @@ -364,7 +373,7 @@ class ExpandedPathTest(unittest.TestCase): expanded = b'expanded_path' self.assertEqual(expanded, types.ExpandedPath(original, expanded)) - @mock.patch('mopidy.utils.path.expand_path') + @mock.patch('mopidy.internal.path.expand_path') def test_orginal_stores_unexpanded(self, expand_path_mock): original = b'~' expanded = b'expanded_path' @@ -373,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..410933d2 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -7,10 +7,11 @@ import mock import pykka from mopidy.core import Core -from mopidy.utils import versioning +from mopidy.internal 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 942f9b5f..be47d506 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -7,6 +7,7 @@ import mock import pykka from mopidy import core +from mopidy.internal import deprecation from mopidy.models import Track from tests import dummy_backend @@ -14,9 +15,21 @@ from tests import dummy_backend @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): + def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.backend = dummy_backend.create_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + self.backend.library.dummy_library = [ + Track(uri='dummy:a'), Track(uri='dummy:b')] + + with deprecation.ignore(): + self.core = core.Core.start( + config, backends=[self.backend]).proxy() def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() @@ -39,76 +52,62 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[1]['mute'], True) 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() - send.reset_mock() + self.core.tracklist.add(uris=['dummy:a']).get() self.core.tracklist.clear().get() 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() - send.reset_mock() + self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() self.core.tracklist.move(0, 1, 1).get() 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() - send.reset_mock() + self.core.tracklist.add(uris=['dummy:a']).get() - self.core.tracklist.remove(uri=['dummy:a']).get() + self.core.tracklist.remove({'uri': ['dummy:a']}).get() 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() - send.reset_mock() + self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() self.core.tracklist.shuffle().get() self.assertEqual(send.call_args[0][0], 'tracklist_changed') 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') def test_playlists_refresh_uri_sends_playlists_loaded_event(self, send): - send.reset_mock() - self.core.playlists.refresh(uri_scheme='dummy').get() self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_playlists_create_sends_playlist_changed_event(self, send): - send.reset_mock() - self.core.playlists.create('foo').get() self.assertEqual(send.call_args[0][0], 'playlist_changed') - @unittest.SkipTest def test_playlists_delete_sends_playlist_deleted_event(self, send): - # TODO We should probably add a playlist_deleted event - pass + playlist = self.core.playlists.create('foo').get() + self.core.playlists.delete(playlist.uri).get() + + self.assertEqual(send.call_args[0][0], 'playlist_deleted') def test_playlists_save_sends_playlist_changed_event(self, send): playlist = self.core.playlists.create('foo').get() - playlist = playlist.copy(name='bar') - send.reset_mock() + playlist = playlist.replace(name='bar') self.core.playlists.save(playlist).get() diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 48062aaf..068518b6 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -40,7 +40,7 @@ class PlaybackHistoryTest(unittest.TestCase): result = self.history.get_history() (timestamp, ref) = result[0] - self.assertIsInstance(timestamp, int) + self.assertIsInstance(timestamp, (int, long)) self.assertEqual(track.uri, ref.uri) self.assertIn(track.name, ref.name) for artist in track.artists: diff --git a/tests/core/test_library.py b/tests/core/test_library.py index c49809cf..941f1831 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -5,38 +5,45 @@ import unittest import mock from mopidy import backend, core +from mopidy.internal import deprecation from mopidy.models import Image, Ref, SearchResult, Track -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.backend1.actor_ref.actor_class.__name__ = 'DummyBackend1' self.library1 = mock.Mock(spec=backend.LibraryProvider) - self.library1.get_images().get.return_value = {} - self.library1.get_images.reset_mock() + self.library1.get_images.return_value.get.return_value = {} self.library1.root_directory.get.return_value = dummy1_root self.backend1.library = self.library1 dummy2_root = Ref.directory(uri='dummy2:directory', name='dummy2') self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2', 'du2'] + self.backend2.actor_ref.actor_class.__name__ = 'DummyBackend2' self.library2 = mock.Mock(spec=backend.LibraryProvider) - self.library2.get_images().get.return_value = {} - self.library2.get_images.reset_mock() + self.library2.get_images.return_value.get.return_value = {} self.library2.root_directory.get.return_value = dummy2_root self.backend2.library = self.library2 # A backend without the optional library provider self.backend3 = mock.Mock() self.backend3.uri_schemes.get.return_value = ['dummy3'] + self.backend3.actor_ref.actor_class.__name__ = 'DummyBackend3' self.backend3.has_library().get.return_value = False self.backend3.has_library_browse().get.return_value = False 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([])) @@ -59,20 +66,17 @@ class CoreLibraryTest(unittest.TestCase): self.library2.get_images.assert_called_once_with(['dummy2:track']) def test_get_images_returns_images(self): - self.library1.get_images().get.return_value = { + self.library1.get_images.return_value.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 = { + self.library1.get_images.return_value.get.return_value = { 'dummy1:track': [Image(uri='uri1')]} - self.library1.get_images.reset_mock() - self.library2.get_images().get.return_value = { + self.library2.get_images.return_value.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']) @@ -100,11 +104,10 @@ class CoreLibraryTest(unittest.TestCase): self.assertFalse(self.library2.browse.called) def test_browse_dummy1_selects_dummy1_backend(self): - self.library1.browse().get.return_value = [ + self.library1.browse.return_value.get.return_value = [ Ref.directory(uri='dummy1:directory:/foo/bar', name='bar'), Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'), ] - self.library1.browse.reset_mock() self.core.library.browse('dummy1:directory:/foo') @@ -113,11 +116,10 @@ class CoreLibraryTest(unittest.TestCase): self.library1.browse.assert_called_with('dummy1:directory:/foo') def test_browse_dummy2_selects_dummy2_backend(self): - self.library2.browse().get.return_value = [ + self.library2.browse.return_value.get.return_value = [ Ref.directory(uri='dummy2:directory:/bar/baz', name='quux'), Ref.track(uri='dummy2:track:/bar/foo.mp3', name='Baz'), ] - self.library2.browse.reset_mock() self.core.library.browse('dummy2:directory:/bar') @@ -133,11 +135,10 @@ class CoreLibraryTest(unittest.TestCase): self.assertEqual(self.library2.browse.call_count, 0) def test_browse_dir_returns_subdirs_and_tracks(self): - self.library1.browse().get.return_value = [ + self.library1.browse.return_value.get.return_value = [ Ref.directory(uri='dummy1:directory:/foo/bar', name='Bar'), Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'), ] - self.library1.browse.reset_mock() result = self.core.library.browse('dummy1:directory:/foo') self.assertEqual(result, [ @@ -145,35 +146,19 @@ 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') - - 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_fails_with_uri_and_uris_set(self): with self.assertRaises(ValueError): self.core.library.lookup('dummy1:a', ['dummy2:a']) def test_lookup_can_handle_uris(self): - self.library1.lookup().get.return_value = [1234] - self.library2.lookup().get.return_value = [5678] + track1 = Track(name='abc') + track2 = Track(name='def') + + self.library1.lookup().get.return_value = [track1] + self.library2.lookup().get.return_value = [track2] result = self.core.library.lookup(uris=['dummy1:a', 'dummy2:a']) - self.assertEqual(result, {'dummy2:a': [5678], 'dummy1:a': [1234]}) - - def test_lookup_uri_returns_empty_list_for_dummy3_track(self): - result = self.core.library.lookup('dummy3:a') - - self.assertEqual(result, []) - self.assertFalse(self.library1.lookup.called) - self.assertFalse(self.library2.lookup.called) + self.assertEqual(result, {'dummy2:a': [track2], 'dummy1:a': [track1]}) def test_lookup_uris_returns_empty_list_for_dummy3_track(self): result = self.core.library.lookup(uris=['dummy3:a']) @@ -203,9 +188,89 @@ class CoreLibraryTest(unittest.TestCase): def test_refresh_without_uri_calls_all_backends(self): self.core.library.refresh() - self.library1.refresh.assert_called_once_with(None) - self.library2.refresh.assert_called_with(None) - self.assertEqual(self.library2.refresh.call_count, 2) + self.library1.refresh.return_value.get.assert_called_once_with() + self.library2.refresh.return_value.get.assert_called_once_with() + + def test_search_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.search({'any': ['a']}) + + self.assertIn(result1, result) + self.assertIn(result2, result) + self.library1.search.assert_called_once_with( + query={'any': ['a']}, uris=None, exact=False) + self.library2.search.assert_called_once_with( + query={'any': ['a']}, uris=None, exact=False) + + def test_search_with_uris_selects_dummy1_backend(self): + self.core.library.search( + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy3:']) + + self.library1.search.assert_called_once_with( + 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={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy2:']) + + self.library1.search.assert_called_once_with( + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=False) + self.library2.search.assert_called_once_with( + query={'any': ['a']}, uris=['dummy2:'], exact=False) + + def test_search_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.search({'any': ['a']}) + + self.assertIn(result1, result) + self.assertNotIn(None, result) + self.library1.search.assert_called_once_with( + query={'any': ['a']}, uris=None, exact=False) + self.library2.search.assert_called_once_with( + query={'any': ['a']}, uris=None, exact=False) + + def test_search_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.search({'any': ['a']}) + + self.assertIn(result1, result) + self.assertIn(result2, result) + self.library1.search.assert_called_once_with( + query={'any': ['a']}, uris=None, exact=False) + self.library2.search.assert_called_once_with( + 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') @@ -216,7 +281,7 @@ class CoreLibraryTest(unittest.TestCase): 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']) + result = self.core.library.find_exact({'any': ['a']}) self.assertIn(result1, result) self.assertIn(result2, result) @@ -227,20 +292,20 @@ class CoreLibraryTest(unittest.TestCase): def test_find_exact_with_uris_selects_dummy1_backend(self): self.core.library.find_exact( - 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'], exact=True) + 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( - 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'], exact=True) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=True) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy2:'], exact=True) + query={'any': ['a']}, uris=['dummy2:'], exact=True) def test_find_exact_filters_out_none(self): track1 = Track(uri='dummy1:a') @@ -249,14 +314,14 @@ class CoreLibraryTest(unittest.TestCase): 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']) + result = self.core.library.find_exact({'any': ['a']}) self.assertIn(result1, result) self.assertNotIn(None, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) + query={'any': ['a']}, uris=None, exact=True) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) + query={'any': ['a']}, uris=None, exact=True) def test_find_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') @@ -267,102 +332,56 @@ class CoreLibraryTest(unittest.TestCase): self.library1.search.return_value.get.return_value = result1 self.library2.search.return_value.get.return_value = result2 - result = self.core.library.find_exact(dict(any=['a'])) + 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) + query={'any': ['a']}, uris=None, exact=True) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) - - def test_search_combines_results_from_all_backends(self): - track1 = Track(uri='dummy1:a') - track2 = Track(uri='dummy2:a') - result1 = SearchResult(tracks=[track1]) - result2 = SearchResult(tracks=[track2]) - - self.library1.search().get.return_value = result1 - self.library1.search.reset_mock() - self.library2.search().get.return_value = result2 - self.library2.search.reset_mock() - - 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, exact=False) - self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=False) - - def test_search_with_uris_selects_dummy1_backend(self): - self.core.library.search( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy3:']) - - self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], 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:']) - - self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=False) - self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy2:'], exact=False) - - def test_search_filters_out_none(self): - track1 = Track(uri='dummy1:a') - result1 = SearchResult(tracks=[track1]) - - self.library1.search().get.return_value = result1 - self.library1.search.reset_mock() - self.library2.search().get.return_value = None - self.library2.search.reset_mock() - - 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, exact=False) - self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=False) - - def test_search_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().get.return_value = result1 - self.library1.search.reset_mock() - self.library2.search().get.return_value = result2 - self.library2.search.reset_mock() - - result = self.core.library.search(dict(any=['a'])) - - self.assertIn(result1, result) - self.assertIn(result2, result) - self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=False) - self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=False) - - def test_search_normalises_bad_queries(self): - self.core.library.search({'any': 'foobar'}) - self.library1.search.assert_called_once_with( - query={'any': ['foobar']}, uris=None, exact=False) + 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.library1.lookup.return_value.get.return_value = [] + 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.library2.lookup.return_value.get.return_value = [] + 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' @@ -394,3 +413,199 @@ class LegacyFindExactToSearchLibraryTest(unittest.TestCase): 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. + + +class MockBackendCoreLibraryBase(unittest.TestCase): + + def setUp(self): # noqa: N802 + dummy_root = Ref.directory(uri='dummy:directory', name='dummy') + + self.library = mock.Mock(spec=backend.LibraryProvider) + self.library.root_directory.get.return_value = dummy_root + + self.backend = mock.Mock() + self.backend.actor_ref.actor_class.__name__ = 'DummyBackend' + self.backend.uri_schemes.get.return_value = ['dummy'] + self.backend.library = self.library + + self.core = core.Core(mixer=None, backends=[self.backend]) + + +@mock.patch('mopidy.core.library.logger') +class BrowseBadBackendTest(MockBackendCoreLibraryBase): + + def test_backend_raises_exception_for_root(self, logger): + # Might happen if root_directory is a property for some weird reason. + self.library.root_directory.get.side_effect = Exception + self.assertEqual([], self.core.library.browse(None)) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_backend_returns_none_for_root(self, logger): + self.library.root_directory.get.return_value = None + self.assertEqual([], self.core.library.browse(None)) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + def test_backend_returns_wrong_type_for_root(self, logger): + self.library.root_directory.get.return_value = 123 + self.assertEqual([], self.core.library.browse(None)) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + def test_backend_raises_exception_for_browse(self, logger): + self.library.browse.return_value.get.side_effect = Exception + self.assertEqual([], self.core.library.browse('dummy:directory')) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_backend_returns_wrong_type_for_browse(self, logger): + self.library.browse.return_value.get.return_value = [123] + self.assertEqual([], self.core.library.browse('dummy:directory')) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + +@mock.patch('mopidy.core.library.logger') +class GetDistinctBadBackendTest(MockBackendCoreLibraryBase): + + def test_backend_raises_exception(self, logger): + self.library.get_distinct.return_value.get.side_effect = Exception + self.assertEqual(set(), self.core.library.get_distinct('artist')) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_backend_returns_none(self, logger): + self.library.get_distinct.return_value.get.return_value = None + self.assertEqual(set(), self.core.library.get_distinct('artist')) + self.assertFalse(logger.error.called) + + def test_backend_returns_wrong_type(self, logger): + self.library.get_distinct.return_value.get.return_value = 'abc' + self.assertEqual(set(), self.core.library.get_distinct('artist')) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + def test_backend_returns_iterable_containing_wrong_types(self, logger): + self.library.get_distinct.return_value.get.return_value = [1, 2, 3] + self.assertEqual(set(), self.core.library.get_distinct('artist')) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + +@mock.patch('mopidy.core.library.logger') +class GetImagesBadBackendTest(MockBackendCoreLibraryBase): + + def test_backend_raises_exception(self, logger): + uri = 'dummy:/1' + self.library.get_images.return_value.get.side_effect = Exception + self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_backend_returns_none(self, logger): + uri = 'dummy:/1' + self.library.get_images.return_value.get.return_value = None + self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) + self.assertFalse(logger.error.called) + + def test_backend_returns_wrong_type(self, logger): + uri = 'dummy:/1' + self.library.get_images.return_value.get.return_value = 'abc' + self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + def test_backend_returns_mapping_containing_wrong_types(self, logger): + uri = 'dummy:/1' + self.library.get_images.return_value.get.return_value = {uri: 'abc'} + self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + def test_backend_returns_mapping_containing_none(self, logger): + uri = 'dummy:/1' + self.library.get_images.return_value.get.return_value = {uri: None} + self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + def test_backend_returns_unknown_uri(self, logger): + uri = 'dummy:/1' + self.library.get_images.return_value.get.return_value = {'foo': []} + self.assertEqual({uri: tuple()}, self.core.library.get_images([uri])) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + +@mock.patch('mopidy.core.library.logger') +class LookupByUrisBadBackendTest(MockBackendCoreLibraryBase): + + def test_backend_raises_exception(self, logger): + uri = 'dummy:/1' + self.library.lookup.return_value.get.side_effect = Exception + self.assertEqual({uri: []}, self.core.library.lookup(uris=[uri])) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_backend_returns_none(self, logger): + uri = 'dummy:/1' + self.library.lookup.return_value.get.return_value = None + self.assertEqual({uri: []}, self.core.library.lookup(uris=[uri])) + self.assertFalse(logger.error.called) + + def test_backend_returns_wrong_type(self, logger): + uri = 'dummy:/1' + self.library.lookup.return_value.get.return_value = 'abc' + self.assertEqual({uri: []}, self.core.library.lookup(uris=[uri])) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + def test_backend_returns_iterable_containing_wrong_types(self, logger): + uri = 'dummy:/1' + self.library.lookup.return_value.get.return_value = [123] + self.assertEqual({uri: []}, self.core.library.lookup(uris=[uri])) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + def test_backend_returns_none_with_uri(self, logger): + uri = 'dummy:/1' + self.library.lookup.return_value.get.return_value = None + self.assertEqual([], self.core.library.lookup(uri)) + self.assertFalse(logger.error.called) + + def test_backend_returns_wrong_type_with_uri(self, logger): + uri = 'dummy:/1' + self.library.lookup.return_value.get.return_value = 'abc' + self.assertEqual([], self.core.library.lookup(uri)) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + def test_backend_returns_iterable_wrong_types_with_uri(self, logger): + uri = 'dummy:/1' + self.library.lookup.return_value.get.return_value = [123] + self.assertEqual([], self.core.library.lookup(uri)) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + +@mock.patch('mopidy.core.library.logger') +class RefreshBadBackendTest(MockBackendCoreLibraryBase): + + def test_backend_raises_exception(self, logger): + self.library.refresh.return_value.get.side_effect = Exception + self.core.library.refresh() + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_backend_raises_exception_with_uri(self, logger): + self.library.refresh.return_value.get.side_effect = Exception + self.core.library.refresh('dummy:/1') + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + +@mock.patch('mopidy.core.library.logger') +class SearchBadBackendTest(MockBackendCoreLibraryBase): + + def test_backend_raises_exception(self, logger): + self.library.search.return_value.get.side_effect = Exception + self.assertEqual([], self.core.library.search(query={'any': ['foo']})) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_backend_raises_lookuperror(self, logger): + # TODO: is this behavior desired? Do we need to continue handling + # LookupError case specially. + self.library.search.return_value.get.side_effect = LookupError + with self.assertRaises(LookupError): + self.core.library.search(query={'any': ['foo']}) + + def test_backend_returns_none(self, logger): + self.library.search.return_value.get.return_value = None + self.assertEqual([], self.core.library.search(query={'any': ['foo']})) + self.assertFalse(logger.error.called) + + def test_backend_returns_wrong_type(self, logger): + self.library.search.return_value.get.return_value = 'abc' + self.assertEqual([], self.core.library.search(query={'any': ['foo']})) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) diff --git a/tests/core/test_listener.py b/tests/core/test_listener.py index 8ec3a843..f78b061b 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() @@ -46,6 +47,9 @@ class CoreListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_playlist_changed(self): self.listener.playlist_changed(Playlist()) + def test_listener_has_default_impl_for_playlist_deleted(self): + self.listener.playlist_deleted(Playlist()) + def test_listener_has_default_impl_for_options_changed(self): self.listener.options_changed() diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index c4126eaa..45241fec 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -11,6 +11,7 @@ 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=[]) @@ -22,6 +23,7 @@ class CoreMixerTest(unittest.TestCase): self.mixer.get_volume.assert_called_once_with() def test_set_volume(self): + self.mixer.set_volume.return_value.get.return_value = True self.core.mixer.set_volume(30) self.mixer.set_volume.assert_called_once_with(30) @@ -33,12 +35,14 @@ class CoreMixerTest(unittest.TestCase): self.mixer.get_mute.assert_called_once_with() def test_set_mute(self): + self.mixer.set_mute.return_value.get.return_value = True 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=[]) @@ -57,6 +61,7 @@ class CoreNoneMixerTest(unittest.TestCase): @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=[]) @@ -78,6 +83,7 @@ class CoreMixerListenerTest(unittest.TestCase): @mock.patch.object(mixer.MixerListener, 'send') class CoreNoneMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.core = core.Core(mixer=None, backends=[]) @@ -88,3 +94,63 @@ class CoreNoneMixerListenerTest(unittest.TestCase): def test_forwards_mixer_mute_changed_event_to_frontends(self, send): self.core.mixer.set_mute(mute=True) self.assertEqual(send.call_count, 0) + + +class MockBackendCoreMixerBase(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.mixer = mock.Mock() + self.mixer.actor_ref.actor_class.__name__ = 'DummyMixer' + self.core = core.Core(mixer=self.mixer, backends=[]) + + +class GetVolumeBadBackendTest(MockBackendCoreMixerBase): + + def test_backend_raises_exception(self): + self.mixer.get_volume.return_value.get.side_effect = Exception + self.assertEqual(self.core.mixer.get_volume(), None) + + def test_backend_returns_too_small_value(self): + self.mixer.get_volume.return_value.get.return_value = -1 + self.assertEqual(self.core.mixer.get_volume(), None) + + def test_backend_returns_too_large_value(self): + self.mixer.get_volume.return_value.get.return_value = 1000 + self.assertEqual(self.core.mixer.get_volume(), None) + + def test_backend_returns_wrong_type(self): + self.mixer.get_volume.return_value.get.return_value = '12' + self.assertEqual(self.core.mixer.get_volume(), None) + + +class SetVolumeBadBackendTest(MockBackendCoreMixerBase): + + def test_backend_raises_exception(self): + self.mixer.set_volume.return_value.get.side_effect = Exception + self.assertFalse(self.core.mixer.set_volume(30)) + + def test_backend_returns_wrong_type(self): + self.mixer.set_volume.return_value.get.return_value = 'done' + self.assertFalse(self.core.mixer.set_volume(30)) + + +class GetMuteBadBackendTest(MockBackendCoreMixerBase): + + def test_backend_raises_exception(self): + self.mixer.get_mute.return_value.get.side_effect = Exception + self.assertEqual(self.core.mixer.get_mute(), None) + + def test_backend_returns_wrong_type(self): + self.mixer.get_mute.return_value.get.return_value = '12' + self.assertEqual(self.core.mixer.get_mute(), None) + + +class SetMuteBadBackendTest(MockBackendCoreMixerBase): + + def test_backend_raises_exception(self): + self.mixer.set_mute.return_value.get.side_effect = Exception + self.assertFalse(self.core.mixer.set_mute(True)) + + def test_backend_returns_wrong_type(self): + self.mixer.set_mute.return_value.get.return_value = 'done' + self.assertFalse(self.core.mixer.set_mute(True)) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 7f395c47..5a8c9649 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -7,26 +7,33 @@ import mock import pykka from mopidy import backend, core +from mopidy.internal import deprecation from mopidy.models import Track from tests import dummy_audio as audio # TODO: split into smaller easier to follow tests. setup is way to complex. +# TODO: just mock tracklist? class CorePlaybackTest(unittest.TestCase): + def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.playback1 = mock.Mock(spec=backend.PlaybackProvider) - self.playback1.get_time_position().get.return_value = 1000 - self.playback1.reset_mock() + self.playback1.get_time_position.return_value.get.return_value = 1000 self.backend1.playback = self.playback1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] self.playback2 = mock.Mock(spec=backend.PlaybackProvider) - self.playback2.get_time_position().get.return_value = 2000 - self.playback2.reset_mock() + self.playback2.get_time_position.return_value.get.return_value = 2000 self.backend2.playback = self.playback2 # A backend without the optional playback provider @@ -42,14 +49,32 @@ class CorePlaybackTest(unittest.TestCase): Track(uri='dummy1:c', length=None), # No duration ] - self.core = core.Core(mixer=None, backends=[ + self.uris = [ + 'dummy1:a', 'dummy2:a', 'dummy3:a', 'dummy1:b', 'dummy1:c'] + + self.core = core.Core(config, 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] + def tearDown(self): # noqa: N802 + self.lookup_patcher.stop() + def trigger_end_of_track(self): self.core.playback._on_end_of_track() @@ -102,6 +127,17 @@ class CorePlaybackTest(unittest.TestCase): self.assertEqual( self.core.playback.get_current_track(), self.tracks[0]) + def test_get_current_tlid_none(self): + self.set_current_tl_track(None) + + self.assertEqual(self.core.playback.get_current_tlid(), None) + + def test_get_current_tlid_play(self): + self.core.playback.play(self.tl_tracks[0]) + + self.assertEqual( + self.core.playback.get_current_tlid(), self.tl_tracks[0].tlid) + # TODO Test state def test_play_selects_dummy1_backend(self): @@ -136,7 +172,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.change_track.return_value.get.return_value = False self.core.tracklist.clear() - self.core.tracklist.add(self.tracks[:2]) + self.core.tracklist.add(uris=self.uris[:2]) tl_tracks = self.core.tracklist.tl_tracks self.core.playback.play(tl_tracks[0]) @@ -500,6 +536,12 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback1.seek.called) self.playback2.seek.assert_called_once_with(10000) + def test_seek_normalizes_negative_positions_to_zero(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.seek(-100) + + self.playback1.seek.assert_called_once_with(0) + def test_seek_fails_for_unplayable_track(self): self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.state = core.PlaybackState.PLAYING @@ -582,20 +624,33 @@ class TestBackend(pykka.ThreadingActor, backend.Backend): class TestStream(unittest.TestCase): + def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.audio = audio.DummyAudio.start().proxy() self.backend = TestBackend.start(config={}, audio=self.audio).proxy() - self.core = core.Core(audio=self.audio, backends=[self.backend]) + self.core = core.Core( + config, audio=self.audio, backends=[self.backend]) self.playback = self.core.playback self.tracks = [Track(uri='dummy:a', length=1234), Track(uri='dummy:b', length=1234)] - self.core.tracklist.add(self.tracks) + 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} + + self.core.tracklist.add(uris=[t.uri for t in self.tracks]) self.events = [] - self.patcher = mock.patch('mopidy.audio.listener.AudioListener.send') - self.send_mock = self.patcher.start() + self.send_patcher = mock.patch( + 'mopidy.audio.listener.AudioListener.send') + self.send_mock = self.send_patcher.start() def send(event, **kwargs): self.events.append((event, kwargs)) @@ -604,7 +659,8 @@ class TestStream(unittest.TestCase): def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() - self.patcher.stop() + self.lookup_patcher.stop() + self.send_patcher.stop() def replay_audio_events(self): while self.events: @@ -659,20 +715,61 @@ class TestStream(unittest.TestCase): class CorePlaybackWithOldBackendTest(unittest.TestCase): + def test_type_error_from_old_backend_does_not_crash_core(self): + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + b = mock.Mock() + b.actor_ref.actor_class.__name__ = 'DummyBackend' 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([Track(uri='dummy1:a', length=40000)]) + c = core.Core(config, mixer=None, backends=[b]) + c.tracklist.add(uris=['dummy1:a']) c.playback.play() # No TypeError == test passed. b.playback.play.assert_called_once_with() +class TestPlay(unittest.TestCase): + + def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + + self.backend = mock.Mock() + self.backend.uri_schemes.get.return_value = ['dummy'] + self.core = core.Core(config, backends=[self.backend]) + + self.tracks = [Track(uri='dummy:a', length=1234), + Track(uri='dummy:b', length=1234)] + + with deprecation.ignore('core.tracklist.add:tracks_arg'): + self.tl_tracks = self.core.tracklist.add(tracks=self.tracks) + + def test_play_tlid(self): + self.core.playback.play(tlid=self.tl_tracks[1].tlid) + self.backend.playback.change_track.assert_called_once_with( + self.tl_tracks[1].track) + + class Bug1177RegressionTest(unittest.TestCase): def test(self): + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + b = mock.Mock() b.uri_schemes.get.return_value = ['dummy'] b.playback = mock.Mock(spec=backend.PlaybackProvider) @@ -682,7 +779,7 @@ class Bug1177RegressionTest(unittest.TestCase): track1 = Track(uri='dummy:a', length=40000) track2 = Track(uri='dummy:b', length=40000) - c = core.Core(mixer=None, backends=[b]) + c = core.Core(config, mixer=None, backends=[b]) c.tracklist.add([track1, track2]) c.playback.play() diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index e02f6204..029254a8 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -5,10 +5,12 @@ import unittest import mock from mopidy import backend, core +from mopidy.internal import deprecation from mopidy.models import Playlist, Ref, Track -class PlaylistsTest(unittest.TestCase): +class BasePlaylistsTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.plr1a = Ref.playlist(name='A', uri='dummy1:pl:a') self.plr1b = Ref.playlist(name='B', uri='dummy1:pl:b') @@ -49,6 +51,9 @@ class PlaylistsTest(unittest.TestCase): self.core = core.Core(mixer=None, backends=[ self.backend3, self.backend1, self.backend2]) + +class PlaylistTest(BasePlaylistsTest): + def test_as_list_combines_result_from_backends(self): result = self.core.playlists.as_list() @@ -83,34 +88,9 @@ class PlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) - def test_get_playlists_combines_result_from_backends(self): - result = self.core.playlists.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) - def test_create_without_uri_scheme_uses_first_backend(self): playlist = Playlist() - self.sp1.create().get.return_value = playlist - self.sp1.reset_mock() + self.sp1.create.return_value.get.return_value = playlist result = self.core.playlists.create('foo') @@ -120,10 +100,8 @@ class PlaylistsTest(unittest.TestCase): def test_create_without_uri_scheme_ignores_none_result(self): playlist = Playlist() - self.sp1.create().get.return_value = None - self.sp1.reset_mock() - self.sp2.create().get.return_value = playlist - self.sp2.reset_mock() + self.sp1.create.return_value.get.return_value = None + self.sp2.create.return_value.get.return_value = playlist result = self.core.playlists.create('foo') @@ -133,10 +111,8 @@ class PlaylistsTest(unittest.TestCase): def test_create_without_uri_scheme_ignores_exception(self): playlist = Playlist() - self.sp1.create().get.side_effect = Exception - self.sp1.reset_mock() - self.sp2.create().get.return_value = playlist - self.sp2.reset_mock() + self.sp1.create.return_value.get.side_effect = Exception + self.sp2.create.return_value.get.return_value = playlist result = self.core.playlists.create('foo') @@ -146,8 +122,7 @@ class PlaylistsTest(unittest.TestCase): def test_create_with_uri_scheme_selects_the_matching_backend(self): playlist = Playlist() - self.sp2.create().get.return_value = playlist - self.sp2.reset_mock() + self.sp2.create.return_value.get.return_value = playlist result = self.core.playlists.create('foo', uri_scheme='dummy2') @@ -157,8 +132,7 @@ class PlaylistsTest(unittest.TestCase): def test_create_with_unsupported_uri_scheme_uses_first_backend(self): playlist = Playlist() - self.sp1.create().get.return_value = playlist - self.sp1.reset_mock() + self.sp1.create.return_value.get.return_value = playlist result = self.core.playlists.create('foo', uri_scheme='dummy3') @@ -190,16 +164,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') @@ -245,8 +209,7 @@ class PlaylistsTest(unittest.TestCase): def test_save_selects_the_dummy1_backend(self): playlist = Playlist(uri='dummy1:a') - self.sp1.save().get.return_value = playlist - self.sp1.reset_mock() + self.sp1.save.return_value.get.return_value = playlist result = self.core.playlists.save(playlist) @@ -256,8 +219,7 @@ class PlaylistsTest(unittest.TestCase): def test_save_selects_the_dummy2_backend(self): playlist = Playlist(uri='dummy2:a') - self.sp2.save().get.return_value = playlist - self.sp2.reset_mock() + self.sp2.save.return_value.get.return_value = playlist result = self.core.playlists.save(playlist) @@ -285,3 +247,190 @@ 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) + + +class MockBackendCorePlaylistsBase(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.playlists = mock.Mock(spec=backend.PlaylistsProvider) + + self.backend = mock.Mock() + self.backend.actor_ref.actor_class.__name__ = 'DummyBackend' + self.backend.uri_schemes.get.return_value = ['dummy'] + self.backend.playlists = self.playlists + + self.core = core.Core(mixer=None, backends=[self.backend]) + + +@mock.patch('mopidy.core.playlists.logger') +class AsListBadBackendsTest(MockBackendCorePlaylistsBase): + + def test_backend_raises_exception(self, logger): + self.playlists.as_list.return_value.get.side_effect = Exception + self.assertEqual([], self.core.playlists.as_list()) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_backend_returns_none(self, logger): + self.playlists.as_list.return_value.get.return_value = None + self.assertEqual([], self.core.playlists.as_list()) + self.assertFalse(logger.error.called) + + def test_backend_returns_wrong_type(self, logger): + self.playlists.as_list.return_value.get.return_value = 'abc' + self.assertEqual([], self.core.playlists.as_list()) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + +@mock.patch('mopidy.core.playlists.logger') +class GetItemsBadBackendsTest(MockBackendCorePlaylistsBase): + + def test_backend_raises_exception(self, logger): + self.playlists.get_items.return_value.get.side_effect = Exception + self.assertIsNone(self.core.playlists.get_items('dummy:/1')) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_backend_returns_none(self, logger): + self.playlists.get_items.return_value.get.return_value = None + self.assertIsNone(self.core.playlists.get_items('dummy:/1')) + self.assertFalse(logger.error.called) + + def test_backend_returns_wrong_type(self, logger): + self.playlists.get_items.return_value.get.return_value = 'abc' + self.assertIsNone(self.core.playlists.get_items('dummy:/1')) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + +@mock.patch('mopidy.core.playlists.logger') +class CreateBadBackendsTest(MockBackendCorePlaylistsBase): + + def test_backend_raises_exception(self, logger): + self.playlists.create.return_value.get.side_effect = Exception + self.assertIsNone(self.core.playlists.create('foobar')) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_backend_returns_none(self, logger): + self.playlists.create.return_value.get.return_value = None + self.assertIsNone(self.core.playlists.create('foobar')) + self.assertFalse(logger.error.called) + + def test_backend_returns_wrong_type(self, logger): + self.playlists.create.return_value.get.return_value = 'abc' + self.assertIsNone(self.core.playlists.create('foobar')) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + +@mock.patch('mopidy.core.playlists.logger') +class DeleteBadBackendsTest(MockBackendCorePlaylistsBase): + + def test_backend_raises_exception(self, logger): + self.playlists.delete.return_value.get.side_effect = Exception + self.assertIsNone(self.core.playlists.delete('dummy:/1')) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + +@mock.patch('mopidy.core.playlists.logger') +class LookupBadBackendsTest(MockBackendCorePlaylistsBase): + + def test_backend_raises_exception(self, logger): + self.playlists.lookup.return_value.get.side_effect = Exception + self.assertIsNone(self.core.playlists.lookup('dummy:/1')) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_backend_returns_none(self, logger): + self.playlists.lookup.return_value.get.return_value = None + self.assertIsNone(self.core.playlists.lookup('dummy:/1')) + self.assertFalse(logger.error.called) + + def test_backend_returns_wrong_type(self, logger): + self.playlists.lookup.return_value.get.return_value = 'abc' + self.assertIsNone(self.core.playlists.lookup('dummy:/1')) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) + + +@mock.patch('mopidy.core.playlists.logger') +class RefreshBadBackendsTest(MockBackendCorePlaylistsBase): + + @mock.patch('mopidy.core.listener.CoreListener.send') + def test_backend_raises_exception(self, send, logger): + self.playlists.refresh.return_value.get.side_effect = Exception + self.core.playlists.refresh() + self.assertFalse(send.called) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + @mock.patch('mopidy.core.listener.CoreListener.send') + def test_backend_raises_exception_called_with_uri(self, send, logger): + self.playlists.refresh.return_value.get.side_effect = Exception + self.core.playlists.refresh('dummy') + self.assertFalse(send.called) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + +@mock.patch('mopidy.core.playlists.logger') +class SaveBadBackendsTest(MockBackendCorePlaylistsBase): + + def test_backend_raises_exception(self, logger): + playlist = Playlist(uri='dummy:/1') + self.playlists.save.return_value.get.side_effect = Exception + self.assertIsNone(self.core.playlists.save(playlist)) + logger.exception.assert_called_with(mock.ANY, 'DummyBackend') + + def test_backend_returns_none(self, logger): + playlist = Playlist(uri='dummy:/1') + self.playlists.save.return_value.get.return_value = None + self.assertIsNone(self.core.playlists.save(playlist)) + self.assertFalse(logger.error.called) + + def test_backend_returns_wrong_type(self, logger): + playlist = Playlist(uri='dummy:/1') + self.playlists.save.return_value.get.return_value = 'abc' + self.assertIsNone(self.core.playlists.save(playlist)) + logger.error.assert_called_with(mock.ANY, 'DummyBackend', mock.ANY) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 415d1fa0..24edb2e7 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -5,58 +5,72 @@ import unittest import mock from mopidy import backend, core -from mopidy.models import Track +from mopidy.internal import deprecation +from mopidy.models import TlTrack, Track class TracklistTest(unittest.TestCase): - def setUp(self): # noqa: N802 + + def setUp(self): # noqa: + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.tracks = [ Track(uri='dummy1:a', name='foo'), Track(uri='dummy1:b', name='foo'), 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.core = core.Core(config, mixer=None, backends=[self.backend]) + 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.return_value.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): - track1 = Track(uri='dummy1:x', name='x') - track2 = Track(uri='dummy1:y1', name='y1') - track3 = Track(uri='dummy1:y2', name='y2') - self.library.lookup.return_value.get.side_effect = [ - [track1], [track2, track3]] + self.library.lookup.reset_mock() + self.core.tracklist.clear() - tl_tracks = self.core.tracklist.add(uris=['dummy1:x', 'dummy1:y']) + tl_tracks = self.core.tracklist.add(uris=[t.uri for t in self.tracks]) self.library.lookup.assert_has_calls([ - mock.call('dummy1:x'), - mock.call('dummy1:y'), + mock.call('dummy1:a'), + mock.call('dummy1:b'), + mock.call('dummy1:c'), ]) self.assertEqual(3, len(tl_tracks)) - self.assertEqual(track1, tl_tracks[0].track) - self.assertEqual(track2, tl_tracks[1].track) - self.assertEqual(track3, tl_tracks[2].track) + self.assertEqual(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']) + tl_tracks = self.core.tracklist.remove({'name': ['foo']}) self.assertEqual(2, len(tl_tracks)) self.assertListEqual(self.tl_tracks[:2], tl_tracks) @@ -74,7 +88,7 @@ class TracklistTest(unittest.TestCase): self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks) def test_filter_returns_tl_tracks_matching_query(self): - tl_tracks = self.core.tracklist.filter(name=['foo']) + tl_tracks = self.core.tracklist.filter({'name': ['foo']}) self.assertEqual(2, len(tl_tracks)) self.assertListEqual(self.tl_tracks[:2], tl_tracks) @@ -87,10 +101,79 @@ class TracklistTest(unittest.TestCase): def test_filter_fails_if_values_isnt_iterable(self): with self.assertRaises(ValueError): - self.core.tracklist.filter(tlid=3) + self.core.tracklist.filter({'tlid': 3}) def test_filter_fails_if_values_is_a_string(self): with self.assertRaises(ValueError): - self.core.tracklist.filter(uri='a') + self.core.tracklist.filter({'uri': 'a'}) # TODO Extract tracklist tests from the local backend tests + + +class TracklistIndexTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + + self.tracks = [ + Track(uri='dummy1:a', name='foo'), + Track(uri='dummy1:b', name='foo'), + Track(uri='dummy1:c', name='bar'), + ] + + def lookup(uris): + return {u: [t for t in self.tracks if t.uri == u] for u in uris} + + self.core = core.Core(config, mixer=None, backends=[]) + self.core.library = mock.Mock(spec=core.LibraryController) + self.core.library.lookup.side_effect = lookup + + self.core.playback = mock.Mock(spec=core.PlaybackController) + + self.tl_tracks = self.core.tracklist.add(uris=[ + t.uri for t in self.tracks]) + + def test_index_returns_index_of_track(self): + self.assertEqual(0, self.core.tracklist.index(self.tl_tracks[0])) + self.assertEqual(1, self.core.tracklist.index(self.tl_tracks[1])) + self.assertEqual(2, self.core.tracklist.index(self.tl_tracks[2])) + + def test_index_returns_none_if_item_not_found(self): + tl_track = TlTrack(0, Track()) + self.assertEqual(self.core.tracklist.index(tl_track), None) + + def test_index_returns_none_if_called_with_none(self): + self.assertEqual(self.core.tracklist.index(None), None) + + def test_index_errors_out_for_invalid_tltrack(self): + with self.assertRaises(ValueError): + self.core.tracklist.index('abc') + + def test_index_return_index_when_called_with_tlids(self): + tl_tracks = self.tl_tracks + self.assertEqual(0, self.core.tracklist.index(tlid=tl_tracks[0].tlid)) + self.assertEqual(1, self.core.tracklist.index(tlid=tl_tracks[1].tlid)) + self.assertEqual(2, self.core.tracklist.index(tlid=tl_tracks[2].tlid)) + + def test_index_returns_none_if_tlid_not_found(self): + self.assertEqual(self.core.tracklist.index(tlid=123), None) + + def test_index_returns_none_if_called_with_tlid_none(self): + self.assertEqual(self.core.tracklist.index(tlid=None), None) + + def test_index_errors_out_for_invalid_tlid(self): + with self.assertRaises(ValueError): + self.core.tracklist.index(tlid=-1) + + def test_index_without_args_returns_current_tl_track_index(self): + self.core.playback.get_current_tl_track.side_effect = [ + None, self.tl_tracks[0], self.tl_tracks[1], self.tl_tracks[2]] + + self.assertEqual(None, self.core.tracklist.index()) + self.assertEqual(0, self.core.tracklist.index()) + self.assertEqual(1, self.core.tracklist.index()) + self.assertEqual(2, self.core.tracklist.index()) diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py index dcf90ffa..7c48d9f0 100644 --- a/tests/dummy_audio.py +++ b/tests/dummy_audio.py @@ -16,6 +16,7 @@ def create_proxy(config=None, mixer=None): class DummyAudio(pykka.ThreadingActor): + def __init__(self, config=None, mixer=None): super(DummyAudio, self).__init__() self.state = audio.PlaybackState.STOPPED diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 61c26c5f..9ce8e38f 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -17,6 +17,7 @@ def create_proxy(config=None, audio=None): class DummyBackend(pykka.ThreadingActor, backend.Backend): + def __init__(self, config, audio): super(DummyBackend, self).__init__() @@ -57,6 +58,7 @@ class DummyLibraryProvider(backend.LibraryProvider): class DummyPlaybackProvider(backend.PlaybackProvider): + def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) self._uri = None @@ -93,6 +95,7 @@ class DummyPlaybackProvider(backend.PlaybackProvider): class DummyPlaylistsProvider(backend.PlaylistsProvider): + def __init__(self, backend): super(DummyPlaylistsProvider, self).__init__(backend) self._playlists = [] diff --git a/tests/file/__init__.py b/tests/file/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/file/conftest.py b/tests/file/conftest.py new file mode 100644 index 00000000..c4ba96f4 --- /dev/null +++ b/tests/file/conftest.py @@ -0,0 +1,20 @@ +from __future__ import unicode_literals + +import pytest + + +@pytest.fixture +def file_config(): + return { + 'file': { + } + } + + +@pytest.fixture +def file_library(file_config): + # Import library, thus scanner, thus gobject as late as possible to avoid + # hard to track import errors during conftest setup. + from mopidy.file import library + + return library.FileLibraryProvider(backend=None, config=file_config) diff --git a/tests/file/test_browse.py b/tests/file/test_browse.py new file mode 100644 index 00000000..81021966 --- /dev/null +++ b/tests/file/test_browse.py @@ -0,0 +1,3 @@ +from __future__ import unicode_literals + +# TODO Test browse() diff --git a/tests/file/test_lookup.py b/tests/file/test_lookup.py new file mode 100644 index 00000000..19c07181 --- /dev/null +++ b/tests/file/test_lookup.py @@ -0,0 +1,3 @@ +from __future__ import unicode_literals + +# TODO Test lookup() diff --git a/tests/http/test_events.py b/tests/http/test_events.py index 43d9db58..dd1760a3 100644 --- a/tests/http/test_events.py +++ b/tests/http/test_events.py @@ -12,8 +12,6 @@ from mopidy.http import actor class HttpEventsTest(unittest.TestCase): def test_track_playback_paused_is_broadcasted(self, broadcast): - broadcast.reset_mock() - actor.on_event('track_playback_paused', foo='bar') self.assertDictEqual( @@ -23,8 +21,6 @@ class HttpEventsTest(unittest.TestCase): }) def test_track_playback_resumed_is_broadcasted(self, broadcast): - broadcast.reset_mock() - actor.on_event('track_playback_resumed', foo='bar') self.assertDictEqual( diff --git a/tests/http/test_handlers.py b/tests/http/test_handlers.py index 8bd82e11..78071fb2 100644 --- a/tests/http/test_handlers.py +++ b/tests/http/test_handlers.py @@ -13,6 +13,7 @@ from mopidy.http import handlers class StaticFileHandlerTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): return tornado.web.Application([ (r'/(.*)', handlers.StaticFileHandler, { @@ -43,6 +44,7 @@ class StaticFileHandlerTest(tornado.testing.AsyncHTTPTestCase): # 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([ 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/utils/__init__.py b/tests/internal/__init__.py similarity index 100% rename from tests/utils/__init__.py rename to tests/internal/__init__.py diff --git a/tests/utils/network/__init__.py b/tests/internal/network/__init__.py similarity index 100% rename from tests/utils/network/__init__.py rename to tests/internal/network/__init__.py diff --git a/tests/utils/network/test_connection.py b/tests/internal/network/test_connection.py similarity index 99% rename from tests/utils/network/test_connection.py rename to tests/internal/network/test_connection.py index 0ccaea0a..8ae7d15c 100644 --- a/tests/utils/network/test_connection.py +++ b/tests/internal/network/test_connection.py @@ -11,12 +11,13 @@ from mock import Mock, call, patch, sentinel import pykka -from mopidy.utils import network +from mopidy.internal import network 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/internal/network/test_lineprotocol.py similarity index 99% rename from tests/utils/network/test_lineprotocol.py rename to tests/internal/network/test_lineprotocol.py index 1b584e47..586d180e 100644 --- a/tests/utils/network/test_lineprotocol.py +++ b/tests/internal/network/test_lineprotocol.py @@ -8,12 +8,13 @@ import unittest from mock import Mock, sentinel from mopidy import compat -from mopidy.utils import network +from mopidy.internal import network 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/internal/network/test_server.py similarity index 99% rename from tests/utils/network/test_server.py rename to tests/internal/network/test_server.py index d85d6c27..af8effd2 100644 --- a/tests/utils/network/test_server.py +++ b/tests/internal/network/test_server.py @@ -8,12 +8,13 @@ import gobject from mock import Mock, patch, sentinel -from mopidy.utils import network +from mopidy.internal import network 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/internal/network/test_utils.py similarity index 87% rename from tests/utils/network/test_utils.py rename to tests/internal/network/test_utils.py index d5f558b4..a769ff93 100644 --- a/tests/utils/network/test_utils.py +++ b/tests/internal/network/test_utils.py @@ -5,23 +5,25 @@ import unittest from mock import Mock, patch -from mopidy.utils import network +from mopidy.internal import network class FormatHostnameTest(unittest.TestCase): - @patch('mopidy.utils.network.has_ipv6', True) + + @patch('mopidy.internal.network.has_ipv6', True) def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): network.has_ipv6 = True self.assertEqual(network.format_hostname('0.0.0.0'), '::ffff:0.0.0.0') self.assertEqual(network.format_hostname('1.0.0.1'), '::ffff:1.0.0.1') - @patch('mopidy.utils.network.has_ipv6', False) + @patch('mopidy.internal.network.has_ipv6', False) def test_format_hostname_does_nothing_when_only_ipv4_available(self): network.has_ipv6 = False self.assertEqual(network.format_hostname('0.0.0.0'), '0.0.0.0') 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,14 +42,15 @@ class TryIPv6SocketTest(unittest.TestCase): class CreateSocketTest(unittest.TestCase): - @patch('mopidy.utils.network.has_ipv6', False) + + @patch('mopidy.internal.network.has_ipv6', False) @patch('socket.socket') def test_ipv4_socket(self, socket_mock): network.create_socket() self.assertEqual( socket_mock.call_args[0], (socket.AF_INET, socket.SOCK_STREAM)) - @patch('mopidy.utils.network.has_ipv6', True) + @patch('mopidy.internal.network.has_ipv6', True) @patch('socket.socket') def test_ipv6_socket(self, socket_mock): network.create_socket() diff --git a/tests/utils/test_deps.py b/tests/internal/test_deps.py similarity index 99% rename from tests/utils/test_deps.py rename to tests/internal/test_deps.py index 3b06973f..27e6f629 100644 --- a/tests/utils/test_deps.py +++ b/tests/internal/test_deps.py @@ -12,10 +12,11 @@ import pygst pygst.require('0.10') import gst # noqa -from mopidy.utils import deps +from mopidy.internal import deps class DepsTest(unittest.TestCase): + def test_format_dependency_list(self): adapters = [ lambda: dict(name='Python', version='FooPython 2.7.3'), diff --git a/tests/utils/test_encoding.py b/tests/internal/test_encoding.py similarity index 80% rename from tests/utils/test_encoding.py rename to tests/internal/test_encoding.py index 68634855..cc8987ce 100644 --- a/tests/utils/test_encoding.py +++ b/tests/internal/test_encoding.py @@ -4,15 +4,16 @@ import unittest import mock -from mopidy.utils.encoding import locale_decode +from mopidy.internal import encoding -@mock.patch('mopidy.utils.encoding.locale.getpreferredencoding') +@mock.patch('mopidy.internal.encoding.locale.getpreferredencoding') class LocaleDecodeTest(unittest.TestCase): + def test_can_decode_utf8_strings_with_french_content(self, mock): mock.return_value = 'UTF-8' - result = locale_decode( + result = encoding.locale_decode( b'[Errno 98] Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e') self.assertEqual('[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e', result) @@ -21,7 +22,7 @@ class LocaleDecodeTest(unittest.TestCase): mock.return_value = 'UTF-8' error = IOError(98, b'Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e') - result = locale_decode(error) + result = encoding.locale_decode(error) expected = '[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e' self.assertEqual( @@ -32,13 +33,13 @@ class LocaleDecodeTest(unittest.TestCase): def test_does_not_use_locale_to_decode_unicode_strings(self, mock): mock.return_value = 'UTF-8' - locale_decode('abc') + encoding.locale_decode('abc') self.assertFalse(mock.called) def test_does_not_use_locale_to_decode_ascii_bytestrings(self, mock): mock.return_value = 'UTF-8' - locale_decode('abc') + encoding.locale_decode('abc') self.assertFalse(mock.called) diff --git a/tests/utils/test_jsonrpc.py b/tests/internal/test_jsonrpc.py similarity index 99% rename from tests/utils/test_jsonrpc.py rename to tests/internal/test_jsonrpc.py index fb59d06b..b2103caa 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/internal/test_jsonrpc.py @@ -8,12 +8,13 @@ import mock import pykka from mopidy import core, models -from mopidy.utils import jsonrpc +from mopidy.internal import deprecation, jsonrpc from tests import dummy_backend class Calculator(object): + def __init__(self): self._mem = None @@ -50,11 +51,14 @@ class Calculator(object): class JsonRpcTestBase(unittest.TestCase): + def setUp(self): # noqa: N802 self.backend = dummy_backend.create_proxy() - self.core = core.Core.start(backends=[self.backend]).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!', @@ -72,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'} @@ -143,6 +149,7 @@ class JsonRpcSerializationTest(JsonRpcTestBase): class JsonRpcSingleCommandTest(JsonRpcTestBase): + def test_call_method_on_root(self): request = { 'jsonrpc': '2.0', @@ -247,6 +254,7 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): class JsonRpcSingleNotificationTest(JsonRpcTestBase): + def test_notification_does_not_return_a_result(self): request = { 'jsonrpc': '2.0', @@ -281,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() @@ -329,6 +338,7 @@ class JsonRpcBatchTest(JsonRpcTestBase): class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): + def test_application_error_response(self): request = { 'jsonrpc': '2.0', @@ -498,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) @@ -564,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}) diff --git a/tests/utils/test_path.py b/tests/internal/test_path.py similarity index 97% rename from tests/utils/test_path.py rename to tests/internal/test_path.py index 6fd4f8d1..0d266725 100644 --- a/tests/utils/test_path.py +++ b/tests/internal/test_path.py @@ -10,12 +10,13 @@ import unittest import glib from mopidy import compat, exceptions -from mopidy.utils import path +from mopidy.internal import path 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('')) @@ -375,9 +380,22 @@ class FindMTimesTest(unittest.TestCase): self.assertEqual(expected, result) self.assertEqual({}, errors) + def test_gives_mtime_in_milliseconds(self): + fname = self.touch('foobar') + + os.utime(fname, (1, 3.14159265)) + + result, errors = path.find_mtimes(fname) + + self.assertEqual(len(result), 1) + mtime, = result.values() + self.assertEqual(mtime, 3141) + self.assertEqual(errors, {}) + # 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/tests/audio/test_playlists.py b/tests/internal/test_playlists.py similarity index 66% rename from tests/audio/test_playlists.py rename to tests/internal/test_playlists.py index f01568f8..21537813 100644 --- a/tests/audio/test_playlists.py +++ b/tests/internal/test_playlists.py @@ -2,28 +2,39 @@ from __future__ import absolute_import, unicode_literals -import io import unittest -from mopidy.audio import playlists +import pytest + +from mopidy.internal import playlists BAD = b'foobarbaz' -M3U = b"""#EXTM3U +EXTM3U = b"""#EXTM3U #EXTINF:123, Sample artist - Sample title file:///tmp/foo #EXTINF:321,Example Artist - Example \xc5\xa7\xc5\x95 file:///tmp/bar + #EXTINF:213,Some Artist - Other title file:///tmp/baz """ +URILIST = b""" +file:///tmp/foo +# a comment +file:///tmp/bar + +file:///tmp/baz +""" + PLS = b"""[Playlist] NumberOfEntries=3 File1=file:///tmp/foo Title1=Sample Title Length1=123 + File2=file:///tmp/bar Title2=Example \xc5\xa7\xc5\x95 Length2=321 @@ -76,13 +87,20 @@ XSPF = b""" """ +EXPECTED = [b'file:///tmp/foo', b'file:///tmp/bar', b'file:///tmp/baz'] -class TypeFind(object): - def __init__(self, data): - self.data = data - def peek(self, start, end): - return self.data[start:end] +@pytest.mark.parametrize('data,result', [ + (BAD, []), + (URILIST, EXPECTED), + (EXTM3U, EXPECTED), + (PLS, EXPECTED), + (ASX, EXPECTED), + (SIMPLE_ASX, EXPECTED), + (XSPF, EXPECTED), +]) +def test_parse(data, result): + assert playlists.parse(data) == result class BasePlaylistTest(object): @@ -92,26 +110,25 @@ class BasePlaylistTest(object): parse = None def test_detect_valid_header(self): - self.assertTrue(self.detect(TypeFind(self.valid))) + self.assertTrue(self.detect(self.valid)) def test_detect_invalid_header(self): - self.assertFalse(self.detect(TypeFind(self.invalid))) + self.assertFalse(self.detect(self.invalid)) def test_parse_valid_playlist(self): - uris = list(self.parse(io.BytesIO(self.valid))) - expected = [b'file:///tmp/foo', b'file:///tmp/bar', b'file:///tmp/baz'] - self.assertEqual(uris, expected) + uris = list(self.parse(self.valid)) + self.assertEqual(uris, EXPECTED) def test_parse_invalid_playlist(self): - uris = list(self.parse(io.BytesIO(self.invalid))) + uris = list(self.parse(self.invalid)) self.assertEqual(uris, []) -class M3uPlaylistTest(BasePlaylistTest, unittest.TestCase): - valid = M3U +class ExtM3uPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = EXTM3U invalid = BAD - detect = staticmethod(playlists.detect_m3u_header) - parse = staticmethod(playlists.parse_m3u) + detect = staticmethod(playlists.detect_extm3u_header) + parse = staticmethod(playlists.parse_extm3u) class PlsPlaylistTest(BasePlaylistTest, unittest.TestCase): @@ -140,3 +157,17 @@ class XspfPlaylistTest(BasePlaylistTest, unittest.TestCase): invalid = BAD detect = staticmethod(playlists.detect_xspf_header) parse = staticmethod(playlists.parse_xspf) + + +class UriListPlaylistTest(unittest.TestCase): + valid = URILIST + invalid = BAD + parse = staticmethod(playlists.parse_urilist) + + def test_parse_valid_playlist(self): + uris = list(self.parse(self.valid)) + self.assertEqual(uris, EXPECTED) + + def test_parse_invalid_playlist(self): + uris = list(self.parse(self.invalid)) + self.assertEqual(uris, []) diff --git a/tests/internal/test_validation.py b/tests/internal/test_validation.py new file mode 100644 index 00000000..a46a3b59 --- /dev/null +++ b/tests/internal/test_validation.py @@ -0,0 +1,167 @@ +from __future__ import absolute_import, unicode_literals + +from pytest import raises + +from mopidy import compat, exceptions +from mopidy.internal import validation + + +def test_check_boolean_with_valid_values(): + for value in (True, False): + validation.check_boolean(value) + + +def test_check_boolean_with_other_values(): + for value in 1, 0, None, '', list(), tuple(): + with raises(exceptions.ValidationError): + validation.check_boolean(value) + + +def test_check_boolean_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_boolean(1234) + assert 'Expected a boolean, not 1234' == str(excinfo.value) + + +def test_check_choice_with_valid_values(): + for value, choices in (2, (1, 2, 3)), ('abc', ('abc', 'def')): + validation.check_choice(value, choices) + + +def test_check_choice_with_invalid_values(): + for value, choices in (5, (1, 2, 3)), ('xyz', ('abc', 'def')): + with raises(exceptions.ValidationError): + validation.check_choice(value, choices) + + +def test_check_choice_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_choice(5, (1, 2, 3)) + assert 'Expected one of (1, 2, 3), not 5' == str(excinfo.value) + + +def test_check_instance_with_valid_choices(): + for value, cls in ((True, bool), ('a', compat.text_type), (123, int)): + validation.check_instance(value, cls) + + +def test_check_instance_with_invalid_values(): + for value, cls in (1, str), ('abc', int): + with raises(exceptions.ValidationError): + validation.check_instance(value, cls) + + +def test_check_instance_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_instance(1, dict) + assert 'Expected a dict instance, not 1' == str(excinfo.value) + + +def test_check_instances_with_valid_values(): + validation.check_instances([], int) + validation.check_instances([1, 2], int) + validation.check_instances((1, 2), int) + + +def test_check_instances_with_invalid_values(): + with raises(exceptions.ValidationError): + validation.check_instances('abc', compat.string_types) + with raises(exceptions.ValidationError): + validation.check_instances(['abc', 123], compat.string_types) + with raises(exceptions.ValidationError): + validation.check_instances(None, compat.string_types) + with raises(exceptions.ValidationError): + validation.check_instances([None], compat.string_types) + with raises(exceptions.ValidationError): + validation.check_instances(iter(['abc']), compat.string_types) + + +def test_check_instances_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_instances([1], compat.string_types) + assert 'Expected a list of basestring, not [1]' == str(excinfo.value) + + +def test_check_query_valid_values(): + for value in {}, {'any': []}, {'any': ['abc']}: + validation.check_query(value) + + +def test_check_query_random_iterables(): + for value in None, tuple(), list(), 'abc': + with raises(exceptions.ValidationError): + validation.check_query(value) + + +def test_check_mapping_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_query([]) + assert 'Expected a query dictionary, not []' == str(excinfo.value) + + +def test_check_query_invalid_fields(): + for value in 'wrong', 'bar', 'foo', 'tlid': + with raises(exceptions.ValidationError): + validation.check_query({value: []}) + + +def test_check_field_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_query({'wrong': ['abc']}) + assert 'Expected query field to be one of ' in str(excinfo.value) + + +def test_check_query_invalid_values(): + for value in '', None, 'foo', 123, [''], [None], iter(['abc']): + with raises(exceptions.ValidationError): + validation.check_query({'any': value}) + + +def test_check_values_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_query({'any': 'abc'}) + assert 'Expected "any" to be list of strings, not' in str(excinfo.value) + + +def test_check_uri_with_valid_values(): + for value in 'foobar:', 'http://example.com', 'git+http://example.com': + validation.check_uri(value) + + +def test_check_uri_with_invalid_values(): + # Note that tuple catches a potential bug with using "'foo' % arg" for + # formatting. + for value in ('foobar', 'htt p://example.com', None, 1234, tuple()): + with raises(exceptions.ValidationError): + validation.check_uri(value) + + +def test_check_uri_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_uri('testing') + assert "Expected a valid URI, not u'testing'" == str(excinfo.value) + + +def test_check_uris_with_valid_values(): + validation.check_uris([]) + validation.check_uris(['foobar:']) + validation.check_uris(('foobar:',)) + + +def test_check_uris_with_invalid_values(): + with raises(exceptions.ValidationError): + validation.check_uris('foobar:') + with raises(exceptions.ValidationError): + validation.check_uris(None) + with raises(exceptions.ValidationError): + validation.check_uris([None]) + with raises(exceptions.ValidationError): + validation.check_uris(['foobar:', 'foobar']) + with raises(exceptions.ValidationError): + validation.check_uris(iter(['http://example.com'])) + + +def test_check_uris_error_message(): + with raises(exceptions.ValidationError) as excinfo: + validation.check_uris('testing') + assert "Expected a list of URIs, not u'testing'" == str(excinfo.value) diff --git a/tests/internal/test_xdg.py b/tests/internal/test_xdg.py new file mode 100644 index 00000000..521447f7 --- /dev/null +++ b/tests/internal/test_xdg.py @@ -0,0 +1,69 @@ +from __future__ import unicode_literals + +import os + +import mock + +import pytest + +from mopidy.internal import xdg + + +@pytest.yield_fixture +def environ(): + patcher = mock.patch.dict(os.environ, clear=True) + yield patcher.start() + patcher.stop() + + +def test_cache_dir_default(environ): + assert xdg.get_dirs()['XDG_CACHE_DIR'] == os.path.expanduser(b'~/.cache') + + +def test_cache_dir_from_env(environ): + os.environ['XDG_CACHE_HOME'] = '/foo/bar' + + assert xdg.get_dirs()['XDG_CACHE_DIR'] == '/foo/bar' + + +def test_config_dir_default(environ): + assert xdg.get_dirs()['XDG_CONFIG_DIR'] == os.path.expanduser(b'~/.config') + + +def test_config_dir_from_env(environ): + os.environ['XDG_CONFIG_HOME'] = '/foo/bar' + + assert xdg.get_dirs()['XDG_CONFIG_DIR'] == '/foo/bar' + + +def test_data_dir_default(environ): + assert xdg.get_dirs()['XDG_DATA_DIR'] == os.path.expanduser( + b'~/.local/share') + + +def test_data_dir_from_env(environ): + os.environ['XDG_DATA_HOME'] = '/foo/bar' + + assert xdg.get_dirs()['XDG_DATA_DIR'] == '/foo/bar' + + +def test_user_dirs(environ, tmpdir): + os.environ['XDG_CONFIG_HOME'] = str(tmpdir) + + with open(os.path.join(str(tmpdir), 'user-dirs.dirs'), 'wb') as fh: + fh.write('# Some comments\n') + fh.write('XDG_MUSIC_DIR="$HOME/Music2"\n') + + result = xdg.get_dirs() + + assert result['XDG_MUSIC_DIR'] == os.path.expanduser(b'~/Music2') + assert 'XDG_DOWNLOAD_DIR' not in result + + +def test_user_dirs_when_no_dirs_file(environ, tmpdir): + os.environ['XDG_CONFIG_HOME'] = str(tmpdir) + + result = xdg.get_dirs() + + assert 'XDG_MUSIC_DIR' not in result + assert 'XDG_DOWNLOAD_DIR' not in result diff --git a/tests/local/__init__.py b/tests/local/__init__.py index b1520768..7f3cfb33 100644 --- a/tests/local/__init__.py +++ b/tests/local/__init__.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, unicode_literals +from mopidy.internal 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_library.py b/tests/local/test_library.py index 39f0e53e..7763057f 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -9,7 +9,7 @@ import mock import pykka -from mopidy import core +from mopidy import core, exceptions from mopidy.local import actor, json from mopidy.models import Album, Artist, Image, Track @@ -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,437 +158,439 @@ 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]) + # TODO: This is really just a test of the query validation code now, + # as this code path never even makes it to the local backend. def test_find_exact_wrong_type(self): - with self.assertRaises(LookupError): - self.library.find_exact(wrong=['test']) + with self.assertRaises(exceptions.ValidationError): + self.find_exact(wrong=['test']) def test_find_exact_with_empty_query(self): - with self.assertRaises(LookupError): - self.library.find_exact(artist=['']) + with self.assertRaises(exceptions.ValidationError): + self.find_exact(artist=['']) - with self.assertRaises(LookupError): - self.library.find_exact(albumartist=['']) + with self.assertRaises(exceptions.ValidationError): + self.find_exact(albumartist=['']) - with self.assertRaises(LookupError): - self.library.find_exact(track_name=['']) + with self.assertRaises(exceptions.ValidationError): + self.find_exact(track_name=['']) - with self.assertRaises(LookupError): - self.library.find_exact(composer=['']) + with self.assertRaises(exceptions.ValidationError): + self.find_exact(composer=['']) - with self.assertRaises(LookupError): - self.library.find_exact(performer=['']) + with self.assertRaises(exceptions.ValidationError): + self.find_exact(performer=['']) - with self.assertRaises(LookupError): - self.library.find_exact(album=['']) + with self.assertRaises(exceptions.ValidationError): + self.find_exact(album=['']) - with self.assertRaises(LookupError): - self.library.find_exact(track_no=['']) + with self.assertRaises(exceptions.ValidationError): + self.find_exact(track_no=['']) - with self.assertRaises(LookupError): - self.library.find_exact(genre=['']) + with self.assertRaises(exceptions.ValidationError): + self.find_exact(genre=['']) - with self.assertRaises(LookupError): - self.library.find_exact(date=['']) + with self.assertRaises(exceptions.ValidationError): + self.find_exact(date=['']) - with self.assertRaises(LookupError): - self.library.find_exact(comment=['']) + with self.assertRaises(exceptions.ValidationError): + self.find_exact(comment=['']) - with self.assertRaises(LookupError): - self.library.find_exact(any=['']) + with self.assertRaises(exceptions.ValidationError): + 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']) + with self.assertRaises(exceptions.ValidationError): + self.search(wrong=['test']) def test_search_with_empty_query(self): - with self.assertRaises(LookupError): - self.library.search(artist=['']) + with self.assertRaises(exceptions.ValidationError): + self.search(artist=['']) - with self.assertRaises(LookupError): - self.library.search(albumartist=['']) + with self.assertRaises(exceptions.ValidationError): + self.search(albumartist=['']) - with self.assertRaises(LookupError): - self.library.search(composer=['']) + with self.assertRaises(exceptions.ValidationError): + self.search(composer=['']) - with self.assertRaises(LookupError): - self.library.search(performer=['']) + with self.assertRaises(exceptions.ValidationError): + self.search(performer=['']) - with self.assertRaises(LookupError): - self.library.search(track_name=['']) + with self.assertRaises(exceptions.ValidationError): + self.search(track_name=['']) - with self.assertRaises(LookupError): - self.library.search(album=['']) + with self.assertRaises(exceptions.ValidationError): + self.search(album=['']) - with self.assertRaises(LookupError): - self.library.search(genre=['']) + with self.assertRaises(exceptions.ValidationError): + self.search(genre=['']) - with self.assertRaises(LookupError): - self.library.search(date=['']) + with self.assertRaises(exceptions.ValidationError): + self.search(date=['']) - with self.assertRaises(LookupError): - self.library.search(comment=['']) + with self.assertRaises(exceptions.ValidationError): + self.search(comment=['']) - with self.assertRaises(LookupError): - self.library.search(uri=['']) + with self.assertRaises(exceptions.ValidationError): + self.search(uri=['']) - with self.assertRaises(LookupError): - self.library.search(any=['']) + with self.assertRaises(exceptions.ValidationError): + self.search(any=['']) def test_default_get_images_impl_no_images(self): result = self.library.get_images([track.uri for track in self.tracks]) diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 6ea82f2d..bab70847 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -9,8 +9,9 @@ import pykka from mopidy import core from mopidy.core import PlaybackState +from mopidy.internal import deprecation from mopidy.local import actor -from mopidy.models import Track +from mopidy.models import TlTrack, Track from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist @@ -21,6 +22,9 @@ from tests.local import generate_song, populate_tracklist class LocalPlaybackProviderTest(unittest.TestCase): config = { + 'core': { + 'max_tracklist_length': 10000, + }, 'local': { 'media_dir': path_to_data_dir(''), 'data_dir': path_to_data_dir(''), @@ -42,11 +46,15 @@ class LocalPlaybackProviderTest(unittest.TestCase): def trigger_end_of_track(self): self.playback._on_end_of_track() + 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 = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() - self.core = core.Core(backends=[self.backend]) + self.core = core.Core(self.config, backends=[self.backend]) self.playback = self.core.playback self.tracklist = self.core.tracklist @@ -836,22 +844,6 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.playback.seek(self.tracklist.tracks[-1].length * 100) self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @unittest.SkipTest - @populate_tracklist - def test_seek_beyond_start_of_song(self): - # FIXME need to decide return value - self.playback.play() - result = self.playback.seek(-1000) - self.assert_(not result, 'Seek return value was %s' % result) - - @populate_tracklist - def test_seek_beyond_start_of_song_update_postion(self): - self.playback.play() - self.playback.seek(-1000) - position = self.playback.time_position - self.assertGreaterEqual(position, 0) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - @populate_tracklist def test_stop_when_stopped(self): self.playback.stop() @@ -1083,4 +1075,4 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_playing_track_that_isnt_in_playlist(self): with self.assertRaises(AssertionError): - self.playback.play((17, Track())) + self.playback.play(TlTrack(17, Track())) 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 db5de58b..72da3f13 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -7,8 +7,9 @@ import pykka from mopidy import core from mopidy.core import PlaybackState +from mopidy.internal import deprecation from mopidy.local import actor -from mopidy.models import Playlist, TlTrack, Track +from mopidy.models import Playlist, Track from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist @@ -16,6 +17,9 @@ from tests.local import generate_song, populate_tracklist class LocalTracklistProviderTest(unittest.TestCase): config = { + 'core': { + 'max_tracklist_length': 10000 + }, 'local': { 'media_dir': path_to_data_dir(''), 'data_dir': path_to_data_dir(''), @@ -26,11 +30,15 @@ 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 = 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]) + self.core = core.Core(self.config, mixer=None, backends=[self.backend]) self.controller = self.core.tracklist self.playback = self.core.playback @@ -72,53 +80,54 @@ class LocalTracklistProviderTest(unittest.TestCase): def test_filter_by_tlid(self): tl_track = self.controller.tl_tracks[1] self.assertEqual( - [tl_track], self.controller.filter(tlid=[tl_track.tlid])) + [tl_track], self.controller.filter({'tlid': [tl_track.tlid]})) @populate_tracklist def test_filter_by_uri(self): tl_track = self.controller.tl_tracks[1] self.assertEqual( - [tl_track], self.controller.filter(uri=[tl_track.track.uri])) + [tl_track], self.controller.filter({'uri': [tl_track.track.uri]})) @populate_tracklist def test_filter_by_uri_returns_nothing_for_invalid_uri(self): - self.assertEqual([], self.controller.filter(uri=['foobar'])) + self.assertEqual([], self.controller.filter({'uri': ['foobar']})) def test_filter_by_uri_returns_single_match(self): - track = Track(uri='a') - self.controller.add([Track(uri='z'), track, Track(uri='y')]) - self.assertEqual(track, self.controller.filter(uri=['a'])[0].track) + t = Track(uri='a') + self.controller.add([Track(uri='z'), t, Track(uri='y')]) + self.assertEqual(t, self.controller.filter({'uri': ['a']})[0].track) def test_filter_by_uri_returns_multiple_matches(self): track = Track(uri='a') self.controller.add([Track(uri='z'), track, track]) - tl_tracks = self.controller.filter(uri=['a']) + tl_tracks = self.controller.filter({'uri': ['a']}) self.assertEqual(track, tl_tracks[0].track) self.assertEqual(track, tl_tracks[1].track) def test_filter_by_uri_returns_nothing_if_no_match(self): self.controller.playlist = Playlist( - tracks=[Track(uri=['z']), Track(uri=['y'])]) - self.assertEqual([], self.controller.filter(uri=['a'])) + tracks=[Track(uri='z'), Track(uri='y')]) + self.assertEqual([], self.controller.filter({'uri': ['a']})) def test_filter_by_multiple_criteria_returns_elements_matching_all(self): - track1 = Track(uri='a', name='x') - track2 = Track(uri='b', name='x') - track3 = Track(uri='b', name='y') - self.controller.add([track1, track2, track3]) + t1 = Track(uri='a', name='x') + t2 = Track(uri='b', name='x') + t3 = Track(uri='b', name='y') + self.controller.add([t1, t2, t3]) self.assertEqual( - track1, self.controller.filter(uri=['a'], name=['x'])[0].track) + t1, self.controller.filter({'uri': ['a'], 'name': ['x']})[0].track) self.assertEqual( - track2, self.controller.filter(uri=['b'], name=['x'])[0].track) + t2, self.controller.filter({'uri': ['b'], 'name': ['x']})[0].track) self.assertEqual( - track3, self.controller.filter(uri=['b'], name=['y'])[0].track) + t3, self.controller.filter({'uri': ['b'], 'name': ['y']})[0].track) def test_filter_by_criteria_that_is_not_present_in_all_elements(self): track1 = Track() track2 = Track(uri='b') track3 = Track() self.controller.add([track1, track2, track3]) - self.assertEqual(track2, self.controller.filter(uri=['b'])[0].track) + self.assertEqual( + track2, self.controller.filter({'uri': ['b']})[0].track) @populate_tracklist def test_clear(self): @@ -170,16 +179,6 @@ class LocalTracklistProviderTest(unittest.TestCase): tl_tracks = self.controller.add(self.controller.tracks[1:2]) self.assertEqual(tl_tracks[0].track, self.controller.tracks[1]) - def test_index_returns_index_of_track(self): - tl_tracks = self.controller.add(self.tracks) - self.assertEqual(0, self.controller.index(tl_tracks[0])) - self.assertEqual(1, self.controller.index(tl_tracks[1])) - self.assertEqual(2, self.controller.index(tl_tracks[2])) - - def test_index_returns_none_if_item_not_found(self): - tl_track = TlTrack(0, Track()) - self.assertEqual(self.controller.index(tl_track), None) - @populate_tracklist def test_move_single(self): self.controller.move(0, 0, 2) @@ -228,17 +227,17 @@ class LocalTracklistProviderTest(unittest.TestCase): track1 = self.controller.tracks[1] track2 = self.controller.tracks[2] version = self.controller.version - self.controller.remove(uri=[track1.uri]) + self.controller.remove({'uri': [track1.uri]}) self.assertLess(version, self.controller.version) self.assertNotIn(track1, self.controller.tracks) self.assertEqual(track2, self.controller.tracks[1]) @populate_tracklist def test_removing_track_that_does_not_exist_does_nothing(self): - self.controller.remove(uri=['/nonexistant']) + self.controller.remove({'uri': ['/nonexistant']}) def test_removing_from_empty_playlist_does_nothing(self): - self.controller.remove(uri=['/nonexistant']) + self.controller.remove({'uri': ['/nonexistant']}) @populate_tracklist def test_remove_lists(self): @@ -246,7 +245,7 @@ class LocalTracklistProviderTest(unittest.TestCase): track1 = self.controller.tracks[1] track2 = self.controller.tracks[2] version = self.controller.version - self.controller.remove(uri=[track0.uri, track2.uri]) + self.controller.remove({'uri': [track0.uri, track2.uri]}) self.assertLess(version, self.controller.version) self.assertNotIn(track0, self.controller.tracks) self.assertNotIn(track2, self.controller.tracks) diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py new file mode 100644 index 00000000..124766dd --- /dev/null +++ b/tests/local/test_translator.py @@ -0,0 +1,98 @@ +# encoding: utf-8 + +from __future__ import unicode_literals + +import pytest + +from mopidy.local import translator + + +@pytest.mark.parametrize('local_uri,file_uri', [ + ('local:directory:A/B', 'file:///home/alice/Music/A/B'), + ('local:directory:A%20B', 'file:///home/alice/Music/A%20B'), + ('local:directory:A+B', 'file:///home/alice/Music/A%2BB'), + ( + 'local:directory:%C3%A6%C3%B8%C3%A5', + 'file:///home/alice/Music/%C3%A6%C3%B8%C3%A5'), + ('local:track:A/B.mp3', 'file:///home/alice/Music/A/B.mp3'), + ('local:track:A%20B.mp3', 'file:///home/alice/Music/A%20B.mp3'), + ('local:track:A+B.mp3', 'file:///home/alice/Music/A%2BB.mp3'), + ( + 'local:track:%C3%A6%C3%B8%C3%A5.mp3', + 'file:///home/alice/Music/%C3%A6%C3%B8%C3%A5.mp3'), +]) +def test_local_uri_to_file_uri(local_uri, file_uri): + media_dir = b'/home/alice/Music' + + assert translator.local_uri_to_file_uri(local_uri, media_dir) == file_uri + + +@pytest.mark.parametrize('uri', [ + 'A/B', + 'local:foo:A/B', +]) +def test_local_uri_to_file_uri_errors(uri): + media_dir = b'/home/alice/Music' + + with pytest.raises(ValueError): + translator.local_uri_to_file_uri(uri, media_dir) + + +@pytest.mark.parametrize('uri,path', [ + ('local:directory:A/B', b'/home/alice/Music/A/B'), + ('local:directory:A%20B', b'/home/alice/Music/A B'), + ('local:directory:A+B', b'/home/alice/Music/A+B'), + ('local:directory:%C3%A6%C3%B8%C3%A5', b'/home/alice/Music/æøå'), + ('local:track:A/B.mp3', b'/home/alice/Music/A/B.mp3'), + ('local:track:A%20B.mp3', b'/home/alice/Music/A B.mp3'), + ('local:track:A+B.mp3', b'/home/alice/Music/A+B.mp3'), + ('local:track:%C3%A6%C3%B8%C3%A5.mp3', b'/home/alice/Music/æøå.mp3'), +]) +def test_local_uri_to_path(uri, path): + media_dir = b'/home/alice/Music' + + assert translator.local_uri_to_path(uri, media_dir) == path + + # Legacy version to keep old versions of Mopidy-Local-Sqlite working + assert translator.local_track_uri_to_path(uri, media_dir) == path + + +@pytest.mark.parametrize('uri', [ + 'A/B', + 'local:foo:A/B', +]) +def test_local_uri_to_path_errors(uri): + media_dir = b'/home/alice/Music' + + with pytest.raises(ValueError): + translator.local_uri_to_path(uri, media_dir) + + +@pytest.mark.parametrize('path,uri', [ + ('/foo', 'file:///foo'), + (b'/foo', 'file:///foo'), + ('/æøå', 'file:///%C3%A6%C3%B8%C3%A5'), + (b'/\x00\x01\x02', 'file:///%00%01%02'), +]) +def test_path_to_file_uri(path, uri): + assert translator.path_to_file_uri(path) == uri + + +@pytest.mark.parametrize('path,uri', [ + ('foo', 'local:track:foo'), + (b'foo', 'local:track:foo'), + ('æøå', 'local:track:%C3%A6%C3%B8%C3%A5'), + (b'\x00\x01\x02', 'local:track:%00%01%02'), +]) +def test_path_to_local_track_uri(path, uri): + assert translator.path_to_local_track_uri(path) == uri + + +@pytest.mark.parametrize('path,uri', [ + ('foo', 'local:directory:foo'), + (b'foo', 'local:directory:foo'), + ('æøå', 'local:directory:%C3%A6%C3%B8%C3%A5'), + (b'\x00\x01\x02', 'local:directory:%00%01%02'), +]) +def test_path_to_local_directory_uri(path, uri): + assert translator.path_to_local_directory_uri(path) == uri diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 31d8f100..edebe65b 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals import os +import platform import shutil import tempfile import unittest @@ -11,6 +12,7 @@ import urllib import pykka from mopidy import core +from mopidy.internal import deprecation from mopidy.m3u import actor from mopidy.m3u.translator import playlist_uri_to_path from mopidy.models import Playlist, Track @@ -72,7 +74,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertTrue(os.path.exists(path1)) self.assertFalse(os.path.exists(path2)) - playlist = self.core.playlists.save(playlist.copy(name='test2')) + playlist = self.core.playlists.save(playlist.replace(name='test2')) self.assertEqual('test2', playlist.name) self.assertEqual(uri2, playlist.uri) self.assertFalse(os.path.exists(path1)) @@ -95,7 +97,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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])) + playlist = self.core.playlists.save(playlist.replace(tracks=[track])) path = playlist_uri_to_path(playlist.uri, self.playlists_dir) with open(path) as f: @@ -106,7 +108,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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])) + playlist = self.core.playlists.save(playlist.replace(tracks=[track])) path = playlist_uri_to_path(playlist.uri, self.playlists_dir) with open(path) as f: @@ -137,7 +139,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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 = playlist.replace(tracks=[track]) playlist = self.core.playlists.save(playlist) self.assertEqual(len(self.core.playlists.as_list()), 1) @@ -155,8 +157,11 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.core.playlists.refresh() self.assertEqual(len(self.core.playlists.as_list()), 1) - result = self.core.playlists.lookup(uri) - self.assertEqual('\ufffd\ufffd\ufffd', result.name) + result = self.core.playlists.as_list() + if platform.system() == 'Darwin': + self.assertEqual('%F8%E6%E5', result[0].name) + else: + self.assertEqual('\ufffd\ufffd\ufffd', result[0].name) @unittest.SkipTest def test_playlists_dir_is_created(self): @@ -172,8 +177,8 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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) + 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) @@ -202,30 +207,6 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.core.playlists.delete(playlist.uri) self.assertIsNone(self.core.playlists.lookup(playlist.uri)) - def test_filter_without_criteria(self): - self.assertEqual( - self.core.playlists.get_playlists(), self.core.playlists.filter()) - - def test_filter_with_wrong_criteria(self): - self.assertEqual([], self.core.playlists.filter(name='foo')) - - def test_filter_with_right_criteria(self): - playlist = self.core.playlists.create('test') - playlists = self.core.playlists.filter(name='test') - self.assertEqual([playlist], playlists) - - def test_filter_by_name_returns_single_match(self): - self.core.playlists.create('a') - playlist = self.core.playlists.create('b') - - self.assertEqual([playlist], self.core.playlists.filter(name='b')) - - def test_filter_by_name_returns_no_matches(self): - self.core.playlists.create('a') - self.core.playlists.create('b') - - self.assertEqual([], self.core.playlists.filter(name='c')) - def test_lookup_finds_playlist_by_uri(self): original_playlist = self.core.playlists.create('test') @@ -245,7 +226,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): playlist1 = self.core.playlists.create('test1') self.assertEqual(playlist1, self.core.playlists.lookup(playlist1.uri)) - playlist2 = playlist1.copy(name='test2') + playlist2 = playlist1.replace(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)) @@ -253,7 +234,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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])) + playlist1 = self.core.playlists.save(playlist1.replace(tracks=[track])) self.assertEqual(playlist1, self.core.playlists.lookup(playlist1.uri)) playlist2 = self.core.playlists.create('test') @@ -274,7 +255,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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 = playlist.replace(tracks=[track]) playlist = self.core.playlists.save(playlist) self.assertEqual(len(self.core.playlists.as_list()), 1) @@ -298,7 +279,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): check_order(self.core.playlists.as_list(), ['a', 'b', 'c']) playlist = self.core.playlists.lookup('m3u:a.m3u') - playlist = playlist.copy(name='d') + playlist = playlist.replace(name='d') playlist = self.core.playlists.save(playlist) check_order(self.core.playlists.as_list(), ['b', 'c', 'd']) @@ -310,7 +291,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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])) + playlist = self.core.playlists.save(playlist.replace(tracks=[track])) item_refs = self.core.playlists.get_items(playlist.uri) @@ -323,3 +304,36 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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.filter:kwargs_criteria', + '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/m3u/test_translator.py b/tests/m3u/test_translator.py index fc7fc958..f1e14301 100644 --- a/tests/m3u/test_translator.py +++ b/tests/m3u/test_translator.py @@ -6,30 +6,34 @@ import os import tempfile import unittest +from mopidy.internal import path from mopidy.m3u import translator from mopidy.models import Track -from mopidy.utils import path from tests import path_to_data_dir data_dir = path_to_data_dir('') song1_path = path_to_data_dir('song1.mp3') song2_path = path_to_data_dir('song2.mp3') +song3_path = path_to_data_dir('φοο.mp3') encoded_path = path_to_data_dir('æøå.mp3') song1_uri = path.path_to_uri(song1_path) song2_uri = path.path_to_uri(song2_path) +song3_uri = path.path_to_uri(song3_path) encoded_uri = path.path_to_uri(encoded_path) song1_track = Track(uri=song1_uri) song2_track = Track(uri=song2_uri) +song3_track = Track(uri=song3_uri) encoded_track = Track(uri=encoded_uri) -song1_ext_track = song1_track.copy(name='song1') -song2_ext_track = song2_track.copy(name='song2', length=60000) -encoded_ext_track = encoded_track.copy(name='æøå') +song1_ext_track = song1_track.replace(name='song1') +song2_ext_track = song2_track.replace(name='song2', length=60000) +encoded_ext_track = encoded_track.replace(name='æøå') # FIXME use mock instead of tempfile.NamedTemporaryFile class M3UToUriTest(unittest.TestCase): + def parse(self, name): return translator.parse_m3u(name, data_dir) @@ -115,6 +119,16 @@ class M3UToUriTest(unittest.TestCase): tracks = self.parse(path_to_data_dir('encoding-ext.m3u')) self.assertEqual([encoded_ext_track], tracks) + def test_m3u8_file(self): + with tempfile.NamedTemporaryFile(suffix='.m3u8', delete=False) as tmp: + tmp.write(song3_path) + try: + tracks = self.parse(tmp.name) + self.assertEqual([song3_track], tracks) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) + class URItoM3UTest(unittest.TestCase): pass diff --git a/tests/models/test_fields.py b/tests/models/test_fields.py new file mode 100644 index 00000000..bf842fd5 --- /dev/null +++ b/tests/models/test_fields.py @@ -0,0 +1,204 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +from mopidy.models.fields import * # noqa: F403 + + +def create_instance(field): + """Create an instance of a dummy class for testing fields.""" + + class Dummy(object): + attr = field + attr._name = 'attr' + + return Dummy() + + +class FieldDescriptorTest(unittest.TestCase): + def test_raw_field_accesible_through_class(self): + field = Field() + instance = create_instance(field) + self.assertEqual(field, instance.__class__.attr) + + def test_field_knows_its_name(self): + instance = create_instance(Field()) + self.assertEqual('attr', instance.__class__.attr._name) + + def test_field_has_none_as_default(self): + instance = create_instance(Field()) + self.assertIsNone(instance.attr) + + def test_field_does_not_store_default(self): + instance = create_instance(Field()) + self.assertFalse(hasattr(instance, '_attr')) + + def test_field_assigment_and_retrival(self): + instance = create_instance(Field()) + instance.attr = 1234 + self.assertEqual(1234, instance.attr) + + def test_field_can_be_reassigned(self): + instance = create_instance(Field()) + instance.attr = 1234 + instance.attr = 5678 + self.assertEqual(5678, instance.attr) + + def test_field_can_be_deleted(self): + instance = create_instance(Field()) + instance.attr = 1234 + del instance.attr + self.assertEqual(None, instance.attr) + self.assertFalse(hasattr(instance, '_attr')) + + def test_field_can_be_set_to_none(self): + instance = create_instance(Field()) + instance.attr = 1234 + instance.attr = None + self.assertEqual(None, instance.attr) + self.assertFalse(hasattr(instance, '_attr')) + + def test_field_can_be_set_default(self): + default = object() + instance = create_instance(Field(default=default)) + instance.attr = 1234 + instance.attr = default + self.assertEqual(default, instance.attr) + self.assertFalse(hasattr(instance, '_attr')) + + +class FieldTest(unittest.TestCase): + def test_default_handling(self): + instance = create_instance(Field(default=1234)) + self.assertEqual(1234, instance.attr) + + def test_type_checking(self): + instance = create_instance(Field(type=set)) + instance.attr = set() + + with self.assertRaises(TypeError): + instance.attr = 1234 + + def test_choices_checking(self): + instance = create_instance(Field(choices=(1, 2, 3))) + instance.attr = 1 + + with self.assertRaises(TypeError): + instance.attr = 4 + + def test_default_respects_type_check(self): + with self.assertRaises(TypeError): + create_instance(Field(type=int, default='123')) + + def test_default_respects_choices_check(self): + with self.assertRaises(TypeError): + create_instance(Field(choices=(1, 2, 3), default=5)) + + +class StringTest(unittest.TestCase): + def test_default_handling(self): + instance = create_instance(String(default='abc')) + self.assertEqual('abc', instance.attr) + + def test_native_str_allowed(self): + instance = create_instance(String()) + instance.attr = str('abc') + self.assertEqual('abc', instance.attr) + + def test_bytes_allowed(self): + instance = create_instance(String()) + instance.attr = b'abc' + self.assertEqual(b'abc', instance.attr) + + def test_unicode_allowed(self): + instance = create_instance(String()) + instance.attr = u'abc' + self.assertEqual(u'abc', instance.attr) + + def test_other_disallowed(self): + instance = create_instance(String()) + with self.assertRaises(TypeError): + instance.attr = 1234 + + def test_empty_string(self): + instance = create_instance(String()) + instance.attr = '' + self.assertEqual('', instance.attr) + + +class IntegerTest(unittest.TestCase): + def test_default_handling(self): + instance = create_instance(Integer(default=1234)) + self.assertEqual(1234, instance.attr) + + def test_int_allowed(self): + instance = create_instance(Integer()) + instance.attr = int(123) + self.assertEqual(123, instance.attr) + + def test_long_allowed(self): + instance = create_instance(Integer()) + instance.attr = long(123) + self.assertEqual(123, instance.attr) + + def test_float_disallowed(self): + instance = create_instance(Integer()) + with self.assertRaises(TypeError): + instance.attr = 123.0 + + def test_numeric_string_disallowed(self): + instance = create_instance(Integer()) + with self.assertRaises(TypeError): + instance.attr = '123' + + def test_other_disallowed(self): + instance = create_instance(String()) + with self.assertRaises(TypeError): + instance.attr = tuple() + + def test_min_validation(self): + instance = create_instance(Integer(min=0)) + instance.attr = 0 + self.assertEqual(0, instance.attr) + + with self.assertRaises(ValueError): + instance.attr = -1 + + def test_max_validation(self): + instance = create_instance(Integer(max=10)) + instance.attr = 10 + self.assertEqual(10, instance.attr) + + with self.assertRaises(ValueError): + instance.attr = 11 + + +class CollectionTest(unittest.TestCase): + def test_container_instance_is_default(self): + instance = create_instance(Collection(type=int, container=frozenset)) + self.assertEqual(frozenset(), instance.attr) + + def test_empty_collection(self): + instance = create_instance(Collection(type=int, container=frozenset)) + instance.attr = [] + self.assertEqual(frozenset(), instance.attr) + + def test_collection_gets_stored_in_container(self): + instance = create_instance(Collection(type=int, container=frozenset)) + instance.attr = [1, 2, 3] + self.assertEqual(frozenset([1, 2, 3]), instance.attr) + + def test_collection_with_wrong_type(self): + instance = create_instance(Collection(type=int, container=frozenset)) + with self.assertRaises(TypeError): + instance.attr = [1, '2', 3] + + def test_collection_with_string(self): + instance = create_instance(Collection(type=int, container=frozenset)) + with self.assertRaises(TypeError): + instance.attr = '123' + + def test_strings_should_not_be_considered_a_collection(self): + instance = create_instance(Collection(type=str, container=tuple)) + with self.assertRaises(TypeError): + instance.attr = b'123' diff --git a/tests/models/test_legacy.py b/tests/models/test_legacy.py new file mode 100644 index 00000000..d837d738 --- /dev/null +++ b/tests/models/test_legacy.py @@ -0,0 +1,164 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +from mopidy.models import ImmutableObject + + +class Model(ImmutableObject): + uri = None + name = None + models = frozenset() + + def __init__(self, *args, **kwargs): + self.__dict__['models'] = frozenset(kwargs.pop('models', None) or []) + super(Model, self).__init__(self, *args, **kwargs) + + +class SubModel(ImmutableObject): + uri = None + name = None + + +class GenericCopyTest(unittest.TestCase): + def compare(self, orig, other): + self.assertEqual(orig, other) + self.assertNotEqual(id(orig), id(other)) + + def test_copying_model(self): + model = Model() + self.compare(model, model.replace()) + + def test_copying_model_with_basic_values(self): + model = Model(name='foo', uri='bar') + other = model.replace(name='baz') + self.assertEqual('baz', other.name) + self.assertEqual('bar', other.uri) + + def test_copying_model_with_missing_values(self): + model = Model(uri='bar') + other = model.replace(name='baz') + self.assertEqual('baz', other.name) + self.assertEqual('bar', other.uri) + + def test_copying_model_with_private_internal_value(self): + model = Model(models=[SubModel(name=123)]) + other = model.replace(models=[SubModel(name=345)]) + self.assertIn(SubModel(name=345), other.models) + + def test_copying_model_with_invalid_key(self): + with self.assertRaises(TypeError): + Model().replace(invalid_key=True) + + def test_copying_model_to_remove(self): + model = Model(name='foo').replace(name=None) + self.assertEqual(model, Model()) + + +class ModelTest(unittest.TestCase): + def test_uri(self): + uri = 'an_uri' + model = Model(uri=uri) + self.assertEqual(model.uri, uri) + with self.assertRaises(AttributeError): + model.uri = None + + def test_name(self): + name = 'a name' + model = Model(name=name) + self.assertEqual(model.name, name) + with self.assertRaises(AttributeError): + model.name = None + + def test_submodels(self): + models = [SubModel(name=123), SubModel(name=456)] + model = Model(models=models) + self.assertEqual(set(model.models), set(models)) + with self.assertRaises(AttributeError): + model.models = None + + def test_models_none(self): + self.assertEqual(set(), Model(models=None).models) + + def test_invalid_kwarg(self): + with self.assertRaises(TypeError): + Model(foo='baz') + + def test_repr_without_models(self): + self.assertEqual( + "Model(name=u'name', uri=u'uri')", + repr(Model(uri='uri', name='name'))) + + def test_repr_with_models(self): + self.assertEqual( + "Model(models=[SubModel(name=123)], name=u'name', uri=u'uri')", + repr(Model(uri='uri', name='name', models=[SubModel(name=123)]))) + + def test_serialize_without_models(self): + self.assertDictEqual( + {'__model__': 'Model', 'uri': 'uri', 'name': 'name'}, + Model(uri='uri', name='name').serialize()) + + def test_serialize_with_models(self): + submodel = SubModel(name=123) + self.assertDictEqual( + {'__model__': 'Model', 'uri': 'uri', 'name': 'name', + 'models': [submodel.serialize()]}, + Model(uri='uri', name='name', models=[submodel]).serialize()) + + def test_eq_uri(self): + model1 = Model(uri='uri1') + model2 = Model(uri='uri1') + self.assertEqual(model1, model2) + self.assertEqual(hash(model1), hash(model2)) + + def test_eq_name(self): + model1 = Model(name='name1') + model2 = Model(name='name1') + self.assertEqual(model1, model2) + self.assertEqual(hash(model1), hash(model2)) + + def test_eq_models(self): + models = [SubModel()] + model1 = Model(models=models) + model2 = Model(models=models) + self.assertEqual(model1, model2) + self.assertEqual(hash(model1), hash(model2)) + + def test_eq_models_order(self): + submodel1 = SubModel(name='name1') + submodel2 = SubModel(name='name2') + model1 = Model(models=[submodel1, submodel2]) + model2 = Model(models=[submodel2, submodel1]) + self.assertEqual(model1, model2) + self.assertEqual(hash(model1), hash(model2)) + + def test_eq_none(self): + self.assertNotEqual(Model(), None) + + def test_eq_other(self): + self.assertNotEqual(Model(), 'other') + + def test_ne_uri(self): + model1 = Model(uri='uri1') + model2 = Model(uri='uri2') + self.assertNotEqual(model1, model2) + self.assertNotEqual(hash(model1), hash(model2)) + + def test_ne_name(self): + model1 = Model(name='name1') + model2 = Model(name='name2') + self.assertNotEqual(model1, model2) + self.assertNotEqual(hash(model1), hash(model2)) + + def test_ne_models(self): + model1 = Model(models=[SubModel(name='name1')]) + model2 = Model(models=[SubModel(name='name2')]) + self.assertNotEqual(model1, model2) + self.assertNotEqual(hash(model1), hash(model2)) + + def test_ignores_values_with_default_value_none(self): + model1 = Model(name='name1') + model2 = Model(name='name1', uri=None) + self.assertEqual(model1, model2) + self.assertEqual(hash(model1), hash(model2)) diff --git a/tests/test_models.py b/tests/models/test_models.py similarity index 91% rename from tests/test_models.py rename to tests/models/test_models.py index 7711f00d..5108411a 100644 --- a/tests/test_models.py +++ b/tests/models/test_models.py @@ -8,56 +8,105 @@ from mopidy.models import ( TlTrack, Track, model_json_decoder) -class GenericCopyTest(unittest.TestCase): +class InheritanceTest(unittest.TestCase): + + def test_weakref_and_slots_play_nice_in_subclass(self): + # Check that the following does not happen: + # TypeError: Error when calling the metaclass bases + # __weakref__ slot disallowed: either we already got one... + + class Foo(Track): + pass + + def test_sub_class_can_have_its_own_slots(self): + # Needed for things like SpotifyTrack in mopidy-spotify 1.x + + class Foo(Track): + __slots__ = ('_foo',) + + f = Foo() + f._foo = 123 + + def test_sub_class_can_be_initialized(self): + # Fails with following error if fields are not handled across classes. + # TypeError: __init__() got an unexpected keyword argument "type" + # Essentially this is testing that sub-classes take parent _fields into + # account. + + class Foo(Ref): + pass + + Foo.directory() + + +class CachingTest(unittest.TestCase): + + def test_same_instance(self): + self.assertIs(Track(), Track()) + + def test_same_instance_with_values(self): + self.assertIs(Track(uri='test'), Track(uri='test')) + + def test_different_instance_with_different_values(self): + self.assertIsNot(Track(uri='test1'), Track(uri='test2')) + + def test_different_instance_with_replace(self): + t = Track(uri='test1') + self.assertIsNot(t, t.replace(uri='test2')) + + +class GenericReplaceTest(unittest.TestCase): + def compare(self, orig, other): self.assertEqual(orig, other) - self.assertNotEqual(id(orig), id(other)) + self.assertEqual(id(orig), id(other)) - def test_copying_track(self): + def test_replace_track(self): track = Track() - self.compare(track, track.copy()) + self.compare(track, track.replace()) - def test_copying_artist(self): + def test_replace_artist(self): artist = Artist() - self.compare(artist, artist.copy()) + self.compare(artist, artist.replace()) - def test_copying_album(self): + def test_replace_album(self): album = Album() - self.compare(album, album.copy()) + self.compare(album, album.replace()) - def test_copying_playlist(self): + def test_replace_playlist(self): playlist = Playlist() - self.compare(playlist, playlist.copy()) + self.compare(playlist, playlist.replace()) - def test_copying_track_with_basic_values(self): + def test_replace_track_with_basic_values(self): track = Track(name='foo', uri='bar') - copy = track.copy(name='baz') - self.assertEqual('baz', copy.name) - self.assertEqual('bar', copy.uri) + other = track.replace(name='baz') + self.assertEqual('baz', other.name) + self.assertEqual('bar', other.uri) - def test_copying_track_with_missing_values(self): + def test_replace_track_with_missing_values(self): track = Track(uri='bar') - copy = track.copy(name='baz') - self.assertEqual('baz', copy.name) - self.assertEqual('bar', copy.uri) + other = track.replace(name='baz') + self.assertEqual('baz', other.name) + self.assertEqual('bar', other.uri) - def test_copying_track_with_private_internal_value(self): + def test_replace_track_with_private_internal_value(self): artist1 = Artist(name='foo') artist2 = Artist(name='bar') track = Track(artists=[artist1]) - copy = track.copy(artists=[artist2]) - self.assertIn(artist2, copy.artists) + other = track.replace(artists=[artist2]) + self.assertIn(artist2, other.artists) - def test_copying_track_with_invalid_key(self): + def test_replace_track_with_invalid_key(self): with self.assertRaises(TypeError): - Track().copy(invalid_key=True) + Track().replace(invalid_key=True) - def test_copying_track_to_remove(self): - track = Track(name='foo').copy(name=None) - self.assertEqual(track.__dict__, Track().__dict__) + def test_replace_track_to_remove(self): + track = Track(name='foo').replace(name=None) + self.assertFalse(hasattr(track, '_name')) class RefTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' ref = Ref(uri=uri) @@ -72,13 +121,19 @@ class RefTest(unittest.TestCase): with self.assertRaises(AttributeError): ref.name = None + # TODO: add these for the more of the models? + def test_del_name(self): + ref = Ref(name='foo') + with self.assertRaises(AttributeError): + del ref.name + def test_invalid_kwarg(self): with self.assertRaises(TypeError): Ref(foo='baz') def test_repr_without_results(self): self.assertEqual( - "Ref(name=u'foo', type=u'artist', uri=u'uri')", + "Ref(name=u'foo', type='artist', uri='uri')", repr(Ref(uri='uri', name='foo', type='artist'))) def test_serialize_without_results(self): @@ -131,6 +186,7 @@ class RefTest(unittest.TestCase): class ImageTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' image = Image(uri=uri) @@ -156,6 +212,7 @@ class ImageTest(unittest.TestCase): class ArtistTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' artist = Artist(uri=uri) @@ -183,14 +240,14 @@ class ArtistTest(unittest.TestCase): def test_invalid_kwarg_with_name_matching_method(self): with self.assertRaises(TypeError): - Artist(copy='baz') + Artist(replace='baz') with self.assertRaises(TypeError): Artist(serialize='baz') def test_repr(self): self.assertEqual( - "Artist(name=u'name', uri=u'uri')", + "Artist(name=u'name', uri='uri')", repr(Artist(uri='uri', name='name'))) def test_serialize(self): @@ -286,6 +343,7 @@ class ArtistTest(unittest.TestCase): class AlbumTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' album = Album(uri=uri) @@ -354,12 +412,12 @@ class AlbumTest(unittest.TestCase): def test_repr_without_artists(self): self.assertEqual( - "Album(name=u'name', uri=u'uri')", + "Album(name=u'name', uri='uri')", repr(Album(uri='uri', name='name'))) def test_repr_with_artists(self): self.assertEqual( - "Album(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", + "Album(artists=[Artist(name=u'foo')], name=u'name', uri='uri')", repr(Album(uri='uri', name='name', artists=[Artist(name='foo')]))) def test_serialize_without_artists(self): @@ -498,6 +556,7 @@ class AlbumTest(unittest.TestCase): class TrackTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' track = Track(uri=uri) @@ -597,12 +656,12 @@ class TrackTest(unittest.TestCase): def test_repr_without_artists(self): self.assertEqual( - "Track(name=u'name', uri=u'uri')", + "Track(name=u'name', uri='uri')", repr(Track(uri='uri', name='name'))) def test_repr_with_artists(self): self.assertEqual( - "Track(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", + "Track(artists=[Artist(name=u'foo')], name=u'name', uri='uri')", repr(Track(uri='uri', name='name', artists=[Artist(name='foo')]))) def test_serialize_without_artists(self): @@ -788,14 +847,15 @@ class TrackTest(unittest.TestCase): self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) - def test_copy_can_reset_to_default_value(self): + def test_replace_can_reset_to_default_value(self): track1 = Track(name='name1') - track2 = Track(name='name1', album=Album()).copy(album=None) + track2 = Track(name='name1', album=Album()).replace(album=None) self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) class TlTrackTest(unittest.TestCase): + def test_tlid(self): tlid = 123 tl_track = TlTrack(tlid=tlid) @@ -831,7 +891,7 @@ class TlTrackTest(unittest.TestCase): def test_repr(self): self.assertEqual( - "TlTrack(tlid=123, track=Track(uri=u'uri'))", + "TlTrack(tlid=123, track=Track(uri='uri'))", repr(TlTrack(tlid=123, track=Track(uri='uri')))) def test_serialize(self): @@ -874,6 +934,7 @@ class TlTrackTest(unittest.TestCase): class PlaylistTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' playlist = Playlist(uri=uri) @@ -913,7 +974,7 @@ class PlaylistTest(unittest.TestCase): playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) - new_playlist = playlist.copy(uri='another uri') + new_playlist = playlist.replace(uri='another uri') self.assertEqual(new_playlist.uri, 'another uri') self.assertEqual(new_playlist.name, 'a name') self.assertEqual(list(new_playlist.tracks), tracks) @@ -925,7 +986,7 @@ class PlaylistTest(unittest.TestCase): playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) - new_playlist = playlist.copy(name='another name') + new_playlist = playlist.replace(name='another name') self.assertEqual(new_playlist.uri, 'an uri') self.assertEqual(new_playlist.name, 'another name') self.assertEqual(list(new_playlist.tracks), tracks) @@ -938,7 +999,7 @@ class PlaylistTest(unittest.TestCase): uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) new_tracks = [Track(), Track()] - new_playlist = playlist.copy(tracks=new_tracks) + new_playlist = playlist.replace(tracks=new_tracks) self.assertEqual(new_playlist.uri, 'an uri') self.assertEqual(new_playlist.name, 'a name') self.assertEqual(list(new_playlist.tracks), new_tracks) @@ -951,7 +1012,7 @@ class PlaylistTest(unittest.TestCase): playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) - new_playlist = playlist.copy(last_modified=new_last_modified) + new_playlist = playlist.replace(last_modified=new_last_modified) self.assertEqual(new_playlist.uri, 'an uri') self.assertEqual(new_playlist.name, 'a name') self.assertEqual(list(new_playlist.tracks), tracks) @@ -963,12 +1024,12 @@ class PlaylistTest(unittest.TestCase): def test_repr_without_tracks(self): self.assertEqual( - "Playlist(name=u'name', uri=u'uri')", + "Playlist(name=u'name', uri='uri')", repr(Playlist(uri='uri', name='name'))) def test_repr_with_tracks(self): self.assertEqual( - "Playlist(name=u'name', tracks=[Track(name=u'foo')], uri=u'uri')", + "Playlist(name=u'name', tracks=[Track(name=u'foo')], uri='uri')", repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')]))) def test_serialize_without_tracks(self): @@ -1065,6 +1126,7 @@ class PlaylistTest(unittest.TestCase): class SearchResultTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' result = SearchResult(uri=uri) @@ -1099,16 +1161,10 @@ class SearchResultTest(unittest.TestCase): def test_repr_without_results(self): self.assertEqual( - "SearchResult(uri=u'uri')", + "SearchResult(uri='uri')", repr(SearchResult(uri='uri'))) def test_serialize_without_results(self): self.assertDictEqual( {'__model__': 'SearchResult', 'uri': 'uri'}, SearchResult(uri='uri').serialize()) - - def test_to_json_and_back(self): - result1 = SearchResult(uri='uri') - serialized = json.dumps(result1, cls=ModelJSONEncoder) - result2 = json.loads(serialized, object_hook=model_json_decoder) - self.assertEqual(result1, result2) diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 88e3567b..754b4418 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -7,12 +7,14 @@ import mock import pykka from mopidy import core +from mopidy.internal import deprecation from mopidy.mpd import session, uri_mapper 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 @@ -29,6 +31,9 @@ class BaseTestCase(unittest.TestCase): def get_config(self): return { + 'core': { + 'max_tracklist_length': 10000 + }, 'mpd': { 'password': None, } @@ -40,8 +45,12 @@ class BaseTestCase(unittest.TestCase): else: self.mixer = None self.backend = dummy_backend.create_proxy() - self.core = core.Core.start( - mixer=self.mixer, backends=[self.backend]).proxy() + + with deprecation.ignore(): + self.core = core.Core.start( + self.get_config(), + mixer=self.mixer, + backends=[self.backend]).proxy() self.uri_map = uri_mapper.MpdUriMapper(self.core) self.connection = MockConnection() 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 bd9a9e6c..2aeab3b0 100644 --- a/tests/mpd/protocol/test_command_list.py +++ b/tests/mpd/protocol/test_command_list.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class CommandListsTest(protocol.BaseTestCase): + def test_command_list_begin(self): response = self.send_request('command_list_begin') self.assertEqual([], response) diff --git a/tests/mpd/protocol/test_connection.py b/tests/mpd/protocol/test_connection.py index e25402bd..ae2212f6 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..81bec5a4 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.internal import deprecation from mopidy.models import Ref, Track 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,29 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('Id: %d' % tl_tracks[2].tlid) self.assertInResponse('OK') + +class PrioCommandTest(protocol.BaseTestCase): + + def test_prio(self): + self.send_request('prio 255 0:10') + self.assertEqualResponse('ACK [0@0] {prio} Not implemented') + + def test_prioid(self): + self.send_request('prioid 255 17 23') + self.assertEqualResponse('ACK [0@0] {prioid} Not implemented') + + +class RangeIdCommandTest(protocol.BaseTestCase): + + def test_rangeid(self): + self.send_request('rangeid 17 0:30') + self.assertEqualResponse('ACK [0@0] {rangeid} Not implemented') + + +# 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 +415,58 @@ 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') + + +class TagCommandTest(protocol.BaseTestCase): + + def test_addtagid(self): + self.send_request('addtagid 17 artist Abba') + self.assertEqualResponse('ACK [0@0] {addtagid} Not implemented') + + def test_cleartagid(self): + self.send_request('cleartagid 17 artist') + self.assertEqualResponse('ACK [0@0] {cleartagid} Not implemented') diff --git a/tests/mpd/protocol/test_idle.py b/tests/mpd/protocol/test_idle.py index e3c6ad38..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) diff --git a/tests/mpd/protocol/test_mount.py b/tests/mpd/protocol/test_mount.py new file mode 100644 index 00000000..c599ff46 --- /dev/null +++ b/tests/mpd/protocol/test_mount.py @@ -0,0 +1,22 @@ +from __future__ import absolute_import, unicode_literals + +from tests.mpd import protocol + + +class MountTest(protocol.BaseTestCase): + + def test_mount(self): + self.send_request('mount my_disk /dev/sda') + self.assertEqualResponse('ACK [0@0] {mount} Not implemented') + + def test_unmount(self): + self.send_request('unmount my_disk') + self.assertEqualResponse('ACK [0@0] {unmount} Not implemented') + + def test_listmounts(self): + self.send_request('listmounts') + self.assertEqualResponse('ACK [0@0] {listmounts} Not implemented') + + def test_listneighbors(self): + self.send_request('listneighbors') + self.assertEqualResponse('ACK [0@0] {listneighbors} Not implemented') diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 32fb3e25..ea944b7a 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -2,13 +2,18 @@ from __future__ import absolute_import, unicode_literals import unittest +import mock + from mopidy.models import Album, Artist, Playlist, Ref, SearchResult, Track -from mopidy.mpd.protocol import music_db +from mopidy.mpd.protocol import music_db, stored_playlists 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 +37,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') @@ -73,6 +81,16 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('playtime: 650') self.assertInResponse('OK') + def test_count_with_track_length_none(self): + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[ + Track(uri='dummy:b', date="2001", length=None), + ]) + self.send_request('count "date" "2001"') + self.assertInResponse('songs: 1') + self.assertInResponse('playtime: 0') + self.assertInResponse('OK') + def test_findadd(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(uri='dummy:a', name='A')]) @@ -97,38 +115,42 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_searchaddpl_appends_to_existing_playlist(self): playlist = self.core.playlists.create('my favs').get() - playlist = playlist.copy(tracks=[ + playlist = playlist.replace(tracks=[ Track(uri='dummy:x', name='X'), Track(uri='dummy:y', name='y'), ]) 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): @@ -275,33 +297,41 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('directory: /dummy/a') self.assertInResponse('directory: /dummy/a [2]') - def test_lsinfo_without_path_returns_same_as_for_root(self): - last_modified = 1390942873222 + def test_listfiles(self): + self.send_request('listfiles') + self.assertEqualResponse('ACK [0@0] {listfiles} Not implemented') + + @mock.patch.object(stored_playlists, '_get_last_modified') + def test_lsinfo_without_path_returns_same_as_for_root( + self, last_modified_mock): + last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.playlists.set_dummy_playlists([ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) + Playlist(name='a', uri='dummy:/a')]) response1 = self.send_request('lsinfo') response2 = self.send_request('lsinfo "/"') self.assertEqual(response1, response2) - def test_lsinfo_with_empty_path_returns_same_as_for_root(self): - last_modified = 1390942873222 + @mock.patch.object(stored_playlists, '_get_last_modified') + def test_lsinfo_with_empty_path_returns_same_as_for_root( + self, last_modified_mock): + last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.playlists.set_dummy_playlists([ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) + Playlist(name='a', uri='dummy:/a')]) response1 = self.send_request('lsinfo ""') response2 = self.send_request('lsinfo "/"') self.assertEqual(response1, response2) - def test_lsinfo_for_root_includes_playlists(self): - last_modified = 1390942873222 + @mock.patch.object(stored_playlists, '_get_last_modified') + def test_lsinfo_for_root_includes_playlists(self, last_modified_mock): + last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.playlists.set_dummy_playlists([ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) + Playlist(name='a', uri='dummy:/a')]) self.send_request('lsinfo "/"') self.assertInResponse('playlist: a') - # Date without milliseconds and with time zone information - self.assertInResponse('Last-Modified: 2014-01-28T21:01:13Z') + self.assertInResponse('Last-Modified: 2015-08-05T22:51:06Z') self.assertInResponse('OK') def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self): @@ -313,7 +343,10 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('directory: dummy') self.assertInResponse('OK') - def test_lsinfo_for_dir_with_and_without_leading_slash_is_the_same(self): + @mock.patch.object(stored_playlists, '_get_last_modified') + def test_lsinfo_for_dir_with_and_without_leading_slash_is_the_same( + self, last_modified_mock): + last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} @@ -322,7 +355,10 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): response2 = self.send_request('lsinfo "/dummy"') self.assertEqual(response1, response2) - def test_lsinfo_for_dir_with_and_without_trailing_slash_is_the_same(self): + @mock.patch.object(stored_playlists, '_get_last_modified') + def test_lsinfo_for_dir_with_and_without_trailing_slash_is_the_same( + self, last_modified_mock): + last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} @@ -380,12 +416,11 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_lsinfo_for_root_returns_browse_result_before_playlists(self): - last_modified = 1390942873222 self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} self.backend.playlists.set_dummy_playlists([ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) + Playlist(name='a', uri='dummy:/a')]) response = self.send_request('lsinfo "/"') self.assertLess(response.index('directory: dummy'), @@ -422,6 +457,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,6 +648,7 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): class MusicDatabaseListTest(protocol.BaseTestCase): + def test_list(self): self.backend.library.dummy_get_distinct_result = { 'artist': set(['A Artist'])} @@ -625,6 +662,7 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [2@0] {list} incorrect arguments') # Track title + def test_list_title(self): self.send_request('list "title"') self.assertInResponse('OK') @@ -1058,6 +1096,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 22527e1e..b9adb646 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals import unittest from mopidy.core import PlaybackState +from mopidy.internal import deprecation from mopidy.models import Track 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()) @@ -172,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"') @@ -186,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()) @@ -247,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() @@ -269,7 +266,6 @@ 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) @@ -282,7 +278,6 @@ 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) @@ -297,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()) @@ -321,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() @@ -343,7 +332,6 @@ 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) @@ -356,7 +344,6 @@ 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) @@ -371,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') @@ -413,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"') @@ -446,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) @@ -457,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) @@ -468,6 +445,7 @@ 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') diff --git a/tests/mpd/protocol/test_reflection.py b/tests/mpd/protocol/test_reflection.py index 5c44c464..097c2e2a 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( @@ -40,6 +41,23 @@ class ReflectionHandlerTest(protocol.BaseTestCase): def test_tagtypes(self): self.send_request('tagtypes') + self.assertInResponse('tagtype: Artist') + self.assertInResponse('tagtype: ArtistSort') + self.assertInResponse('tagtype: Album') + self.assertInResponse('tagtype: AlbumArtist') + self.assertInResponse('tagtype: AlbumArtistSort') + self.assertInResponse('tagtype: Title') + self.assertInResponse('tagtype: Track') + self.assertInResponse('tagtype: Name') + self.assertInResponse('tagtype: Genre') + self.assertInResponse('tagtype: Date') + self.assertInResponse('tagtype: Composer') + self.assertInResponse('tagtype: Performer') + self.assertInResponse('tagtype: Disc') + self.assertInResponse('tagtype: MUSICBRAINZ_ARTISTID') + self.assertInResponse('tagtype: MUSICBRAINZ_ALBUMID') + self.assertInResponse('tagtype: MUSICBRAINZ_ALBUMARTISTID') + self.assertInResponse('tagtype: MUSICBRAINZ_TRACKID') self.assertInResponse('OK') def test_urlhandlers(self): @@ -49,6 +67,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 a31258ab..565b369e 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -2,12 +2,16 @@ from __future__ import absolute_import, unicode_literals import random +import mock + from mopidy.models import Playlist, Ref, Track +from mopidy.mpd.protocol import stored_playlists from tests.mpd import protocol class IssueGH17RegressionTest(protocol.BaseTestCase): + """ The issue: http://github.com/mopidy/mopidy/issues/17 @@ -17,15 +21,19 @@ 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') @@ -48,6 +56,7 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): class IssueGH18RegressionTest(protocol.BaseTestCase): + """ The issue: http://github.com/mopidy/mopidy/issues/18 @@ -59,9 +68,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 +95,7 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): class IssueGH22RegressionTest(protocol.BaseTestCase): + """ The issue: http://github.com/mopidy/mopidy/issues/22 @@ -95,9 +109,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 +130,7 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): class IssueGH69RegressionTest(protocol.BaseTestCase): + """ The issue: https://github.com/mopidy/mopidy/issues/69 @@ -124,9 +143,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 +159,7 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): class IssueGH113RegressionTest(protocol.BaseTestCase): + """ The issue: https://github.com/mopidy/mopidy/issues/113 @@ -161,6 +185,7 @@ class IssueGH113RegressionTest(protocol.BaseTestCase): class IssueGH137RegressionTest(protocol.BaseTestCase): + """ The issue: https://github.com/mopidy/mopidy/issues/137 @@ -192,12 +217,14 @@ class IssueGH1120RegressionTest(protocol.BaseTestCase): """ - def test(self): + @mock.patch.object(stored_playlists, '_get_last_modified') + def test(self, last_modified_mock): + last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.playlist(name='Top 100 tracks', uri='dummy:/1')], } self.backend.playlists.set_dummy_playlists([ - Playlist(name='Top 100 tracks', uri='dummy:/1', last_modified=123), + Playlist(name='Top 100 tracks', uri='dummy:/1'), ]) response1 = self.send_request('lsinfo "/"') diff --git a/tests/mpd/protocol/test_status.py b/tests/mpd/protocol/test_status.py index 09df3526..fb448d8d 100644 --- a/tests/mpd/protocol/test_status.py +++ b/tests/mpd/protocol/test_status.py @@ -6,21 +6,24 @@ 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: ') - self.assertInResponse('Album: ') - self.assertInResponse('Track: 0') + self.assertNotInResponse('Artist: ') + self.assertNotInResponse('Title: ') + self.assertNotInResponse('Album: ') + self.assertNotInResponse('Track: 0') self.assertNotInResponse('Date: ') self.assertInResponse('Pos: 0') self.assertInResponse('Id: 0') 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 cca32b0d..90c325ff 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -1,11 +1,15 @@ from __future__ import absolute_import, unicode_literals +import mock + from mopidy.models import Playlist, Track +from mopidy.mpd.protocol import stored_playlists from tests.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): + def test_listplaylist(self): self.backend.playlists.set_dummy_playlists([ Playlist( @@ -44,7 +48,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.send_request('listplaylistinfo "name"') self.assertInResponse('file: dummy:a') - self.assertInResponse('Track: 0') + self.assertNotInResponse('Track: 0') self.assertNotInResponse('Pos: 0') self.assertInResponse('OK') @@ -55,7 +59,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.send_request('listplaylistinfo name') self.assertInResponse('file: dummy:a') - self.assertInResponse('Track: 0') + self.assertNotInResponse('Track: 0') self.assertNotInResponse('Pos: 0') self.assertInResponse('OK') @@ -71,19 +75,20 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.send_request('listplaylistinfo "a [2]"') self.assertInResponse('file: c') - self.assertInResponse('Track: 0') + self.assertNotInResponse('Track: 0') self.assertNotInResponse('Pos: 0') self.assertInResponse('OK') - def test_listplaylists(self): - last_modified = 1390942873222 + @mock.patch.object(stored_playlists, '_get_last_modified') + def test_listplaylists(self, last_modified_mock): + last_modified_mock.return_value = '2015-08-05T22:51:06Z' self.backend.playlists.set_dummy_playlists([ - Playlist(name='a', uri='dummy:a', last_modified=last_modified)]) + Playlist(name='a', uri='dummy:a')]) self.send_request('listplaylists') self.assertInResponse('playlist: a') # Date without milliseconds and with time zone information - self.assertInResponse('Last-Modified: 2014-01-28T21:01:13Z') + self.assertInResponse('Last-Modified: 2015-08-05T22:51:06Z') self.assertInResponse('OK') def test_listplaylists_duplicate(self): @@ -130,54 +135,78 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): 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.set_dummy_playlists([ - Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])]) + 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.set_dummy_playlists([ - Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])]) + 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.set_dummy_playlists([ - Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])]) + 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 a281d10e..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() diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index d6b11e43..e5eec0f9 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -5,6 +5,7 @@ import unittest import pykka from mopidy import core +from mopidy.internal import deprecation from mopidy.mpd.dispatcher import MpdDispatcher from mopidy.mpd.exceptions import MpdAckError @@ -12,6 +13,7 @@ from tests import dummy_backend class MpdDispatcherTest(unittest.TestCase): + def setUp(self): # noqa: N802 config = { 'mpd': { @@ -20,9 +22,11 @@ class MpdDispatcherTest(unittest.TestCase): } } self.backend = dummy_backend.create_proxy() - self.core = core.Core.start(backends=[self.backend]).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() diff --git a/tests/mpd/test_exceptions.py b/tests/mpd/test_exceptions.py index 7bb64096..e3759e4e 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -8,14 +8,6 @@ from mopidy.mpd.exceptions import ( 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: diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index e130353b..76fa9fcb 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -6,6 +6,7 @@ import pykka from mopidy import core from mopidy.core import PlaybackState +from mopidy.internal import deprecation from mopidy.models import Track from mopidy.mpd import dispatcher from mopidy.mpd.protocol import status @@ -22,17 +23,33 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.mixer = dummy_mixer.create_proxy() self.backend = dummy_backend.create_proxy() - self.core = core.Core.start( - mixer=self.mixer, backends=[self.backend]).proxy() + + with deprecation.ignore(): + self.core = core.Core.start( + config, + 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) @@ -67,7 +84,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): - self.core.tracklist.repeat = 1 + self.core.tracklist.set_repeat(True) result = dict(status.status(self.context)) self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 1) @@ -78,7 +95,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): - self.core.tracklist.random = 1 + self.core.tracklist.set_random(True) result = dict(status.status(self.context)) self.assertIn('random', result) self.assertEqual(int(result['random']), 1) @@ -94,7 +111,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): - self.core.tracklist.consume = 1 + self.core.tracklist.set_consume(True) result = dict(status.status(self.context)) self.assertIn('consume', result) self.assertEqual(int(result['consume']), 1) @@ -135,21 +152,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) @@ -159,7 +177,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) @@ -169,7 +187,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) @@ -178,7 +196,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)) @@ -186,8 +204,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 527cfef8..65c80bbb 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -1,11 +1,10 @@ from __future__ import absolute_import, unicode_literals -import datetime import unittest +from mopidy.internal import path from mopidy.models import Album, Artist, Playlist, TlTrack, Track from mopidy.mpd import translator -from mopidy.utils.path import mtime class TrackMpdFormatTest(unittest.TestCase): @@ -15,36 +14,37 @@ class TrackMpdFormatTest(unittest.TestCase): name='a name', album=Album( name='an album', num_tracks=13, - artists=[Artist(name='an other artist')]), + artists=[Artist(name='an other artist')], + uri='urischeme:album:12345', images=['image1']), track_no=7, composers=[Artist(name='a composer')], performers=[Artist(name='a performer')], genre='a genre', - date=datetime.date(1977, 1, 1), - disc_no='1', + date='1977-01-01', + disc_no=1, comment='a comment', length=137000, ) def setUp(self): # noqa: N802 self.media_dir = '/dir/subdir' - mtime.set_fake_time(1234567) + path.mtime.set_fake_time(1234567) def tearDown(self): # noqa: N802 - mtime.undo_fake() + path.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) - self.assertIn(('Artist', ''), result) - self.assertIn(('Title', ''), result) - self.assertIn(('Album', ''), result) - self.assertIn(('Track', 0), result) + result = translator.track_to_mpd_format( + Track(uri='a uri', length=137000) + ) + self.assertIn(('file', 'a uri'), result) + self.assertIn(('Time', 137), result) + self.assertNotIn(('Artist', ''), result) + self.assertNotIn(('Title', ''), result) + self.assertNotIn(('Album', ''), result) + self.assertNotIn(('Track', 0), result) self.assertNotIn(('Date', ''), result) - self.assertEqual(len(result), 6) + self.assertEqual(len(result), 2) def test_track_to_mpd_format_with_position(self): result = translator.track_to_mpd_format(Track(), position=1) @@ -73,49 +73,84 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertIn(('Performer', 'a performer'), result) self.assertIn(('Genre', 'a genre'), result) self.assertIn(('Track', '7/13'), result) - self.assertIn(('Date', datetime.date(1977, 1, 1)), result) - self.assertIn(('Disc', '1'), result) + self.assertIn(('Date', '1977-01-01'), result) + self.assertIn(('Disc', 1), result) self.assertIn(('Pos', 9), result) self.assertIn(('Id', 122), result) + self.assertIn(('X-AlbumUri', 'urischeme:album:12345'), result) + self.assertIn(('X-AlbumImage', 'image1'), result) self.assertNotIn(('Comment', 'a comment'), result) - self.assertEqual(len(result), 14) + self.assertEqual(len(result), 16) + + def test_track_to_mpd_format_with_last_modified(self): + track = self.track.replace(last_modified=995303899000) + result = translator.track_to_mpd_format(track) + self.assertIn(('Last-Modified', '2001-07-16T17:18:19Z'), result) + + def test_track_to_mpd_format_with_last_modified_of_zero(self): + track = self.track.replace(last_modified=0) + result = translator.track_to_mpd_format(track) + keys = [k for k, v in result] + self.assertNotIn('Last-Modified', keys) def test_track_to_mpd_format_musicbrainz_trackid(self): - track = self.track.copy(musicbrainz_id='foo') + track = self.track.replace(musicbrainz_id='foo') result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_TRACKID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_albumid(self): - album = self.track.album.copy(musicbrainz_id='foo') - track = self.track.copy(album=album) + album = self.track.album.replace(musicbrainz_id='foo') + track = self.track.replace(album=album) result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ALBUMID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_albumartistid(self): - artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') - album = self.track.album.copy(artists=[artist]) - track = self.track.copy(album=album) + artist = list(self.track.artists)[0].replace(musicbrainz_id='foo') + album = self.track.album.replace(artists=[artist]) + track = self.track.replace(album=album) result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ALBUMARTISTID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_artistid(self): - artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') - track = self.track.copy(artists=[artist]) + artist = list(self.track.artists)[0].replace(musicbrainz_id='foo') + track = self.track.replace(artists=[artist]) result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ARTISTID', 'foo'), result) - def test_artists_to_mpd_format(self): + def test_concat_multi_values(self): artists = [Artist(name='ABBA'), Artist(name='Beatles')] - translated = translator.artists_to_mpd_format(artists) - self.assertEqual(translated, 'ABBA, Beatles') + translated = translator.concat_multi_values(artists, 'name') + self.assertEqual(translated, 'ABBA;Beatles') - def test_artists_to_mpd_format_artist_with_no_name(self): + def test_concat_multi_values_artist_with_no_name(self): artists = [Artist(name=None)] - translated = translator.artists_to_mpd_format(artists) + translated = translator.concat_multi_values(artists, 'name') self.assertEqual(translated, '') + def test_concat_multi_values_artist_with_no_musicbrainz_id(self): + artists = [Artist(name='Jah Wobble')] + translated = translator.concat_multi_values(artists, 'musicbrainz_id') + self.assertEqual(translated, '') + + def test_track_to_mpd_format_with_stream_title(self): + result = translator.track_to_mpd_format(self.track, stream_title='foo') + self.assertIn(('Name', 'a name'), result) + self.assertIn(('Title', 'foo'), result) + + def test_track_to_mpd_format_with_empty_stream_title(self): + result = translator.track_to_mpd_format(self.track, stream_title='') + self.assertIn(('Name', 'a name'), result) + self.assertNotIn(('Title', ''), result) + + def test_track_to_mpd_format_with_stream_and_no_track_name(self): + track = self.track.replace(name=None) + result = translator.track_to_mpd_format(track, stream_title='foo') + self.assertNotIn(('Name', ''), result) + self.assertIn(('Title', 'foo'), result) + 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 65c59cb6..67053924 100644 --- a/tests/stream/test_library.py +++ b/tests/stream/test_library.py @@ -1,43 +1,55 @@ from __future__ import absolute_import, unicode_literals -import unittest - -import gobject -gobject.threads_init() - import mock -import pygst -pygst.require('0.10') -import gst # noqa: pygst magic is needed to import correct gst +import pytest +from mopidy.audio import scan +from mopidy.internal import path from mopidy.models import Track from mopidy.stream import actor -from mopidy.utils.path import path_to_uri from tests import path_to_data_dir -class LibraryProviderTest(unittest.TestCase): - def setUp(self): # noqa: N802 - self.backend = mock.Mock() - self.backend.uri_schemes = ['file'] - self.uri = path_to_uri(path_to_data_dir('song1.wav')) +@pytest.fixture +def scanner(): + return scan.Scanner(timeout=100, proxy_config={}) - def test_lookup_ignores_unknown_scheme(self): - library = actor.StreamLibraryProvider(self.backend, 1000, [], {}) - self.assertFalse(library.lookup('http://example.com')) - def test_lookup_respects_blacklist(self): - library = actor.StreamLibraryProvider(self.backend, 10, [self.uri], {}) - self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) +@pytest.fixture +def backend(scanner): + backend = mock.Mock() + backend.uri_schemes = ['file'] + backend._scanner = scanner + return backend - def test_lookup_respects_blacklist_globbing(self): - blacklist = [path_to_uri(path_to_data_dir('')) + '*'] - library = actor.StreamLibraryProvider(self.backend, 100, blacklist, {}) - self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) - def test_lookup_converts_uri_metadata_to_track(self): - library = actor.StreamLibraryProvider(self.backend, 100, [], {}) - self.assertEqual([Track(length=4406, uri=self.uri)], - library.lookup(self.uri)) +@pytest.fixture +def track_uri(): + return path.path_to_uri(path_to_data_dir('song1.wav')) + + +def test_lookup_ignores_unknown_scheme(backend): + library = actor.StreamLibraryProvider(backend, []) + + assert library.lookup('http://example.com') == [] + + +def test_lookup_respects_blacklist(backend, track_uri): + library = actor.StreamLibraryProvider(backend, [track_uri]) + + assert library.lookup(track_uri) == [Track(uri=track_uri)] + + +def test_lookup_respects_blacklist_globbing(backend, track_uri): + blacklist = [path.path_to_uri(path_to_data_dir('')) + '*'] + library = actor.StreamLibraryProvider(backend, blacklist) + + assert library.lookup(track_uri) == [Track(uri=track_uri)] + + +def test_lookup_converts_uri_metadata_to_track(backend, track_uri): + library = actor.StreamLibraryProvider(backend, []) + + assert library.lookup(track_uri) == [Track(length=4406, uri=track_uri)] diff --git a/tests/stream/test_playback.py b/tests/stream/test_playback.py new file mode 100644 index 00000000..4da87ae0 --- /dev/null +++ b/tests/stream/test_playback.py @@ -0,0 +1,145 @@ +from __future__ import absolute_import, unicode_literals + +import mock + +import pytest + +import requests + +import responses + +from mopidy import exceptions +from mopidy.audio import scan +from mopidy.stream import actor + + +TIMEOUT = 1000 +URI = 'http://example.com/listen.m3u' +BODY = """ +#EXTM3U +http://example.com/stream.mp3 +http://foo.bar/baz +""".strip() + + +@pytest.fixture +def config(): + return { + 'proxy': {}, + 'stream': { + 'timeout': TIMEOUT, + }, + } + + +@pytest.fixture +def audio(): + return mock.Mock() + + +@pytest.fixture +def scanner(): + scanner = mock.Mock(spec=scan.Scanner) + scanner.scan.return_value.mime = 'text/foo' + return scanner + + +@pytest.fixture +def backend(scanner): + backend = mock.Mock() + backend.uri_schemes = ['file'] + backend._scanner = scanner + return backend + + +@pytest.fixture +def provider(audio, backend, config): + return actor.StreamPlaybackProvider(audio, backend, config) + + +@responses.activate +def test_translate_uri_of_audio_stream_returns_same_uri( + scanner, provider): + + scanner.scan.return_value.mime = 'audio/ogg' + + result = provider.translate_uri(URI) + + scanner.scan.assert_called_once_with(URI) + assert result == URI + + +@responses.activate +def test_translate_uri_of_playlist_returns_first_uri_in_list( + scanner, provider): + + responses.add( + responses.GET, URI, body=BODY, content_type='audio/x-mpegurl') + + result = provider.translate_uri(URI) + + scanner.scan.assert_called_once_with(URI) + assert result == 'http://example.com/stream.mp3' + assert responses.calls[0].request.headers['User-Agent'].startswith( + 'Mopidy-Stream/') + + +@responses.activate +def test_translate_uri_of_playlist_with_xml_mimetype(scanner, provider): + scanner.scan.return_value.mime = 'application/xspf+xml' + responses.add( + responses.GET, URI, body=BODY, content_type='application/xspf+xml') + + result = provider.translate_uri(URI) + + scanner.scan.assert_called_once_with(URI) + assert result == 'http://example.com/stream.mp3' + + +def test_translate_uri_when_scanner_fails(scanner, provider, caplog): + scanner.scan.side_effect = exceptions.ScannerError('foo failed') + + result = provider.translate_uri('bar') + + assert result is None + assert 'Problem scanning URI bar: foo failed' in caplog.text() + + +@responses.activate +def test_translate_uri_when_playlist_download_fails(provider, caplog): + responses.add(responses.GET, URI, body=BODY, status=500) + + result = provider.translate_uri(URI) + + assert result is None + assert 'Problem downloading stream playlist' in caplog.text() + + +def test_translate_uri_times_out_if_connection_times_out(provider, caplog): + with mock.patch.object(actor.requests, 'Session') as session_mock: + get_mock = session_mock.return_value.get + get_mock.side_effect = requests.exceptions.Timeout + + result = provider.translate_uri(URI) + + get_mock.assert_called_once_with(URI, timeout=1.0, stream=True) + assert result is None + assert ( + 'Download of stream playlist (%s) failed due to connection ' + 'timeout after 1.000s' % URI in caplog.text()) + + +@responses.activate +def test_translate_uri_times_out_if_download_is_slow(provider, caplog): + responses.add( + responses.GET, URI, body=BODY, content_type='audio/x-mpegurl') + + with mock.patch.object(actor, 'time') as time_mock: + time_mock.time.side_effect = [0, TIMEOUT + 1] + + result = provider.translate_uri(URI) + + assert result is None + assert ( + 'Download of stream playlist (%s) failed due to download taking ' + 'more than 1.000s' % URI in caplog.text()) 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..1a6bd538 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -1,34 +1,273 @@ from __future__ import absolute_import, unicode_literals -import unittest +import os -from mopidy import config, ext +import mock + +import pkg_resources + +import pytest + +from mopidy import config, exceptions, ext + +from tests import IsA, any_unicode -class ExtensionTest(unittest.TestCase): - def setUp(self): # noqa: N802 - self.ext = ext.Extension() +class DummyExtension(ext.Extension): + dist_name = 'Mopidy-Foobar' + ext_name = 'foobar' + version = '1.2.3' - def test_dist_name_is_none(self): - self.assertIsNone(self.ext.dist_name) + def get_default_config(self): + return '[foobar]\nenabled = true' - def test_ext_name_is_none(self): - self.assertIsNone(self.ext.ext_name) - def test_version_is_none(self): - self.assertIsNone(self.ext.version) +any_testextension = IsA(DummyExtension) - def test_get_default_config_raises_not_implemented(self): - with self.assertRaises(NotImplementedError): - self.ext.get_default_config() - def test_get_config_schema_returns_extension_schema(self): - schema = self.ext.get_config_schema() - self.assertIsInstance(schema['enabled'], config.Boolean) +class TestExtension(object): - def test_validate_environment_does_nothing_by_default(self): - self.assertIsNone(self.ext.validate_environment()) + @pytest.fixture + def extension(self): + return ext.Extension() - def test_setup_raises_not_implemented(self): - with self.assertRaises(NotImplementedError): - self.ext.setup(None) + def test_dist_name_is_none(self, extension): + assert extension.dist_name is None + + def test_ext_name_is_none(self, extension): + assert extension.ext_name is None + + def test_version_is_none(self, extension): + assert extension.version is None + + def test_get_default_config_raises_not_implemented(self, extension): + with pytest.raises(NotImplementedError): + extension.get_default_config() + + def test_get_config_schema_returns_extension_schema(self, extension): + schema = extension.get_config_schema() + assert isinstance(schema['enabled'], config.Boolean) + + def test_validate_environment_does_nothing_by_default(self, extension): + assert extension.validate_environment() is None + + def test_setup_raises_not_implemented(self, extension): + with pytest.raises(NotImplementedError): + extension.setup(None) + + def test_get_cache_dir_raises_assertion_error(self, extension): + config = {'core': {'cache_dir': '/tmp'}} + with pytest.raises(AssertionError): # ext_name not set + extension.get_cache_dir(config) + + def test_get_config_dir_raises_assertion_error(self, extension): + config = {'core': {'config_dir': '/tmp'}} + with pytest.raises(AssertionError): # ext_name not set + extension.get_config_dir(config) + + def test_get_data_dir_raises_assertion_error(self, extension): + config = {'core': {'data_dir': '/tmp'}} + with pytest.raises(AssertionError): # ext_name not set + extension.get_data_dir(config) + + +class TestLoadExtensions(object): + + @pytest.yield_fixture + def iter_entry_points_mock(self, request): + patcher = mock.patch('pkg_resources.iter_entry_points') + iter_entry_points = patcher.start() + iter_entry_points.return_value = [] + yield iter_entry_points + patcher.stop() + + def test_no_extensions(self, iter_entry_points_mock): + iter_entry_points_mock.return_value = [] + assert ext.load_extensions() == [] + + def test_load_extensions(self, iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = DummyExtension + + iter_entry_points_mock.return_value = [mock_entry_point] + + expected = ext.ExtensionData( + any_testextension, mock_entry_point, IsA(config.ConfigSchema), + any_unicode, None) + + assert ext.load_extensions() == [expected] + + def test_gets_wrong_class(self, iter_entry_points_mock): + + class WrongClass(object): + pass + + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = WrongClass + + iter_entry_points_mock.return_value = [mock_entry_point] + + assert ext.load_extensions() == [] + + def test_gets_instance(self, iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = DummyExtension() + + iter_entry_points_mock.return_value = [mock_entry_point] + + assert ext.load_extensions() == [] + + def test_creating_instance_fails(self, iter_entry_points_mock): + mock_extension = mock.Mock(spec=ext.Extension) + mock_extension.side_effect = Exception + + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = mock_extension + + iter_entry_points_mock.return_value = [mock_entry_point] + + assert ext.load_extensions() == [] + + def test_get_config_schema_fails(self, iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = DummyExtension + + iter_entry_points_mock.return_value = [mock_entry_point] + + with mock.patch.object(DummyExtension, 'get_config_schema') as get: + get.side_effect = Exception + + assert ext.load_extensions() == [] + get.assert_called_once_with() + + def test_get_default_config_fails(self, iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = DummyExtension + + iter_entry_points_mock.return_value = [mock_entry_point] + + with mock.patch.object(DummyExtension, 'get_default_config') as get: + get.side_effect = Exception + + assert ext.load_extensions() == [] + get.assert_called_once_with() + + def test_get_command_fails(self, iter_entry_points_mock): + mock_entry_point = mock.Mock() + mock_entry_point.load.return_value = DummyExtension + + iter_entry_points_mock.return_value = [mock_entry_point] + + with mock.patch.object(DummyExtension, 'get_command') as get: + get.side_effect = Exception + + assert ext.load_extensions() == [] + get.assert_called_once_with() + + +class TestValidateExtensionData(object): + + @pytest.fixture + def ext_data(self): + extension = DummyExtension() + + entry_point = mock.Mock() + entry_point.name = extension.ext_name + + schema = extension.get_config_schema() + defaults = extension.get_default_config() + command = extension.get_command() + + return ext.ExtensionData( + extension, entry_point, schema, defaults, command) + + def test_name_mismatch(self, ext_data): + ext_data.entry_point.name = 'barfoo' + assert not ext.validate_extension_data(ext_data) + + def test_distribution_not_found(self, ext_data): + error = pkg_resources.DistributionNotFound + ext_data.entry_point.require.side_effect = error + assert not ext.validate_extension_data(ext_data) + + def test_version_conflict(self, ext_data): + error = pkg_resources.VersionConflict + ext_data.entry_point.require.side_effect = error + assert not ext.validate_extension_data(ext_data) + + def test_entry_point_require_exception(self, ext_data): + ext_data.entry_point.require.side_effect = Exception + + # Hope that entry points are well behaved, so exception will bubble. + with pytest.raises(Exception): + assert not ext.validate_extension_data(ext_data) + + def test_extenions_validate_environment_error(self, ext_data): + extension = ext_data.extension + with mock.patch.object(extension, 'validate_environment') as validate: + validate.side_effect = exceptions.ExtensionError('error') + + assert not ext.validate_extension_data(ext_data) + validate.assert_called_once_with() + + def test_extenions_validate_environment_exception(self, ext_data): + extension = ext_data.extension + with mock.patch.object(extension, 'validate_environment') as validate: + validate.side_effect = Exception + + assert not ext.validate_extension_data(ext_data) + validate.assert_called_once_with() + + def test_missing_schema(self, ext_data): + ext_data = ext_data._replace(config_schema=None) + assert not ext.validate_extension_data(ext_data) + + def test_schema_that_is_missing_enabled(self, ext_data): + del ext_data.config_schema['enabled'] + ext_data.config_schema['baz'] = config.String() + assert not ext.validate_extension_data(ext_data) + + def test_schema_with_wrong_types(self, ext_data): + ext_data.config_schema['enabled'] = 123 + assert not ext.validate_extension_data(ext_data) + + def test_schema_with_invalid_type(self, ext_data): + ext_data.config_schema['baz'] = 123 + assert not ext.validate_extension_data(ext_data) + + def test_no_default_config(self, ext_data): + ext_data = ext_data._replace(config_defaults=None) + assert not ext.validate_extension_data(ext_data) + + def test_get_cache_dir(self, ext_data): + core_cache_dir = '/tmp' + config = {'core': {'cache_dir': core_cache_dir}} + extension = ext_data.extension + + with mock.patch.object(ext.path, 'get_or_create_dir'): + cache_dir = extension.get_cache_dir(config) + + expected = os.path.join(core_cache_dir, extension.ext_name) + assert cache_dir == expected + + def test_get_config_dir(self, ext_data): + core_config_dir = '/tmp' + config = {'core': {'config_dir': core_config_dir}} + extension = ext_data.extension + + with mock.patch.object(ext.path, 'get_or_create_dir'): + config_dir = extension.get_config_dir(config) + + expected = os.path.join(core_config_dir, extension.ext_name) + assert config_dir == expected + + def test_get_data_dir(self, ext_data): + core_data_dir = '/tmp' + config = {'core': {'data_dir': core_data_dir}} + extension = ext_data.extension + + with mock.patch.object(ext.path, 'get_or_create_dir'): + data_dir = extension.get_data_dir(config) + + expected = os.path.join(core_data_dir, extension.ext_name) + assert data_dir == expected 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_httpclient.py b/tests/test_httpclient.py new file mode 100644 index 00000000..63591f80 --- /dev/null +++ b/tests/test_httpclient.py @@ -0,0 +1,38 @@ +from __future__ import unicode_literals + +import re + +import pytest + +from mopidy import httpclient + + +@pytest.mark.parametrize("config,expected", [ + ({}, None), + ({'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), + ({'scheme': None, 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), + ({'scheme': 'https', 'hostname': 'proxy.lan'}, 'https://proxy.lan:80'), + ({'username': 'user', 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), + ({'password': 'pass', 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), + ({'hostname': 'proxy.lan', 'port': 8080}, 'http://proxy.lan:8080'), + ({'hostname': 'proxy.lan', 'port': -1}, 'http://proxy.lan:80'), + ({'username': 'user', 'password': 'pass', 'hostname': 'proxy.lan'}, + 'http://user:pass@proxy.lan:80'), +]) +def test_format_proxy(config, expected): + assert httpclient.format_proxy(config) == expected + + +def test_format_proxy_without_auth(): + config = {'username': 'user', 'password': 'pass', 'hostname': 'proxy.lan'} + formated_proxy = httpclient.format_proxy(config, auth=False) + assert formated_proxy == 'http://proxy.lan:80' + + +@pytest.mark.parametrize("name,expected", [ + (None, r'^Mopidy/[^ ]+ CPython|/[^ ]+$'), + ('Foo', r'^Foo Mopidy/[^ ]+ CPython|/[^ ]+$'), + ('Foo/1.2.3', r'^Foo/1.2.3 Mopidy/[^ ]+ CPython|/[^ ]+$'), +]) +def test_format_user_agent(name, expected): + assert re.match(expected, httpclient.format_user_agent(name)) 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_version.py b/tests/test_version.py index c0c2d9e6..011c8de7 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)) @@ -63,5 +64,6 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('1.0.4', '1.0.5') self.assertVersionLess('1.0.5', '1.0.6') self.assertVersionLess('1.0.6', '1.0.7') - self.assertVersionLess('1.0.7', __version__) - self.assertVersionLess(__version__, '1.0.9') + self.assertVersionLess('1.0.7', '1.0.8') + self.assertVersionLess('1.0.8', __version__) + self.assertVersionLess(__version__, '1.1.1') diff --git a/tox.ini b/tox.ini index 6dfab5ae..e29a40f2 100644 --- a/tox.ini +++ b/tox.ini @@ -13,8 +13,10 @@ commands = deps = mock pytest + pytest-capturelog pytest-cov pytest-xdist + responses [testenv:py27-tornado23] commands = py.test tests/http