diff --git a/.mailmap b/.mailmap index 54e01b7d..718d8f4b 100644 --- a/.mailmap +++ b/.mailmap @@ -23,4 +23,5 @@ Ignasi Fosch Christopher Schirner Laura Barber John Cass -Ronald Zielaznicki +Ronald Zielaznicki +Tom Roth diff --git a/AUTHORS b/AUTHORS index 91b71008..258967c3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -52,4 +52,10 @@ - John Cass - Laura Barber - Jakab Kristóf -- Ronald Zielaznicki +- Ronald Zielaznicki +- Wojciech Wnętrzak +- Camilo Nova +- Dražen Lučanin +- Naglis Jonaitis +- Tom Roth +- Mark Greenwood 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 605a30fe..320c776a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,15 +4,118 @@ Changelog This changelog is used to track all major changes to Mopidy. - v1.1.0 (UNRELEASED) =================== Core API -------- -- Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs`` - as the query is no longer supported (PR: :issue:`1090`) +- Calling the following methods with ``kwargs`` is being deprecated. + (PR: :issue:`1090`) + + - :meth:`mopidy.core.library.LibraryController.search` + - :meth:`mopidy.core.library.PlaylistsController.filter` + - :meth:`mopidy.core.library.TracklistController.filter` + - :meth:`mopidy.core.library.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.playback.PlaybackController.get_current_tlid`. + (Part of: :issue:`1137`) + +- Update core to handle backend crashes and bad data. (Fixes: :issue:`1161`) + +- Add `max_tracklist_length` config and limitation. (Fixes: :issue:`997` + PR: :issue:`1225`) + +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, intern identifiers and automatically + reuse instances. For the test data set this was developed against, a library + of ~14000 tracks, went from needing ~75MB to ~17MB. (Fixes: :issue:`348`) + +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`) + +Local backend +------------- + +- Filter out :class:`None` from + :meth:`~mopidy.backend.LibraryProvider.get_distinct` results. All returned + results should be strings. (Fixes: :issue:`1202`) + +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`) + +Utils +----- + +- Add :func:`mopidy.httpclient.format_proxy` and + :func:`mopidy.httpclient.format_user_agent`. (Part of: :issue:`1156`) Internal changes ---------------- @@ -20,19 +123,130 @@ Internal changes - Tests have been cleaned up to stop using deprecated APIs where feasible. (Partial fix: :issue:`1083`, PR: :issue:`1090`) +- 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`) -v1.0.1 (UNRELEASED) + +v1.0.8 (2015-07-22) =================== +Bug fix release. + +- Fix reversal of ``Title`` and ``Name`` in MPD protocol (Fixes: :issue:`1212` + PR: :issue:`1214`) + +- Fix crash if an M3U file in the :confval:`m3u/playlist_dir` directory has a + file name not decodable with the current file system encoding. (Fixes: + :issue:`1209`) + + +v1.0.7 (2015-06-26) +=================== + +Bug fix release. + +- Fix error in the MPD command ``list title ...``. The error was introduced in + v1.0.6. + + +v1.0.6 (2015-06-25) +=================== + +Bug fix release. + +- Core/MPD/Local: Add support for ``title`` in + :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`1181`, + PR: :issue:`1183`) + +- Core: Make sure track changes make it to audio while paused. + (Fixes: :issue:`1177`, PR: :issue:`1185`) + + +v1.0.5 (2015-05-19) +=================== + +Bug fix release. + +- Core: Add workaround for playlist providers that do not support + creating playlists. (Fixes: :issue:`1162`, PR :issue:`1165`) + +- M3U: Fix encoding error when saving playlists with non-ASCII track + titles. (Fixes: :issue:`1175`, PR :issue:`1176`) + + +v1.0.4 (2015-04-30) +=================== + +Bug fix release. + +- Audio: Since all previous attempts at tweaking the queuing for :issue:`1097` + seems to break things in subtle ways for different users. We are giving up + on tweaking the defaults and just going to live with a bit more lag on + software volume changes. (Fixes: :issue:`1147`) + + +v1.0.3 (2015-04-28) +=================== + +Bug fix release. + +- HTTP: Another follow-up to the Tornado <3.0 fixing. Since the tests aren't + run for Tornado 2.3 we didn't catch that our previous fix wasn't sufficient. + (Fixes: :issue:`1153`, PR: :issue:`1154`) + +- Audio: Follow-up fix for :issue:`1097` still exhibits issues for certain + setups. We are giving this get an other go by setting the buffer size to + maximum 100ms instead of a fixed number of buffers. (Addresses: :issue:`1147`, + PR: :issue:`1154`) + + +v1.0.2 (2015-04-27) +=================== + +Bug fix release. + +- HTTP: Make event broadcasts work with Tornado 2.3 again. The threading fix + in v1.0.1 broke this. + +- Audio: Fix for :issue:`1097` tuned down the buffer size in the queue. Turns + out this can cause distortions in certain cases. Give this an other go with + a more generous buffer size. (Addresses: :issue:`1147`, PR: :issue:`1152`) + +- Audio: Make sure mute events get emitted by software mixer. + (Fixes: :issue:`1146`, PR: :issue:`1152`) + + +v1.0.1 (2015-04-23) +=================== + +Bug fix release. + +- Core: Make the new history controller available for use. (Fixes: :js:`6`) + - Audio: Software volume control has been reworked to greatly reduce the delay between changing the volume and the change taking effect. (Fixes: - :issue:`1097`) + :issue:`1097`, PR: :issue:`1101`) - Audio: As a side effect of the previous bug fix, software volume is no longer tied to the PulseAudio application volume when using ``pulsesink``. This behavior was confusing for many users and doesn't work well with the plans for multiple outputs. +- Audio: Update scanner to decode all media it finds. This should fix cases + where the scanner hangs on non-audio files like video. The scanner will now + also let us know if we found any decodeable audio. (Fixes: :issue:`726`, PR: + issue:`1124`) + +- HTTP: Fix threading bug that would cause duplicate delivery of WS messages. + (PR: :issue:`1127`) + +- MPD: Fix case where a playlist that is present in both browse and as a listed + playlist breaks the MPD frontend protocol output. (Fixes :issue:`1120`, PR: + :issue:`1142`) + v1.0.0 (2015-03-25) =================== diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 91d0f8db..fe7ef21d 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -12,38 +12,8 @@ http://mpd.wikia.com/wiki/Clients. :local: -Test procedure -============== - -In some cases, we've used the following test procedure to compare the feature -completeness of clients: - -#. Connect to Mopidy -#. Search for "foo", with search type "any" if it can be selected -#. Add "The Pretender" from the search results to the current playlist -#. Start playback -#. Pause and resume playback -#. Adjust volume -#. Find a playlist and append it to the current playlist -#. Skip to next track -#. Skip to previous track -#. Select the last track from the current playlist -#. Turn on repeat mode -#. Seek to 10 seconds or so before the end of the track -#. Wait for the end of the track and confirm that playback continues at the - start of the playlist -#. Turn off repeat mode -#. Turn on random mode -#. Skip to next track and confirm that it random mode works -#. Turn off random mode -#. Stop playback -#. Check if the app got support for single mode and consume mode -#. Kill Mopidy and confirm that the app handles it without crashing - - - -Console clients -=============== +MPD console clients +=================== ncmpcpp ------- @@ -83,8 +53,8 @@ A command line client. Version 0.16 and upwards seems to work nicely with Mopidy. -Graphical clients -================= +MPD graphical clients +===================== GMPC ---- @@ -132,22 +102,12 @@ client for OS X. It is unmaintained, but generally works well with Mopidy. .. _android_mpd_clients: -Android clients -=============== - -We've tested all five MPD clients we could find for Android with Mopidy 0.8.1 -on a Samsung Galaxy Nexus with Android 4.1.2, using our standard test -procedure. - +MPD Android clients +=================== MPDroid ------- -Test date: - 2012-11-06 -Tested version: - 1.03.1 (released 2012-10-16) - .. image:: mpd-client-mpdroid.jpg :width: 288 :height: 512 @@ -155,128 +115,17 @@ Tested version: You can get `MPDroid from Google Play `_. -- MPDroid started out as a fork of PMix, and is now much better. - -- MPDroid's user interface looks nice. - -- Everything in the test procedure works. - -- In contrast to all other Android clients, MPDroid does support single mode or - consume mode. - -- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to - try to reconnect. - MPDroid is a good MPD client, and really the only one we can recommend. -BitMPC ------- - -Test date: - 2012-11-06 -Tested version: - 1.0.0 (released 2010-04-12) - -You can get `BitMPC from Google Play -`_. - -- The user interface lacks some finishing touches. E.g. you can't enter a - hostname for the server. Only IPv4 addresses are allowed. - -- When we last tested the same version of BitMPC using Android 2.1: - - - All features exercised in the test procedure worked. - - - BitMPC lacked support for single mode and consume mode. - - - BitMPC crashed if Mopidy was killed or crashed. - -- When we tried to test using Android 4.1.1, BitMPC started and connected to - Mopidy without problems, but the app crashed as soon as we fired off our - search, and continued to crash on startup after that. - -In conclusion, BitMPC is usable if you got an older Android phone and don't -care about looks. For newer Android versions, BitMPC will probably not work as -it hasn't been maintained for 2.5 years. - - -Droid MPD Client ----------------- - -Test date: - 2012-11-06 -Tested version: - 1.4.0 (released 2011-12-20) - -You can get `Droid MPD Client from Google Play -`_. - -- No intutive way to ask the app to connect to the server after adding the - server hostname to the settings. - -- To find the search functionality, you have to select the menu, - then "Playlist manager", then the search tab. I do not understand why search - is hidden inside "Playlist manager". - -- The tabs "Artists" and "Albums" did not contain anything, and did not cause - any requests. - -- The tab "Folders" showed a spinner and said "Updating data..." but did not - send any requests. - -- Searching for "foo" did nothing. No request was sent to the server. - -- Droid MPD client does not support single mode or consume mode. - -- Not able to complete the test procedure, due to the above problems. - -In conclusion, not a client we can recommend. - - -PMix ----- - -Test date: - 2012-11-06 -Tested version: - 0.4.0 (released 2010-03-06) - -You can get `PMix from Google Play -`_. - -PMix haven't been updated for 2.5 years, and has less working features than -it's fork MPDroid. Ignore PMix and use MPDroid instead. - - -MPD Remote ----------- - -Test date: - 2012-11-06 -Tested version: - 1.0 (released 2012-05-01) - -You can get `MPD Remote from Google Play -`_. - -This app looks terrible in the screen shots, got just 100+ downloads, and got a -terrible rating. I honestly didn't take the time to test it. - - .. _ios_mpd_clients: -iOS clients -=========== +MPD iOS clients +=============== MPoD ---- -Test date: - 2012-11-06 -Tested version: - 1.7.1 - .. image:: mpd-client-mpod.jpg :width: 320 :height: 480 @@ -285,26 +134,10 @@ The `MPoD `_ iPhone/iPod Touch app can be installed from `MPoD at iTunes Store `_. -- The user interface looks nice. - -- All features exercised in the test procedure worked with MPaD, except seek, - which I didn't figure out to do. - -- Search only works in the "Browse" tab, and not under in the "Artist", - "Album", or "Song" tabs. For the tabs where search doesn't work, no queries - are sent to Mopidy when searching. - -- Single mode and consume mode is supported. - MPaD ---- -Test date: - 2012-11-06 -Tested version: - 1.7.1 - .. image:: mpd-client-mpad.jpg :width: 480 :height: 360 @@ -313,25 +146,11 @@ The `MPaD `_ iPad app can be purchased from `MPaD at iTunes Store `_ -- The user interface looks nice, though I would like to be able to view the - current playlist in the large part of the split view. - -- All features exercised in the test procedure worked with MPaD. - -- Search only works in the "Browse" tab, and not under in the "Artist", - "Album", or "Song" tabs. For the tabs where search doesn't work, no queries - are sent to Mopidy when searching. - -- Single mode and consume mode is supported. - -- The server menu can be very slow top open, and there is no visible feedback - when waiting for the connection to a server to succeed. - .. _mpd-web-clients: -Web clients -=========== +MPD web clients +=============== The following web clients use the MPD protocol to communicate with Mopidy. For other web clients, see :ref:`http-clients`. diff --git a/docs/conf.py b/docs/conf.py index 96209182..cc760720 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,7 +47,6 @@ class Mock(object): return Mock() MOCK_MODULES = [ - 'cherrypy', 'dbus', 'dbus.mainloop', 'dbus.mainloop.glib', @@ -61,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() @@ -102,10 +95,13 @@ 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]) +# To make the build reproducible, avoid using today's date in the manpages +today = '2015' + exclude_trees = ['_build'] pygments_style = 'sphinx' 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/material_webclient.png b/docs/ext/material_webclient.png new file mode 100644 index 00000000..1f8b13f8 Binary files /dev/null and b/docs/ext/material_webclient.png differ diff --git a/docs/ext/mixers.rst b/docs/ext/mixers.rst index f934efce..88fd27dd 100644 --- a/docs/ext/mixers.rst +++ b/docs/ext/mixers.rst @@ -29,6 +29,14 @@ Extension for controlling volume using an external Arcam amplifier. Developed and tested with an Arcam AVR-300. +Mopidy-dam1021 +============== + +https://github.com/fortaa/mopidy-dam1021 + +Extension for controlling volume using a dam1021 DAC device. + + Mopidy-NAD ========== 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 7355dbf9..bf29bf72 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -48,6 +48,22 @@ To install, run:: pip install Mopidy-Local-Images +Mopidy-Material-Webclient +========================= + +https://github.com/matgallacher/mopidy-material-webclient + +A Mopidy web client with an Android Material design feel. + +.. image:: /ext/material_webclient.png + :width: 960 + :height: 520 + +To install, run:: + + pip install Mopidy-Material-Webclient + + Mopidy-Mobile ============= @@ -143,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..340a18da 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 to 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 e9775030..9085024a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -54,6 +54,8 @@ extension. The cassettes have NFC tags used to select playlists from Spotify. To get started with Mopidy, start by reading :ref:`installation`. +.. _getting-help: + **Getting help** If you get stuck, you can get help at the `Mopidy discussion forum @@ -94,6 +96,7 @@ Extensions :maxdepth: 2 ext/local + ext/file ext/m3u ext/stream ext/http diff --git a/docs/installation/arch.rst b/docs/installation/arch.rst index f8492fdf..c5675403 100644 --- a/docs/installation/arch.rst +++ b/docs/installation/arch.rst @@ -1,20 +1,19 @@ .. _arch-install: -**************************** -Arch Linux: Install from AUR -**************************** +********************************** +Arch Linux: Install from community +********************************** If you are running Arch Linux, you can install Mopidy using the -`mopidy `_ package found in AUR. +`mopidy `_ package found in ``community``. -#. To install Mopidy with all dependencies, you can use - for example `yaourt `_:: +#. To install Mopidy with all dependencies, you can use:: - yaourt -S mopidy + pacman -S mopidy To upgrade Mopidy to future releases, just upgrade your system using:: - yaourt -Syua + pacman -Syu #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. @@ -24,7 +23,7 @@ Installing extensions ===================== If you want to use any Mopidy extensions, like Spotify support or Last.fm -scrobbling, AUR also has `packages for lots of Mopidy extensions +scrobbling, AUR has `packages for lots of Mopidy extensions `_. You can also install any Mopidy extension directly from PyPI with ``pip``. To diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index 4def3fbb..8cf08bca 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -18,12 +18,12 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See The packages should work with: - - Debian stable and testing, - - Raspbian stable and testing, + - Debian stable ("jessie") and testing ("stretch"), + - Raspbian stable ("jessie") and testing ("stretch"), - Ubuntu 14.04 LTS and later. - Some of the packages, including the core "mopidy" packages, does *not* work - on Ubuntu 12.04 LTS. + Some of the packages *do not* work with Ubuntu 12.04 LTS or Debian 7 + "wheezy". This is just what we currently support, not a promise to continue to support the same in the future. We *will* drop support for older @@ -47,6 +47,13 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See sudo wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/mopidy.list + .. note:: + + If you're still running Debian 7 "wheezy" or Raspbian "wheezy", you + should edit :file:`/etc/apt/sources.list.d/mopidy.list` and replace + "stable" with "wheezy". This will give you the latest set of packages + that is compatible with Debian "wheezy". + #. Install Mopidy and all dependencies:: sudo apt-get update diff --git a/docs/installation/osx.rst b/docs/installation/osx.rst index 71beece3..e9ce16e3 100644 --- a/docs/installation/osx.rst +++ b/docs/installation/osx.rst @@ -18,7 +18,7 @@ If you are running OS X, you can install everything needed with Homebrew. date before you continue:: brew update - brew upgrade + brew upgrade --all Notice that this will upgrade all software on your system that have been installed with Homebrew. 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/installation/source.rst b/docs/installation/source.rst index c2018984..204cc1df 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -5,7 +5,7 @@ Install from source ******************* If you are on Linux, but can't install :ref:`from the APT archive -` or :ref:`from AUR `, you can install Mopidy +` or :ref:`from the Arch Linux repository `, you can install Mopidy from PyPI using the ``pip`` installer. If you are looking to contribute or wish to install from source using ``git`` 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 af37d481..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 =============== @@ -33,8 +47,8 @@ Init scripts `_. For more details, see the :ref:`debian` section of the docs. -- The ``mopidy`` package in `Arch Linux AUR - `__ comes with a systemd init +- The ``mopidy`` package in `Arch Linux + `__ comes with a systemd init script. - A blog post by Benjamin Guillet explains how to `Daemonize Mopidy and Launch 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 388bb9f0..4752f080 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.0' +__version__ = '1.0.8' diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 96e10e18..245a03ce 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: diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index a1e1e119..72750bdf 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -8,7 +8,7 @@ import gobject import pygst pygst.require('0.10') import gst # noqa -import gst.pbutils +import gst.pbutils # noqa import pykka @@ -16,7 +16,7 @@ from mopidy import exceptions from mopidy.audio import playlists, utils from mopidy.audio.constants import PlaybackState from mopidy.audio.listener import AudioListener -from mopidy.utils import deprecation, process +from mopidy.internal import deprecation, process logger = logging.getLogger(__name__) @@ -166,10 +166,7 @@ class _Outputs(gst.Bin): logger.info('Audio output set to "%s"', description) def _add(self, element): - # All tee branches need a queue in front of them. - # But keep the queue short so the volume change isn't to slow: queue = gst.element_factory_make('queue') - queue.set_property('max-size-buffers', 5) self.add(element) self.add(queue) queue.link(element) @@ -199,16 +196,14 @@ class SoftwareMixer(object): def set_volume(self, volume): self._element.set_property('volume', volume / 100.0) - self._mixer.trigger_volume_changed(volume) + self._mixer.trigger_volume_changed(self.get_volume()) def get_mute(self): return self._element.get_property('mute') def set_mute(self, mute): - result = self._element.set_property('mute', bool(mute)) - if result: - self._mixer.trigger_mute_changed(bool(mute)) - return result + self._element.set_property('mute', bool(mute)) + self._mixer.trigger_mute_changed(self.get_mute()) class _Handler(object): diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 384b4197..cf370052 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,20 +1,21 @@ -from __future__ import absolute_import, division, unicode_literals +from __future__ import ( + absolute_import, division, print_function, unicode_literals) import collections import pygst pygst.require('0.10') import gst # noqa -import gst.pbutils +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 _Result = collections.namedtuple( - 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime')) + 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime', 'playable')) _RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') @@ -52,14 +53,14 @@ class Scanner(object): try: _start_pipeline(pipeline) - tags, mime = _process(pipeline, self._timeout_ms) + tags, mime, have_audio = _process(pipeline, self._timeout_ms) duration = _query_duration(pipeline) seekable = _query_seekable(pipeline) finally: pipeline.set_state(gst.STATE_NULL) del pipeline - return _Result(uri, tags, duration, seekable, mime) + return _Result(uri, tags, duration, seekable, mime, have_audio) # Turns out it's _much_ faster to just create a new pipeline for every as @@ -71,31 +72,39 @@ def _setup_pipeline(uri, proxy_config=None): typefind = gst.element_factory_make('typefind') decodebin = gst.element_factory_make('decodebin2') - sink = gst.element_factory_make('fakesink') pipeline = gst.element_factory_make('pipeline') - for e in (src, typefind, decodebin, sink): + for e in (src, typefind, decodebin): pipeline.add(e) gst.element_link_many(src, typefind, decodebin) if proxy_config: utils.setup_proxy(src, proxy_config) - decodebin.set_property('caps', _RAW_AUDIO) - decodebin.connect('pad-added', _pad_added, sink) typefind.connect('have-type', _have_type, decodebin) + decodebin.connect('pad-added', _pad_added, pipeline) return pipeline def _have_type(element, probability, caps, decodebin): decodebin.set_property('sink-caps', caps) - msg = gst.message_new_application(element, caps.get_structure(0)) - element.get_bus().post(msg) + struct = gst.Structure('have-type') + struct['caps'] = caps.get_structure(0) + element.get_bus().post(gst.message_new_application(element, struct)) -def _pad_added(element, pad, sink): - return pad.link(sink.get_pad('sink')) +def _pad_added(element, pad, pipeline): + sink = gst.element_factory_make('fakesink') + sink.set_property('sync', False) + + pipeline.add(sink) + sink.sync_state_with_parent() + pad.link(sink.get_pad('sink')) + + if pad.get_caps().is_subset(_RAW_AUDIO): + struct = gst.Structure('have-audio') + element.get_bus().post(gst.message_new_application(element, struct)) def _start_pipeline(pipeline): @@ -125,7 +134,7 @@ def _process(pipeline, timeout_ms): clock = pipeline.get_clock() bus = pipeline.get_bus() timeout = timeout_ms * gst.MSECOND - tags, mime, missing_description = {}, None, None + tags, mime, have_audio, missing_description = {}, None, False, None types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR | gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG) @@ -141,19 +150,22 @@ def _process(pipeline, timeout_ms): missing_description = encoding.locale_decode( _missing_plugin_desc(message)) elif message.type == gst.MESSAGE_APPLICATION: - mime = message.structure.get_name() - if mime.startswith('text/') or mime == 'application/xml': - return tags, mime + if message.structure.get_name() == 'have-type': + mime = message.structure['caps'].get_name() + if mime.startswith('text/') or mime == 'application/xml': + return tags, mime, have_audio + elif message.structure.get_name() == 'have-audio': + have_audio = True elif message.type == gst.MESSAGE_ERROR: error = encoding.locale_decode(message.parse_error()[0]) if missing_description: error = '%s (%s)' % (missing_description, error) raise exceptions.ScannerError(error) elif message.type == gst.MESSAGE_EOS: - return tags, mime + return tags, mime, have_audio elif message.type == gst.MESSAGE_ASYNC_DONE: if message.src == pipeline: - return tags, mime + return tags, mime, have_audio elif message.type == gst.MESSAGE_TAG: taglist = message.parse_tag() # Note that this will only keep the last tag. @@ -162,3 +174,28 @@ def _process(pipeline, timeout_ms): timeout -= clock.get_time() - start raise exceptions.ScannerError('Timeout after %dms' % timeout_ms) + + +if __name__ == '__main__': + import os + import sys + + import gobject + + from mopidy.internal import path + + gobject.threads_init() + + scanner = Scanner(5000) + for uri in sys.argv[1:]: + if not gst.uri_is_valid(uri): + uri = path.path_to_uri(os.path.abspath(uri)) + try: + result = scanner.scan(uri) + for key in ('uri', 'mime', 'duration', 'playable', 'seekable'): + print('%-20s %s' % (key, getattr(result, key))) + print('tags') + for tag, value in result.tags.items(): + print('%-20s %s' % (tag, value)) + except exceptions.ScannerError as error: + print('%s: %s' % (uri, error)) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 1a8bf6a7..3b9ea30f 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__) @@ -142,11 +142,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 fe8676ca..fd91044f 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -58,6 +58,10 @@ 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): @@ -99,6 +103,9 @@ class LibraryProvider(object): *MAY be implemented by subclass.* Default implementation will simply return an empty set. + + Note that backends should always return an empty set for unexpected + field types. """ return set() @@ -400,7 +407,7 @@ class BackendListener(listener.Listener): Marker interface for recipients of events sent by the backend actors. Any Pykka actor that mixes in this class will receive calls to the methods - defined here when the corresponding events happen in the core actor. This + defined here when the corresponding events happen in a backend actor. This interface is used both for looking up what actors to notify of the events, and for providing default implementations for those listeners that are not interested in all events. diff --git a/mopidy/commands.py b/mopidy/commands.py index ca7c519c..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__) @@ -230,7 +233,24 @@ class Command(object): raise NotImplementedError -# TODO: move out of this file +@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): @@ -277,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, @@ -323,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'] @@ -353,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( @@ -376,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') @@ -415,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 fd914994..13a26412 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -11,10 +11,14 @@ 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') +# 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 +47,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,24 +70,20 @@ 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) diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 42edbbbd..c214de68 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -1,3 +1,6 @@ +[core] +max_tracklist_length = 10000 + [logging] color = true console_format = %(levelname)-8s %(message)s diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 8359766f..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): diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index e83b6a34..8f976ebf 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) @@ -150,10 +150,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/history.py b/mopidy/core/history.py index f0d5e9d4..ae697e8e 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -11,6 +11,7 @@ logger = logging.getLogger(__name__) class HistoryController(object): + pykka_traversable = True def __init__(self): self._history = [] diff --git a/mopidy/core/library.py b/mopidy/core/library.py index c787e013..c300fbb9 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,18 +1,32 @@ from __future__ import absolute_import, unicode_literals import collections +import contextlib import logging import operator import urlparse -import pykka - -from mopidy.utils import deprecation +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 @@ -41,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``. @@ -70,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): """ @@ -88,19 +123,26 @@ class LibraryController(object): protocol supports in a more sane fashion. Other frontends are not recommended to use this method. - :param string field: One of ``artist``, ``albumartist``, ``album``, - ``composer``, ``performer``, ``date``or ``genre``. + :param string field: One of ``track``, ``artist``, ``albumartist``, + ``album``, ``composer``, ``performer``, ``date`` or ``genre``. :param dict query: Query to use for limiting results, see :meth:`search` for details about the query format. :rtype: set of values corresponding to the requested field type. .. 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): @@ -113,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): @@ -140,7 +193,7 @@ class LibraryController(object): 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. @@ -150,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. @@ -158,11 +211,11 @@ 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') @@ -171,23 +224,23 @@ class LibraryController(object): 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): """ @@ -196,14 +249,22 @@ 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): """ @@ -217,26 +278,27 @@ 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 @@ -253,6 +315,10 @@ class LibraryController(object): """ 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') @@ -264,20 +330,31 @@ class LibraryController(object): 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/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 108a7c04..81f51031 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -3,9 +3,10 @@ from __future__ import absolute_import, unicode_literals import logging import urlparse +from mopidy import models from mopidy.audio import PlaybackState from mopidy.core import listener -from mopidy.utils import deprecation +from mopidy.internal import deprecation, validation logger = logging.getLogger(__name__) @@ -64,9 +65,7 @@ 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 = deprecation.deprecated_property(get_current_track) """ @@ -74,6 +73,18 @@ class PlaybackController(object): 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 @@ -100,6 +111,8 @@ 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) @@ -265,17 +278,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() @@ -382,6 +415,13 @@ class PlaybackController(object): :rtype: :class:`True` if successful, else :class:`False` """ # TODO: seek needs to take pending tracks into account :( + 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 2c997d84..086806cc 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -1,17 +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 import deprecation +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 @@ -32,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) @@ -60,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): """ @@ -88,7 +117,7 @@ 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 [ @@ -116,16 +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: - backend = self.backends.with_playlists[uri_scheme] + backends = [self.backends.with_playlists[uri_scheme]] else: - # TODO: this fallback looks suspicious - backend = list(self.backends.with_playlists.values())[0] - playlist = backend.playlists.create(name).get() - listener.CoreListener.send('playlist_changed', playlist=playlist) - return playlist + backends = self.backends.with_playlists.values() + + for backend in backends: + 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): """ @@ -137,10 +173,18 @@ 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 + + with _backend_error_handling(backend): backend.playlists.delete(uri).get() + # TODO: emit playlist changed? + + # TODO: return value? def filter(self, criteria=None, **kwargs): """ @@ -150,15 +194,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 @@ -170,7 +211,10 @@ class PlaylistsController(object): 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 @@ -186,11 +230,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`. @@ -203,16 +254,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): """ @@ -236,11 +297,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 9a251b75..1938f001 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -1,13 +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 import deprecation +from mopidy.internal import deprecation, validation +from mopidy.models import TlTrack, Track logger = logging.getLogger(__name__) @@ -93,6 +92,7 @@ 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) @@ -121,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: @@ -157,7 +157,7 @@ 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) @@ -188,6 +188,7 @@ 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) @@ -200,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): """ @@ -223,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(): @@ -233,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 @@ -247,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 @@ -288,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 @@ -296,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 @@ -328,10 +407,14 @@ 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') - # TODO: assert that tracks are track instances + 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') @@ -349,8 +432,13 @@ class TracklistController(object): 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: @@ -388,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): @@ -443,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), \ @@ -469,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`` is no longer supported. """ - 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] @@ -491,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' @@ -519,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): @@ -535,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 32a2bd9a..4aa66e63 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -21,6 +21,13 @@ 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 @@ -44,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 f5f15058..ab35008a 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -11,6 +11,12 @@ from mopidy import config as config_lib, exceptions 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""" @@ -89,14 +95,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` @@ -155,55 +154,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 342108f8..a752a4f0 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -1,16 +1,18 @@ from __future__ import absolute_import, unicode_literals +import functools import logging import os import socket import tornado.escape +import tornado.ioloop import tornado.web 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__) @@ -65,6 +67,19 @@ def make_jsonrpc_wrapper(core_actor): ) +def _send_broadcast(client, msg): + # We could check for client.ws_connection, but we don't really + # care why the broadcast failed, we just want the rest of them + # to succeed, so catch everything. + try: + client.write_message(msg) + except Exception as e: + error_msg = encoding.locale_decode(e) + logger.debug('Broadcast of WebSocket message to %s failed: %s', + client.request.remote_ip, error_msg) + # TODO: should this do the same cleanup as the on_message code? + + class WebSocketHandler(tornado.websocket.WebSocketHandler): # XXX This set is shared by all WebSocketHandler objects. This isn't @@ -74,17 +89,17 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): @classmethod def broadcast(cls, msg): + if hasattr(tornado.ioloop.IOLoop, 'current'): + loop = tornado.ioloop.IOLoop.current() + else: + loop = tornado.ioloop.IOLoop.instance() # Fallback for pre 3.0 + + # This can be called from outside the Tornado ioloop, so we need to + # safely cross the thread boundary by adding a callback to the loop. for client in cls.clients: - # We could check for client.ws_connection, but we don't really - # care why the broadcast failed, we just want the rest of them - # to succeed, so catch everything. - try: - client.write_message(msg) - except Exception as e: - error_msg = encoding.locale_decode(e) - logger.debug('Broadcast of WebSocket message to %s failed: %s', - client.request.remote_ip, error_msg) - # TODO: should this do the same cleanup as the on_message code? + # One callback per client to keep time we hold up the loop short + # NOTE: Pre 3.0 does not support *args or **kwargs... + loop.add_callback(functools.partial(_send_broadcast, client, msg)) def initialize(self, core): self.jsonrpc = make_jsonrpc_wrapper(core) 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/utils/deprecation.py b/mopidy/internal/deprecation.py similarity index 71% rename from mopidy/utils/deprecation.py rename to mopidy/internal/deprecation.py index 57042347..7b1b915e 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/internal/deprecation.py @@ -30,6 +30,9 @@ _MESSAGES = { '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', @@ -40,11 +43,32 @@ _MESSAGES = { '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): - warnings.warn(_MESSAGES.get(msg_id, msg_id), DeprecationWarning) +def warn(msg_id, pending=False): + if pending: + category = PendingDeprecationWarning + else: + category = DeprecationWarning + warnings.warn(_MESSAGES.get(msg_id, msg_id), category) @contextlib.contextmanager 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 bc9f7c2f..1f363657 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/internal/deps.py @@ -5,13 +5,13 @@ import os import platform import sys +import pkg_resources + import pygst pygst.require('0.10') import gst # noqa -import pkg_resources - -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/utils/jsonrpc.py b/mopidy/internal/jsonrpc.py similarity index 100% rename from mopidy/utils/jsonrpc.py rename to mopidy/internal/jsonrpc.py diff --git a/mopidy/utils/log.py b/mopidy/internal/log.py similarity index 100% rename from mopidy/utils/log.py rename to mopidy/internal/log.py 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 000382e3..4b8b35fe 100644 --- a/mopidy/utils/network.py +++ b/mopidy/internal/network.py @@ -11,7 +11,7 @@ import gobject import pykka -from mopidy.utils import encoding +from mopidy.internal import encoding logger = logging.getLogger(__name__) diff --git a/mopidy/utils/path.py b/mopidy/internal/path.py similarity index 85% rename from mopidy/utils/path.py rename to mopidy/internal/path.py index e845cd95..f56520f0 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): @@ -206,23 +196,23 @@ def find_mtimes(root, follow=False): 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. diff --git a/mopidy/utils/process.py b/mopidy/internal/process.py similarity index 100% rename from mopidy/utils/process.py rename to mopidy/internal/process.py 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 410558ac..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)) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index d9320d4a..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__) @@ -136,12 +136,14 @@ class ScanCommand(commands.Command): file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) result = scanner.scan(file_uri) tags, duration = result.tags, result.duration - if duration < MIN_DURATION_MS: + if not result.playable: + logger.warning('Failed %s: No audio found in file.', uri) + elif duration < MIN_DURATION_MS: logger.warning('Failed %s: Track shorter than %dms', uri, MIN_DURATION_MS) else: mtime = file_mtimes.get(os.path.join(media_dir, relpath)) - track = utils.convert_tags_to_track(tags).copy( + 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) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 22fcfa5b..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__) @@ -141,7 +141,10 @@ class JsonLibrary(local.Library): return [] def get_distinct(self, field, query=None): - if field == 'artist': + if field == 'track': + def distinct(track): + return {track.name} + elif field == 'artist': def distinct(track): return {a.name for a in track.artists} elif field == 'albumartist': @@ -171,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/playback.py b/mopidy/local/playback.py index 24038426..a851239d 100644 --- a/mopidy/local/playback.py +++ b/mopidy/local/playback.py @@ -7,5 +7,5 @@ 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/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/playlists.py b/mopidy/m3u/playlists.py index c09eccdf..bd8b8bfd 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -51,10 +51,11 @@ 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] 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) @@ -66,7 +67,7 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): 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) + name = os.path.splitext(relpath)[0].decode(encoding, 'replace') tracks = translator.parse_m3u(path) playlists[uri] = Playlist(uri=uri, name=name, tracks=tracks) @@ -76,6 +77,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, \ @@ -88,11 +91,6 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): self._playlists[playlist.uri] = playlist return playlist - def _write_m3u_extinf(self, file_handle, track): - title = track.name.encode('latin-1', 'replace') - runtime = track.length // 1000 if track.length else -1 - file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n') - def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()): name = self._invalid_filename_chars.sub('|', name.strip()) # make sure we end up with a valid path segment @@ -113,15 +111,6 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): name, _ = os.path.splitext(os.path.basename(path).decode(encoding)) else: raise ValueError('M3U playlist needs name or URI') - extended = any(track.name for track in playlist.tracks) - - with open(path, 'w') as file_handle: - if extended: - file_handle.write('#EXTM3U\n') - for track in playlist.tracks: - if extended and track.name: - self._write_m3u_extinf(file_handle, track) - file_handle.write(track.uri + '\n') - + 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 4eefce9d..0b494ba3 100644 --- a/mopidy/m3u/translator.py +++ b/mopidy/m3u/translator.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +import codecs import logging import os import re @@ -7,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+),(.*)') @@ -20,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) @@ -80,7 +80,7 @@ def parse_m3u(file_path, media_dir=None): with open(file_path) 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: @@ -98,13 +98,28 @@ def parse_m3u(file_path, media_dir=None): 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 + + +def save_m3u(filename, tracks, encoding='latin1', errors='replace'): + extended = any(track.name for track in tracks) + # codecs.open() always uses binary mode, just being explicit here + with codecs.open(filename, 'wb', encoding, errors) as m3u: + if extended: + m3u.write('#EXTM3U' + os.linesep) + for track in tracks: + if extended and track.name: + m3u.write('#EXTINF:%d,%s%s' % ( + track.length // 1000 if track.length else -1, + track.name, + os.linesep)) + m3u.write(track.uri + os.linesep) diff --git a/mopidy/mixer.py b/mopidy/mixer.py index b25688fb..eb43d810 100644 --- a/mopidy/mixer.py +++ b/mopidy/mixer.py @@ -110,6 +110,10 @@ 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): 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 1ae26811..231a472a 100644 --- a/mopidy/models.py +++ b/mopidy/models/__init__.py @@ -1,151 +1,16 @@ 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. - - :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): +class Ref(ValidatedImmutableObject): """ Model to represent URI references with a human friendly name and type @@ -161,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' @@ -216,7 +82,7 @@ class Ref(ImmutableObject): return cls(**kwargs) -class Image(ImmutableObject): +class Image(ValidatedImmutableObject): """ :param string uri: URI of the image @@ -225,16 +91,16 @@ 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 @@ -246,16 +112,16 @@ class Artist(ImmutableObject): """ #: The artist URI. Read-only. - uri = None + uri = fields.URI() #: The artist name. Read-only. - name = None + name = 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 @@ -277,39 +143,34 @@ 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(ImmutableObject): +class Track(ValidatedImmutableObject): """ :param uri: track URI @@ -345,64 +206,55 @@ 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. @@ -425,10 +277,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: @@ -441,7 +293,7 @@ class TlTrack(ImmutableObject): return iter([self.tlid, self.track]) -class Playlist(ImmutableObject): +class Playlist(ValidatedImmutableObject): """ :param uri: playlist URI @@ -456,23 +308,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): ... ? @@ -482,7 +330,7 @@ class Playlist(ImmutableObject): return len(self.tracks) -class SearchResult(ImmutableObject): +class SearchResult(ValidatedImmutableObject): """ :param uri: search result URI @@ -496,19 +344,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 36775578..8eb59c1f 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -6,8 +6,8 @@ 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__) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 5abc1b4b..a8e2c05c 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -167,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) diff --git a/mopidy/mpd/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py index e6b88dbd..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 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 38ad4017..0d07452c 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -1,7 +1,9 @@ from __future__ import absolute_import, unicode_literals +import urlparse + +from mopidy.internal import deprecation from mopidy.mpd import exceptions, protocol, translator -from mopidy.utils import deprecation @protocol.commands.add('add') @@ -21,8 +23,11 @@ def add(context, uri): if not uri.strip('/'): return - if context.core.tracklist.add(uris=[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: uris = [] @@ -59,17 +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( 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() @@ -174,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() @@ -199,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') @@ -231,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): @@ -251,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 @@ -274,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. @@ -285,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) @@ -306,17 +310,65 @@ 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): +@protocol.commands.add( + 'prio', priority=protocol.UINT, position=protocol.RANGE) +def prio(context, priority, position): + """ + *musicpd.org, current playlist section:* + + ``prio {PRIORITY} {START:END...}`` + + Set the priority of the specified songs. A higher priority means that + it will be played first when "random" mode is enabled. + + A priority is an integer between 0 and 255. The default priority of new + songs is 0. + """ + raise exceptions.MpdNotImplemented # TODO + + +@protocol.commands.add('prioid') +def prioid(context, *args): + """ + *musicpd.org, current playlist section:* + + ``prioid {PRIORITY} {ID...}`` + + Same as prio, but address the songs with their id. + """ + raise exceptions.MpdNotImplemented # TODO + + +@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:* @@ -325,10 +377,10 @@ def shuffle(context, position=None): Shuffles the current playlist. ``START:END`` is optional and specifies a range of songs. """ - if position is None: + if songrange is None: start, end = None, None else: - start, end = position.start, position.stop + start, end = songrange.start, songrange.stop context.core.tracklist.shuffle(start, end) @@ -341,7 +393,7 @@ def swap(context, songpos1, songpos2): Swaps the positions of ``SONG1`` and ``SONG2``. """ - tracks = context.core.tracklist.tracks.get() + tracks = context.core.tracklist.get_tracks().get() song1 = tracks[songpos1] song2 = tracks[songpos2] del tracks[songpos1] @@ -365,8 +417,8 @@ def swapid(context, tlid1, tlid2): 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() + 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() @@ -374,39 +426,7 @@ def swapid(context, tlid1, tlid2): swap(context, position1, position2) -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add( -# 'prio', priority=protocol.UINT, position=protocol.RANGE) -def prio(context, priority, position): - """ - *musicpd.org, current playlist section:* - - ``prio {PRIORITY} {START:END...}`` - - Set the priority of the specified songs. A higher priority means that - it will be played first when "random" mode is enabled. - - A priority is an integer between 0 and 255. The default priority of new - songs is 0. - """ - pass - - -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('prioid') -def prioid(context, *args): - """ - *musicpd.org, current playlist section:* - - ``prioid {PRIORITY} {ID...}`` - - Same as prio, but address the songs with their id. - """ - pass - - -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('addtagid', tlid=protocol.UINT) +@protocol.commands.add('addtagid', tlid=protocol.UINT) def addtagid(context, tlid, tag, value): """ *musicpd.org, current playlist section:* @@ -417,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:* @@ -432,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 fc726255..00db0218 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -2,8 +2,8 @@ from __future__ import absolute_import, unicode_literals import functools import itertools -import warnings +from mopidy.internal import deprecation from mopidy.models import Track from mopidy.mpd import exceptions, protocol, translator @@ -23,6 +23,7 @@ _SEARCH_MAPPING = { 'track': 'track_no'} _LIST_MAPPING = { + 'title': 'track', 'album': 'album', 'albumartist': 'albumartist', 'artist': 'artist', @@ -32,6 +33,7 @@ _LIST_MAPPING = { 'performer': 'performer'} _LIST_NAME_MAPPING = { + 'track': 'Title', 'album': 'Album', 'albumartist': 'AlbumArtist', 'artist': 'Artist', @@ -94,7 +96,6 @@ def count(context, *args): *GMPC:* - - does not add quotes around the tag argument. - use multiple tag-needle pairs to make more specific searches. """ try: @@ -105,7 +106,7 @@ def count(context, *args): 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), ] @@ -123,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:* @@ -142,7 +141,8 @@ def find(context, *args): except ValueError: return - results = context.core.library.search(query=query, exact=True).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 @@ -172,10 +172,9 @@ def findadd(context, *args): results = context.core.library.search(query=query, exact=True).get() - with warnings.catch_warnings(): + 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. - warnings.filterwarnings('ignore', 'tracklist.add.*"tracks" argument.*') context.core.tracklist.add(tracks=_get_tracks(results)).get() @@ -253,27 +252,25 @@ 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) if not params: raise exceptions.MpdArgError('incorrect arguments') - field = params.pop(0).lower() - if field not in _LIST_MAPPING: + field = params.pop(0).lower() + field = _LIST_MAPPING.get(field) + 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) @@ -344,6 +341,30 @@ def listallinfo(context, uri=None): 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): """ @@ -400,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:: @@ -408,7 +428,6 @@ def search(context, *args): *ncmpc:* - - does not add quotes around the field argument. - capitalizes the field argument. *ncmpcpp:* @@ -420,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) @@ -447,10 +467,9 @@ def searchadd(context, *args): results = context.core.library.search(query).get() - with warnings.catch_warnings(): + 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. - warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') context.core.tracklist.add(_get_tracks(results)).get() @@ -484,7 +503,7 @@ def searchaddpl(context, *args): 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 6beb4277..333e1ccb 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -1,8 +1,8 @@ from __future__ import absolute_import, unicode_literals from mopidy.core import PlaybackState +from mopidy.internal import deprecation from mopidy.mpd import exceptions, protocol -from mopidy.utils import deprecation @protocol.commands.add('consume', state=protocol.BOOL) @@ -16,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) @@ -135,9 +135,10 @@ def pause(context, state=None): if state is None: 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() @@ -145,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:* @@ -170,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) @@ -217,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() @@ -279,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) @@ -291,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') @@ -324,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:* @@ -338,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() @@ -353,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() @@ -370,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: @@ -409,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..a3608a96 100644 --- a/mopidy/mpd/protocol/reflection.py +++ b/mopidy/mpd/protocol/reflection.py @@ -107,4 +107,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/session.py b/mopidy/mpd/session.py index adbf6cc3..68550f3b 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -2,8 +2,8 @@ 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__) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 8359f86b..d7ecb0f1 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +import datetime import re from mopidy.models import TlTrack @@ -37,13 +38,16 @@ def track_to_mpd_format(track, position=None, stream_title=None): # TODO: only show length if not none, see: # https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110 ('Time', track.length and (track.length // 1000) or 0), - ('Artist', artists_to_mpd_format(track.artists)), - ('Title', track.name or ''), + ('Artist', concat_multi_values(track.artists, 'name')), ('Album', track.album and track.album.name or ''), ] - if stream_title: - result.append(('Name', stream_title)) + if stream_title is not None: + result.append(('Title', stream_title)) + if track.name: + result.append(('Name', track.name)) + else: + result.append(('Title', track.name or '')) if track.date: result.append(('Date', track.date)) @@ -58,26 +62,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)) @@ -85,22 +90,34 @@ 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)) return result -def artists_to_mpd_format(artists): +def concat_multi_values(models, attribute): """ - Format track artists for output to MPD client. + Format Mopidy model values for output to MPD client. - :param artists: the artists - :type track: array of :class:`mopidy.models.Artist` + :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/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py index 37e4b783..9e7ec2dd 100644 --- a/mopidy/mpd/uri_mapper.py +++ b/mopidy/mpd/uri_mapper.py @@ -2,6 +2,9 @@ from __future__ import absolute_import, unicode_literals import re +# TOOD: refactor this into a generic mapper that does not know about browse +# or playlists and then use one instance for each case? + class MpdUriMapper(object): @@ -18,7 +21,8 @@ class MpdUriMapper(object): def __init__(self, core=None): self.core = core self._uri_from_name = {} - self._name_from_uri = {} + self._browse_name_from_uri = {} + self._playlist_name_from_uri = {} def _create_unique_name(self, name, uri): stripped_name = self._invalid_browse_chars.sub(' ', name) @@ -31,33 +35,37 @@ class MpdUriMapper(object): i += 1 return name - def insert(self, name, uri): + def insert(self, name, uri, playlist=False): """ Create a unique and MPD compatible name that maps to the given URI. """ name = self._create_unique_name(name, uri) self._uri_from_name[name] = uri - self._name_from_uri[uri] = name + if playlist: + self._playlist_name_from_uri[uri] = name + else: + self._browse_name_from_uri[uri] = name return name def uri_from_name(self, name): """ Return the uri for the given MPD name. """ - if name in self._uri_from_name: - return self._uri_from_name[name] + return self._uri_from_name.get(name) def refresh_playlists_mapping(self): """ Maintain map between playlists and unique playlist names to be used by MPD. """ - if self.core is not None: - for playlist_ref in self.core.playlists.as_list().get(): - if not playlist_ref.name: - continue - name = self._invalid_playlist_chars.sub('|', playlist_ref.name) - self.insert(name, playlist_ref.uri) + if self.core is None: + return + + for playlist_ref in self.core.playlists.as_list().get(): + if not playlist_ref.name: + continue + name = self._invalid_playlist_chars.sub('|', playlist_ref.name) + self.insert(name, playlist_ref.uri, playlist=True) def playlist_uri_from_name(self, name): """ @@ -71,6 +79,6 @@ class MpdUriMapper(object): """ Helper function to retrieve the unique MPD playlist name from its URI. """ - if uri not in self._name_from_uri: + if uri not in self._playlist_name_from_uri: self.refresh_playlists_mapping() - return self._name_from_uri[uri] + return self._playlist_name_from_uri[uri] diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 81e07b6d..4b81f60e 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -48,7 +48,7 @@ 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) 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/setup.py b/setup.py index 9f33236f..ca121f74 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,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/audio/test_actor.py b/tests/audio/test_actor.py index 4a442481..732e514c 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -6,17 +6,17 @@ import unittest import gobject gobject.threads_init() +import mock + import pygst pygst.require('0.10') import gst # noqa -import mock - import pykka from mopidy import audio, listener 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 diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 1a4fec7e..c558835e 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -8,7 +8,7 @@ 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 @@ -17,8 +17,7 @@ class ScannerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.errors = {} - self.tags = {} - self.durations = {} + self.result = {} def find(self, path): media_dir = path_to_data_dir(path) @@ -32,15 +31,13 @@ class ScannerTest(unittest.TestCase): uri = path_lib.path_to_uri(path) key = uri[len('file://'):] try: - result = scanner.scan(uri) - self.tags[key] = result.tags - self.durations[key] = result.duration + self.result[key] = scanner.scan(uri) except exceptions.ScannerError as error: self.errors[key] = error def check(self, name, key, value): name = path_to_data_dir(name) - self.assertEqual(self.tags[name][key], value) + 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()]): @@ -48,7 +45,7 @@ class ScannerTest(unittest.TestCase): def test_tags_is_set(self): self.scan(self.find('scanner/simple')) - self.assert_(self.tags) + self.assert_(self.result.values()[0].tags) def test_errors_is_not_set(self): self.scan(self.find('scanner/simple')) @@ -62,10 +59,10 @@ class ScannerTest(unittest.TestCase): self.check_if_missing_plugin() - self.assertEqual( - self.durations[path_to_data_dir('scanner/simple/song1.mp3')], 4680) - self.assertEqual( - self.durations[path_to_data_dir('scanner/simple/song1.ogg')], 4680) + 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) + self.assertEqual(self.result[ogg].duration, 4680) def test_artist_is_set(self): self.scan(self.find('scanner/simple')) @@ -97,20 +94,20 @@ class ScannerTest(unittest.TestCase): def test_other_media_is_ignored(self): self.scan(self.find('scanner/image')) - self.assert_(self.errors) + self.assertFalse(self.result.values()[0].playable) def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self): self.scan([path_to_data_dir('scanner/example.log')]) self.check_if_missing_plugin() - self.assertLess( - self.durations[path_to_data_dir('scanner/example.log')], 100) + log = path_to_data_dir('scanner/example.log') + self.assertLess(self.result[log].duration, 100) def test_empty_wav_file(self): self.scan([path_to_data_dir('scanner/empty.wav')]) - self.assertEqual( - self.durations[path_to_data_dir('scanner/empty.wav')], 0) + wav = path_to_data_dir('scanner/empty.wav') + self.assertEqual(self.result[wav].duration, 0) @unittest.SkipTest def test_song_without_time_is_handeled(self): diff --git a/tests/audio/test_utils.py b/tests/audio/test_utils.py index a49ead90..200d7729 100644 --- a/tests/audio/test_utils.py +++ b/tests/audio/test_utils.py @@ -59,7 +59,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) @@ -67,7 +67,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) @@ -75,15 +75,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') @@ -91,7 +91,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) @@ -99,15 +99,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)) @@ -115,25 +115,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') @@ -141,25 +141,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') @@ -167,9 +167,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') @@ -177,8 +177,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) @@ -186,8 +186,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) @@ -195,21 +195,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') @@ -218,30 +218,30 @@ 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')) diff --git a/tests/config/test_types.py b/tests/config/test_types.py index be1ab829..40226c51 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -373,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' diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index 520c5026..410933d2 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -7,7 +7,7 @@ import mock import pykka from mopidy.core import Core -from mopidy.utils import versioning +from mopidy.internal import versioning class CoreActorTest(unittest.TestCase): diff --git a/tests/core/test_events.py b/tests/core/test_events.py index e916b670..9a439084 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -7,8 +7,8 @@ import mock import pykka from mopidy import core +from mopidy.internal import deprecation from mopidy.models import Track -from mopidy.utils import deprecation from tests import dummy_backend @@ -17,12 +17,19 @@ from tests import dummy_backend class BackendEventsTest(unittest.TestCase): def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + self.backend = dummy_backend.create_proxy() self.backend.library.dummy_library = [ Track(uri='dummy:a'), Track(uri='dummy:b')] with deprecation.ignore(): - self.core = core.Core.start(backends=[self.backend]).proxy() + self.core = core.Core.start( + config, backends=[self.backend]).proxy() def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() @@ -45,15 +52,12 @@ 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(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(uris=['dummy:a']).get() - send.reset_mock() self.core.tracklist.clear().get() @@ -61,7 +65,6 @@ class BackendEventsTest(unittest.TestCase): def test_tracklist_move_sends_tracklist_changed_event(self, send): self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() - send.reset_mock() self.core.tracklist.move(0, 1, 1).get() @@ -69,37 +72,29 @@ class BackendEventsTest(unittest.TestCase): def test_tracklist_remove_sends_tracklist_changed_event(self, send): self.core.tracklist.add(uris=['dummy:a']).get() - send.reset_mock() - 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(uris=['dummy:a', 'dummy:b']).get() - send.reset_mock() 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') @@ -111,8 +106,7 @@ class BackendEventsTest(unittest.TestCase): 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_library.py b/tests/core/test_library.py index 8d2195a2..941f1831 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -5,8 +5,8 @@ import unittest import mock from mopidy import backend, core +from mopidy.internal import deprecation from mopidy.models import Image, Ref, SearchResult, Track -from mopidy.utils import deprecation class BaseCoreLibraryTest(unittest.TestCase): @@ -15,24 +15,25 @@ class BaseCoreLibraryTest(unittest.TestCase): 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 @@ -65,20 +66,17 @@ class CoreLibraryTest(BaseCoreLibraryTest): 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']) @@ -106,11 +104,10 @@ class CoreLibraryTest(BaseCoreLibraryTest): 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') @@ -119,11 +116,10 @@ class CoreLibraryTest(BaseCoreLibraryTest): 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') @@ -139,11 +135,10 @@ class CoreLibraryTest(BaseCoreLibraryTest): 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, [ @@ -156,11 +151,14 @@ class CoreLibraryTest(BaseCoreLibraryTest): 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]}) + 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']) @@ -190,8 +188,8 @@ class CoreLibraryTest(BaseCoreLibraryTest): 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_twice_with(None) + 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') @@ -199,10 +197,8 @@ class CoreLibraryTest(BaseCoreLibraryTest): 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() + 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']}) @@ -234,10 +230,8 @@ class CoreLibraryTest(BaseCoreLibraryTest): 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() + 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']}) @@ -254,10 +248,8 @@ class CoreLibraryTest(BaseCoreLibraryTest): 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() + 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']}) @@ -363,12 +355,14 @@ class DeprecatedLookupCoreLibraryTest(BaseCoreLibraryTest): 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) @@ -419,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_mixer.py b/tests/core/test_mixer.py index c4ef7fe9..45241fec 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -23,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) @@ -34,6 +35,7 @@ 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) @@ -92,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 1141c783..c324d9da 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -7,6 +7,7 @@ import mock import pykka from mopidy import backend, core +from mopidy.internal import deprecation from mopidy.models import Track from tests import dummy_audio @@ -23,10 +24,13 @@ class TestBackend(pykka.ThreadingActor, backend.Backend): class TestCurrentAndPendingTlTrack(unittest.TestCase): + config = {'core': {'max_tracklist_length': 10000}} + def setUp(self): # noqa: N802 self.audio = dummy_audio.DummyAudio.start().proxy() self.backend = TestBackend.start(config={}, audio=self.audio).proxy() - self.core = core.Core(audio=self.audio, backends=[self.backend]) + self.core = core.Core( + audio=self.audio, backends=[self.backend], config=self.config) self.playback = self.core.playback self.tracks = [Track(uri='dummy:a', length=1234), @@ -93,18 +97,22 @@ class TestCurrentAndPendingTlTrack(unittest.TestCase): 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 @@ -123,7 +131,7 @@ class CorePlaybackTest(unittest.TestCase): self.uris = [ 'dummy1:a', 'dummy2:a', 'dummy3:a', 'dummy1:b', 'dummy1:c'] - self.core = core.Core(mixer=None, backends=[ + self.core = core.Core(config, mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) def lookup(uris): @@ -198,6 +206,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): @@ -598,6 +617,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 @@ -672,9 +697,15 @@ class CorePlaybackTest(unittest.TestCase): class TestStream(unittest.TestCase): def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } self.audio = dummy_audio.DummyAudio.start().proxy() self.backend = TestBackend.start(config={}, audio=self.audio).proxy() - self.core = core.Core(audio=self.audio, backends=[self.backend]) + self.core = core.Core( + config, audio=self.audio, backends=[self.backend]) self.playback = self.core.playback self.tracks = [Track(uri='dummy:a', length=1234), @@ -756,6 +787,12 @@ 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.uri_schemes.get.return_value = ['dummy1'] b.playback = mock.Mock(spec=backend.PlaybackProvider) @@ -763,6 +800,61 @@ class CorePlaybackWithOldBackendTest(unittest.TestCase): b.library.lookup.return_value.get.return_value = [ Track(uri='dummy1:a', length=40000)] - c = core.Core(mixer=None, backends=[b]) + 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) + b.playback.change_track.return_value.get.return_value = True + b.playback.play.return_value.get.return_value = True + + track1 = Track(uri='dummy:a', length=40000) + track2 = Track(uri='dummy:b', length=40000) + + c = core.Core(config, mixer=None, backends=[b]) + c.tracklist.add([track1, track2]) + + c.playback.play() + b.playback.change_track.assert_called_once_with(track1) + b.playback.change_track.reset_mock() + + c.playback.pause() + c.playback.next() + b.playback.change_track.assert_called_once_with(track2) diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 4ca3d6df..029254a8 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -5,8 +5,8 @@ import unittest import mock from mopidy import backend, core +from mopidy.internal import deprecation from mopidy.models import Playlist, Ref, Track -from mopidy.utils import deprecation class BasePlaylistsTest(unittest.TestCase): @@ -90,8 +90,7 @@ class PlaylistTest(BasePlaylistsTest): 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') @@ -99,10 +98,31 @@ class PlaylistTest(BasePlaylistsTest): self.sp1.create.assert_called_once_with('foo') self.assertFalse(self.sp2.create.called) + def test_create_without_uri_scheme_ignores_none_result(self): + playlist = Playlist() + self.sp1.create.return_value.get.return_value = None + self.sp2.create.return_value.get.return_value = playlist + + result = self.core.playlists.create('foo') + + self.assertEqual(playlist, result) + self.sp1.create.assert_called_once_with('foo') + self.sp2.create.assert_called_once_with('foo') + + def test_create_without_uri_scheme_ignores_exception(self): + playlist = Playlist() + self.sp1.create.return_value.get.side_effect = Exception + self.sp2.create.return_value.get.return_value = playlist + + result = self.core.playlists.create('foo') + + self.assertEqual(playlist, result) + self.sp1.create.assert_called_once_with('foo') + self.sp2.create.assert_called_once_with('foo') + 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') @@ -112,8 +132,7 @@ class PlaylistTest(BasePlaylistsTest): 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,8 +209,7 @@ class PlaylistTest(BasePlaylistsTest): 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) @@ -201,8 +219,7 @@ class PlaylistTest(BasePlaylistsTest): 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) @@ -240,7 +257,7 @@ class DeprecatedFilterPlaylistsTest(BasePlaylistsTest): return super(DeprecatedFilterPlaylistsTest, self).run(result) def test_filter_returns_matching_playlists(self): - result = self.core.playlists.filter(name='A') + result = self.core.playlists.filter({'name': 'A'}) self.assertEqual(2, len(result)) @@ -279,3 +296,141 @@ class DeprecatedGetPlaylistsTest(BasePlaylistsTest): 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 24a9ef0f..24edb2e7 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -5,13 +5,19 @@ import unittest import mock from mopidy import backend, core -from mopidy.models import Track -from mopidy.utils import deprecation +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'), @@ -29,7 +35,7 @@ class TracklistTest(unittest.TestCase): self.library.lookup.side_effect = lookup self.backend.library = self.library - self.core = core.Core(mixer=None, backends=[self.backend]) + 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]) @@ -64,7 +70,7 @@ class TracklistTest(unittest.TestCase): 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) @@ -82,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) @@ -95,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/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/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 3ad1df6b..8ae7d15c 100644 --- a/tests/utils/network/test_connection.py +++ b/tests/internal/network/test_connection.py @@ -11,7 +11,7 @@ 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 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 d3548117..586d180e 100644 --- a/tests/utils/network/test_lineprotocol.py +++ b/tests/internal/network/test_lineprotocol.py @@ -8,7 +8,7 @@ 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 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 5ea64fca..af8effd2 100644 --- a/tests/utils/network/test_server.py +++ b/tests/internal/network/test_server.py @@ -8,7 +8,7 @@ import gobject from mock import Mock, patch, sentinel -from mopidy.utils import network +from mopidy.internal import network from tests import any_int 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 55d68a99..a769ff93 100644 --- a/tests/utils/network/test_utils.py +++ b/tests/internal/network/test_utils.py @@ -5,18 +5,18 @@ 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') @@ -43,14 +43,14 @@ 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 0639d296..27e6f629 100644 --- a/tests/utils/test_deps.py +++ b/tests/internal/test_deps.py @@ -6,13 +6,13 @@ import unittest import mock +import pkg_resources + import pygst pygst.require('0.10') import gst # noqa -import pkg_resources - -from mopidy.utils import deps +from mopidy.internal import deps class DepsTest(unittest.TestCase): 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 2ec7e529..cc8987ce 100644 --- a/tests/utils/test_encoding.py +++ b/tests/internal/test_encoding.py @@ -4,16 +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) @@ -22,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( @@ -33,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 160afc4d..b2103caa 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/internal/test_jsonrpc.py @@ -8,7 +8,7 @@ import mock import pykka from mopidy import core, models -from mopidy.utils import deprecation, jsonrpc +from mopidy.internal import deprecation, jsonrpc from tests import dummy_backend diff --git a/tests/utils/test_path.py b/tests/internal/test_path.py similarity index 99% rename from tests/utils/test_path.py rename to tests/internal/test_path.py index 1acd7271..503d2490 100644 --- a/tests/utils/test_path.py +++ b/tests/internal/test_path.py @@ -10,7 +10,7 @@ import unittest import glib from mopidy import compat, exceptions -from mopidy.utils import path +from mopidy.internal import path import tests 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 3841a1e4..7f3cfb33 100644 --- a/tests/local/__init__.py +++ b/tests/local/__init__.py @@ -1,6 +1,6 @@ from __future__ import absolute_import, unicode_literals -from mopidy.utils import deprecation +from mopidy.internal import deprecation def generate_song(i): diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 0198ec9e..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 @@ -137,8 +137,8 @@ class LocalLibraryProviderTest(unittest.TestCase): self.assertEqual(result[uri], self.tracks[0:1]) def test_lookup_unknown_track(self): - tracks = self.library.lookup(uris=['fake uri']) - self.assertEqual(tracks, {'fake uri': []}) + tracks = self.library.lookup(uris=['fake:/uri']) + self.assertEqual(tracks, {'fake:/uri': []}) # test backward compatibility with local libraries returning a # single Track @@ -343,42 +343,44 @@ class LocalLibraryProviderTest(unittest.TestCase): 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): + with self.assertRaises(exceptions.ValidationError): self.find_exact(wrong=['test']) def test_find_exact_with_empty_query(self): - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(artist=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(albumartist=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(track_name=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(composer=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(performer=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(album=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(track_no=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(genre=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(date=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(comment=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.find_exact(any=['']) def test_search_no_hits(self): @@ -553,41 +555,41 @@ class LocalLibraryProviderTest(unittest.TestCase): self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(wrong=['test']) def test_search_with_empty_query(self): - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(artist=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(albumartist=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(composer=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(performer=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(track_name=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(album=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(genre=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(date=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(comment=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(uri=['']) - with self.assertRaises(LookupError): + with self.assertRaises(exceptions.ValidationError): self.search(any=['']) def test_default_get_images_impl_no_images(self): diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 131af2ca..e2e2a1ee 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -10,9 +10,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.utils import deprecation +from mopidy.models import TlTrack, Track from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist @@ -24,6 +24,9 @@ logger = logging.getLogger(__name__) class LocalPlaybackProviderTest(unittest.TestCase): config = { + 'core': { + 'max_tracklist_length': 10000, + }, 'local': { 'media_dir': path_to_data_dir(''), 'data_dir': path_to_data_dir(''), @@ -59,7 +62,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() - self.core = core.Core(backends=[self.backend], audio=self.audio) + self.core = core.Core( + backends=[self.backend], config=self.config, audio=self.audio) self.playback = self.core.playback self.tracklist = self.core.tracklist @@ -870,22 +874,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() @@ -1129,4 +1117,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_tracklist.py b/tests/local/test_tracklist.py index 22d4c954..72da3f13 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -7,9 +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.utils import deprecation +from mopidy.models import Playlist, Track from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist @@ -17,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(''), @@ -35,7 +38,7 @@ class LocalTracklistProviderTest(unittest.TestCase): 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 @@ -77,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): @@ -175,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) @@ -233,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): @@ -251,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 a294e6cf..f490887a 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -1,17 +1,20 @@ +# encoding: utf-8 + from __future__ import absolute_import, unicode_literals import os import shutil import tempfile import unittest +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 -from mopidy.utils import deprecation from tests import dummy_audio, path_to_data_dir from tests.m3u import generate_song @@ -70,7 +73,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)) @@ -93,7 +96,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: @@ -104,18 +107,38 @@ 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: - contents = f.read().splitlines() + m3u = f.read().splitlines() - self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri]) + self.assertEqual(['#EXTM3U', '#EXTINF:60,Test', track.uri], m3u) + + def test_latin1_playlist_contents_is_written_to_disk(self): + track = Track(uri=generate_song(1), name='Test\x9f', length=60000) + playlist = self.core.playlists.create('test') + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + + with open(path, 'rb') as f: + m3u = f.read().splitlines() + self.assertEqual([b'#EXTM3U', b'#EXTINF:60,Test\x9f', track.uri], m3u) + + def test_utf8_playlist_contents_is_replaced_and_written_to_disk(self): + track = Track(uri=generate_song(1), name='Test\u07b4', length=60000) + playlist = self.core.playlists.create('test') + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + + with open(path, 'rb') as f: + m3u = f.read().splitlines() + self.assertEqual([b'#EXTM3U', b'#EXTINF:60,Test?', track.uri], m3u) 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) @@ -124,9 +147,17 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertEqual(playlist.name, result.name) self.assertEqual(track.uri, result.tracks[0].uri) - @unittest.SkipTest - def test_santitising_of_playlist_filenames(self): - pass + def test_load_playlist_with_nonfilesystem_encoding_of_filename(self): + uri = 'm3u:%s.m3u' % urllib.quote('øæå'.encode('latin-1')) + path = playlist_uri_to_path(uri, self.playlists_dir) + with open(path, 'wb+') as f: + f.write(b'#EXTM3U\n') + + 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) @unittest.SkipTest def test_playlists_dir_is_created(self): @@ -191,7 +222,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)) @@ -199,7 +230,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') @@ -220,7 +251,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) @@ -244,7 +275,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']) @@ -256,7 +287,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) @@ -275,6 +306,7 @@ 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) diff --git a/tests/m3u/test_translator.py b/tests/m3u/test_translator.py index c84f12bf..cf0bf69f 100644 --- a/tests/m3u/test_translator.py +++ b/tests/m3u/test_translator.py @@ -6,9 +6,9 @@ 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 @@ -22,9 +22,9 @@ encoded_uri = path.path_to_uri(encoded_path) song1_track = Track(uri=song1_uri) song2_track = Track(uri=song2_uri) encoded_track = Track(uri=encoded_uri) -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 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 e9a8f439..5108411a 100644 --- a/tests/test_models.py +++ b/tests/models/test_models.py @@ -8,54 +8,101 @@ 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): @@ -74,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): @@ -187,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): @@ -359,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): @@ -603,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): @@ -794,9 +847,9 @@ 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)) @@ -838,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): @@ -921,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) @@ -933,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) @@ -946,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) @@ -959,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) @@ -971,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): @@ -1108,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 4b009407..754b4418 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -7,8 +7,8 @@ import mock import pykka from mopidy import core +from mopidy.internal import deprecation from mopidy.mpd import session, uri_mapper -from mopidy.utils import deprecation from tests import dummy_backend, dummy_mixer @@ -31,6 +31,9 @@ class BaseTestCase(unittest.TestCase): def get_config(self): return { + 'core': { + 'max_tracklist_length': 10000 + }, 'mpd': { 'password': None, } @@ -45,7 +48,9 @@ class BaseTestCase(unittest.TestCase): with deprecation.ignore(): self.core = core.Core.start( - mixer=self.mixer, backends=[self.backend]).proxy() + 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_connection.py b/tests/mpd/protocol/test_connection.py index 9c7edb4b..ae2212f6 100644 --- a/tests/mpd/protocol/test_connection.py +++ b/tests/mpd/protocol/test_connection.py @@ -10,7 +10,7 @@ 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') - close_mock.assertEqualResponsecalled_once_with() + close_mock.assert_called_once_with() self.assertEqualResponse('OK') def test_empty_request(self): diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index 6ec53adc..81bec5a4 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -1,7 +1,7 @@ from __future__ import absolute_import, unicode_literals +from mopidy.internal import deprecation from mopidy.models import Ref, Track -from mopidy.utils import deprecation from tests.mpd import protocol @@ -386,6 +386,24 @@ class PlChangeCommandTest(BasePopulatedTracklistTestCase): 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): @@ -441,3 +459,14 @@ class SwapCommandTest(BasePopulatedTracklistTestCase): 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_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 ca043d3c..5fe40e0d 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -79,6 +79,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')]) @@ -103,7 +113,7 @@ 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'), ]) @@ -285,6 +295,10 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('directory: /dummy/a') self.assertInResponse('directory: /dummy/a [2]') + def test_listfiles(self): + self.send_request('listfiles') + self.assertEqualResponse('ACK [0@0] {listfiles} Not implemented') + def test_lsinfo_without_path_returns_same_as_for_root(self): last_modified = 1390942873222 self.backend.playlists.set_dummy_playlists([ @@ -636,6 +650,12 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.send_request('list "foo"') self.assertEqualResponse('ACK [2@0] {list} incorrect arguments') + # Track title + + def test_list_title(self): + self.send_request('list "title"') + self.assertInResponse('OK') + # Artist def test_list_artist_with_quotes(self): diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 6121f540..b9adb646 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -3,8 +3,8 @@ 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 mopidy.utils import deprecation from tests.mpd import protocol diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index 7591d55c..ff0141f2 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import random -from mopidy.models import Track +from mopidy.models import Playlist, Ref, Track from tests.mpd import protocol @@ -197,3 +197,33 @@ class IssueGH137RegressionTest(protocol.BaseTestCase): u'Album "This Is Remixed Hits - Mashups & Rare 12" Mixes"') self.assertInResponse('ACK [2@0] {list} Invalid unquoted character') + + +class IssueGH1120RegressionTest(protocol.BaseTestCase): + """ + The issue: https://github.com/mopidy/mopidy/issues/1120 + + How to reproduce: + + - A playlist must be in both browse results and playlists + - Call for instance ``lsinfo "/"`` to populate the cache with the + playlist name from the playlist backend. + - Call ``lsinfo "/dummy"`` to override the playlist name with the browse + name. + - Call ``lsinfo "/"`` and we now have an invalid name with ``/`` in it. + + """ + + def test(self): + 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), + ]) + + response1 = self.send_request('lsinfo "/"') + self.send_request('lsinfo "/dummy"') + + response2 = self.send_request('lsinfo "/"') + self.assertEqual(response1, response2) diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index be2bf608..e5eec0f9 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -5,9 +5,9 @@ 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 -from mopidy.utils import deprecation from tests import dummy_backend diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 6f134df5..76fa9fcb 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -6,10 +6,10 @@ 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 -from mopidy.utils import deprecation from tests import dummy_backend, dummy_mixer @@ -25,12 +25,20 @@ 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() with deprecation.ignore(): self.core = core.Core.start( - mixer=self.mixer, backends=[self.backend]).proxy() + config, + mixer=self.mixer, + backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context @@ -76,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) @@ -87,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) @@ -103,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) diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index bf50687d..6a0220a8 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): @@ -20,18 +19,18 @@ class TrackMpdFormatTest(unittest.TestCase): 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: @@ -73,47 +72,79 @@ 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.assertNotIn(('Comment', 'a comment'), result) self.assertEqual(len(result), 14) + 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.assertIn(('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): diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py index 462136e4..3962159c 100644 --- a/tests/stream/test_library.py +++ b/tests/stream/test_library.py @@ -5,15 +5,15 @@ 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 mock - +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 @@ -23,7 +23,7 @@ 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')) + self.uri = path.path_to_uri(path_to_data_dir('song1.wav')) def test_lookup_ignores_unknown_scheme(self): library = actor.StreamLibraryProvider(self.backend, 1000, [], {}) @@ -34,7 +34,7 @@ class LibraryProviderTest(unittest.TestCase): self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) def test_lookup_respects_blacklist_globbing(self): - blacklist = [path_to_uri(path_to_data_dir('')) + '*'] + blacklist = [path.path_to_uri(path_to_data_dir('')) + '*'] library = actor.StreamLibraryProvider(self.backend, 100, blacklist, {}) self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) diff --git a/tests/test_ext.py b/tests/test_ext.py index c58f6b20..748aebb3 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -1,35 +1,223 @@ from __future__ import absolute_import, unicode_literals -import unittest +import mock -from mopidy import config, ext +import pkg_resources + +import pytest + +from mopidy import config, exceptions, ext + +from tests import IsA, any_unicode -class ExtensionTest(unittest.TestCase): +class TestExtension(ext.Extension): + dist_name = 'Mopidy-Foobar' + ext_name = 'foobar' + version = '1.2.3' - def setUp(self): # noqa: N802 - self.ext = ext.Extension() + def get_default_config(self): + return '[foobar]\nenabled = true' - def test_dist_name_is_none(self): - self.assertIsNone(self.ext.dist_name) - def test_ext_name_is_none(self): - self.assertIsNone(self.ext.ext_name) +any_testextension = IsA(TestExtension) - def test_version_is_none(self): - self.assertIsNone(self.ext.version) - def test_get_default_config_raises_not_implemented(self): - with self.assertRaises(NotImplementedError): - self.ext.get_default_config() +class ExtensionTest(object): - def test_get_config_schema_returns_extension_schema(self): - schema = self.ext.get_config_schema() - self.assertIsInstance(schema['enabled'], config.Boolean) + @pytest.fixture + def extension(self): + return ext.Extension() - def test_validate_environment_does_nothing_by_default(self): - self.assertIsNone(self.ext.validate_environment()) + def test_dist_name_is_none(self, extension): + assert extension.dist_name is None - def test_setup_raises_not_implemented(self): - with self.assertRaises(NotImplementedError): - self.ext.setup(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) + + +class LoadExtensionsTest(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 = TestExtension + + 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 = TestExtension() + + 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 = TestExtension + + iter_entry_points_mock.return_value = [mock_entry_point] + + with mock.patch.object(TestExtension, '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 = TestExtension + + iter_entry_points_mock.return_value = [mock_entry_point] + + with mock.patch.object(TestExtension, '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 = TestExtension + + iter_entry_points_mock.return_value = [mock_entry_point] + + with mock.patch.object(TestExtension, 'get_command') as get: + get.side_effect = Exception + + assert ext.load_extensions() == [] + get.assert_called_once_with() + + +class ValidateExtensionDataTest(object): + + @pytest.fixture + def ext_data(self): + extension = TestExtension() + + 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) 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_version.py b/tests/test_version.py index de4f8d4f..e914efc4 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -56,5 +56,13 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('0.19.2', '0.19.3') self.assertVersionLess('0.19.3', '0.19.4') self.assertVersionLess('0.19.4', '0.19.5') - self.assertVersionLess('0.19.5', __version__) - self.assertVersionLess(__version__, '1.0.1') + self.assertVersionLess('0.19.5', '1.0.0') + self.assertVersionLess('1.0.0', '1.0.1') + self.assertVersionLess('1.0.1', '1.0.2') + self.assertVersionLess('1.0.2', '1.0.3') + self.assertVersionLess('1.0.3', '1.0.4') + 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')