diff --git a/.gitignore b/.gitignore index 79230110..1ec12cbc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ node_modules/ nosetests.xml *~ *.orig +js/test/lib/ diff --git a/.mailmap b/.mailmap index e19cc5cc..a427c69c 100644 --- a/.mailmap +++ b/.mailmap @@ -10,3 +10,7 @@ Alexandre Petitjean Alexandre Petitjean Javier Domingo Cansino Lasse Bigum +Nick Steel +Janez Troha +Luke Giuliani +Colin Montgomerie diff --git a/AUTHORS b/AUTHORS index 8269452d..e51a1966 100644 --- a/AUTHORS +++ b/AUTHORS @@ -29,3 +29,9 @@ - Javier Domingo - Lasse Bigum - David Eisner +- PÃ¥l Ruud +- Thomas Kemmer +- Paul Connolley +- Luke Giuliani +- Colin Montgomerie +- Simon de Bakker diff --git a/MANIFEST.in b/MANIFEST.in index f1968205..51ba5919 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,13 +1,23 @@ +include *.py include *.rst +include .coveragerc +include .mailmap +include .travis.yml +include AUTHORS include LICENSE include MANIFEST.in -include data/mopidy.desktop + +recursive-include data * recursive-include docs * prune docs/_build +recursive-include js * +prune js/node_modules +prune js/test/lib + recursive-include mopidy *.conf -recursive-include mopidy/frontends/http/data * -recursive-include requirements * +recursive-include mopidy/http/data * + recursive-include tests *.py recursive-include tests/data * diff --git a/README.rst b/README.rst index 00886024..0b003815 100644 --- a/README.rst +++ b/README.rst @@ -26,11 +26,11 @@ To get started with Mopidy, check out `the docs `_. - Twitter: `@mopidy `_ .. image:: https://pypip.in/v/Mopidy/badge.png - :target: https://crate.io/packages/Mopidy/ + :target: https://pypi.python.org/pypi/Mopidy/ :alt: Latest PyPI version .. image:: https://pypip.in/d/Mopidy/badge.png - :target: https://crate.io/packages/Mopidy/ + :target: https://pypi.python.org/pypi/Mopidy/ :alt: Number of PyPI downloads .. image:: https://travis-ci.org/mopidy/mopidy.png?branch=develop @@ -40,7 +40,3 @@ To get started with Mopidy, check out `the docs `_. .. image:: https://coveralls.io/repos/mopidy/mopidy/badge.png?branch=develop :target: https://coveralls.io/r/mopidy/mopidy?branch=develop :alt: Test coverage - -.. image:: https://sourcegraph.com/api/repos/github.com/mopidy/mopidy/counters/views-24h.png - :target: https://sourcegraph.com/github.com/mopidy/mopidy - :alt: Mopidy stats diff --git a/data/mopidy.desktop b/data/mopidy.desktop index 88dd5ae4..91daeab3 100644 --- a/data/mopidy.desktop +++ b/data/mopidy.desktop @@ -1,8 +1,8 @@ [Desktop Entry] Type=Application Version=1.0 -Name=Mopidy Music Server -Comment=MPD music server with Spotify support +Name=Mopidy +Comment=Music server with support for MPD and HTTP clients Icon=audio-x-generic TryExec=mopidy Exec=mopidy diff --git a/docs/_static/woutervanwijk-mopidy-webclient.png b/docs/_static/woutervanwijk-mopidy-webclient.png deleted file mode 100644 index eee75168..00000000 Binary files a/docs/_static/woutervanwijk-mopidy-webclient.png and /dev/null differ diff --git a/docs/api/backends.rst b/docs/api/backends.rst index ec78f250..b1ba3128 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -4,46 +4,92 @@ Backend API *********** -.. module:: mopidy.backends.base +.. module:: mopidy.backend :synopsis: The API implemented by backends The backend API is the interface that must be implemented when you create a -backend. If you are working on a frontend and need to access the backend, see -the :ref:`core-api`. +backend. If you are working on a frontend and need to access the backends, see +the :ref:`core-api` instead. + + +URIs and routing of requests to the backend +=========================================== + +When Mopidy's core layer is processing a client request, it routes the request +to one or more appropriate backends based on the URIs of the objects the +request touches on. The objects' URIs are compared with the backends' +:attr:`~mopidy.backend.Backend.uri_schemes` to select the relevant backends. + +An often used pattern when implementing Mopidy backends is to create your own +URI scheme which you use for all tracks, playlists, etc. related to your +backend. In most cases the Mopidy URI is translated to an actual URI that +GStreamer knows how to play right before playback. For example: + +- Spotify already has its own URI scheme (``spotify:track:...``, + ``spotify:playlist:...``, etc.) used throughout their applications, and thus + Mopidy-Spotify simply uses the same URI scheme. Playback is handled by + pushing raw audio data into a GStreamer ``appsrc`` element. + +- Mopidy-SoundCloud created it's own URI scheme, after the model of Spotify, + and use URIs of the following forms: ``soundcloud:search``, + ``soundcloud:user-...``, ``soundcloud:exp-...``, and ``soundcloud:set-...``. + Playback is handled by converting the custom ``soundcloud:..`` URIs to + ``http://`` URIs immediately before they are passed on to GStreamer for + playback. + +- Mopidy differentiates between ``file://...`` URIs handled by + :ref:`ext-stream` and ``local:...`` URIs handled by :ref:`ext-local`. + :ref:`ext-stream` can play ``file://...`` URIs pointing to tracks and + playlists located anywhere on your system, but it doesn't know a thing about + the object before you play it. On the other hand, :ref:`ext-local` scans a + predefined :confval:`local/media_dir` to build a meta data library of all + known tracks. It is thus limited to playing tracks residing in the media + library, but can provide additional features like directory browsing and + search. In other words, we have two different ways of playing local music, + handled by two different backends, and have thus created two different URI + schemes to separate their handling. The ``local:...`` URIs are converted to + ``file://...`` URIs immediately before they are passed on to GStreamer for + playback. + +If there isn't an existing URI scheme that fits for your backend's purpose, +you should create your own, and name it after your extension's +:attr:`~mopidy.ext.Extension.ext_name`. Care should be taken not to conflict +with already in use URI schemes. It is also recommended to design the format +such that tracks, playlists and other entities can be distinguished easily. Backend class ============= -.. autoclass:: mopidy.backends.base.Backend +.. autoclass:: mopidy.backend.Backend :members: Playback provider ================= -.. autoclass:: mopidy.backends.base.BasePlaybackProvider +.. autoclass:: mopidy.backend.PlaybackProvider :members: Playlists provider ================== -.. autoclass:: mopidy.backends.base.BasePlaylistsProvider +.. autoclass:: mopidy.backend.PlaylistsProvider :members: Library provider ================ -.. autoclass:: mopidy.backends.base.BaseLibraryProvider +.. autoclass:: mopidy.backend.LibraryProvider :members: Backend listener ================ -.. autoclass:: mopidy.backends.listener.BackendListener +.. autoclass:: mopidy.backend.BackendListener :members: @@ -52,6 +98,22 @@ Backend listener Backend implementations ======================= -* :mod:`mopidy.backends.dummy` -* :mod:`mopidy.backends.local` -* :mod:`mopidy.backends.stream` +- `Mopidy-Beets `_ + +- `Mopidy-GMusic `_ + +- :ref:`ext-local` + +- `Mopidy-radio-de `_ + +- `Mopidy-SomaFM `_ + +- `Mopidy-SoundCloud `_ + +- `Mopidy-Spotify `_ + +- :ref:`ext-stream` + +- `Mopidy-Subsonic `_ + +- `Mopidy-VKontakte `_ diff --git a/docs/api/core.rst b/docs/api/core.rst index de85557c..38cc0f0a 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -7,10 +7,12 @@ 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.frontends.mpd`. The core layer is inbetween the frontends and the -backends. +:mod:`mopidy.http` and :mod:`mopidy.mpd`. The core layer is inbetween the +frontends and the backends. + +.. autoclass:: mopidy.core.Core + :members: Playback controller diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 70bd73cf..5e2f8d6c 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -47,5 +47,12 @@ The following requirements applies to any frontend implementation: Frontend implementations ======================== -* :mod:`mopidy.frontends.http` -* :mod:`mopidy.frontends.mpd` +- :ref:`ext-http` + +- :ref:`ext-mpd` + +- `Mopidy-MPRIS `_ + +- `Mopidy-Notifier `_ + +- `Mopidy-Scrobbler `_ diff --git a/docs/api/http.rst b/docs/api/http.rst index 16546683..5561955d 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -113,8 +113,8 @@ HTML file: If you don't use Mopidy to host your web client, you can find the JS files in the Git repo at: -- ``mopidy/frontends/http/data/mopidy.js`` -- ``mopidy/frontends/http/data/mopidy.min.js`` +- ``mopidy/http/data/mopidy.js`` +- ``mopidy/http/data/mopidy.min.js`` Getting the library for Node.js use @@ -129,7 +129,7 @@ After npm completes, you can import Mopidy.js using ``require()``: .. code-block:: js - var Mopidy = require("mopidy").Mopidy; + var Mopidy = require("mopidy"); Getting the library for development on the library diff --git a/docs/api/index.rst b/docs/api/index.rst index f58552b7..bede978b 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -4,6 +4,15 @@ API reference ************* +.. warning:: API stability + + Only APIs documented here are public and open for use by Mopidy + extensions. We will change these APIs, but will keep the changelog up to + date with all breaking changes. + + From Mopidy 1.0 and onwards, we intend to keep these APIs far more stable. + + .. toctree:: :glob: @@ -16,4 +25,5 @@ API reference commands ext config + zeroconf http diff --git a/docs/api/zeroconf.rst b/docs/api/zeroconf.rst new file mode 100644 index 00000000..7cdd93f0 --- /dev/null +++ b/docs/api/zeroconf.rst @@ -0,0 +1,11 @@ +.. _zeroconf-api: + +************ +Zeroconf API +************ + +.. module:: mopidy.zeroconf + :synopsis: Helper for publishing of services on Zeroconf + +.. autoclass:: Zeroconf + :members: diff --git a/docs/authors.rst b/docs/authors.rst index 54ba95af..7c00e2ac 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -4,7 +4,7 @@ Authors ******* -Mopidy is copyright 2009-2013 Stein Magnus Jodal and contributors. Mopidy is +Mopidy is copyright 2009-2014 Stein Magnus Jodal and contributors. Mopidy is licensed under the `Apache License, Version 2.0 `_. diff --git a/docs/changelog.rst b/docs/changelog.rst index acb94e3d..dcab214b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,172 @@ Changelog This changelog is used to track all major changes to Mopidy. +v0.18.0 (2014-01-19) +==================== + +The focus of 0.18 have been on two fronts: the local library and browsing. + +First, the local library's old tag cache file used for storing the track +metadata scanned from your music collection has been replaced with a far +simpler implementation using JSON as the storage format. At the same time, the +local library have been made replaceable by extensions, so you can now create +extensions that use your favorite database to store the metadata. + +Second, we've finally implemented the long awaited "file system" browsing +feature that you know from MPD. It is supported by both the MPD frontend and +the local and Spotify backends. It is also used by the new Mopidy-Dirble +extension to provide you with a directory of Internet radio stations from all +over the world. + +Since the release of 0.17, we've closed or merged 49 issues and pull requests +through about 285 commits by :ref:`11 people `, including six new +guys. Thanks to everyone that has contributed! + +**Core API** + +- Add :meth:`mopidy.core.Core.version` for HTTP clients to manage compatibility + between API versions. (Fixes: :issue:`597`) + +- Add :class:`mopidy.models.Ref` class for use as a lightweight reference to + other model types, containing just an URI, a name, and an object type. It is + barely used for now, but its use will be extended over time. + +- Add :meth:`mopidy.core.LibraryController.browse` method for browsing a + virtual file system of tracks. Backends can implement support for this by + implementing :meth:`mopidy.backend.LibraryProvider.browse`. + +- Events emitted on play/stop, pause/resume, next/previous and on end of track + has been cleaned up to work consistently. See the message of + :commit:`1d108752f6` for the full details. (Fixes: :issue:`629`) + +**Backend API** + +- Move the backend API classes from :mod:`mopidy.backends.base` to + :mod:`mopidy.backend` and remove the ``Base`` prefix from the class names: + + - From :class:`mopidy.backends.base.Backend` + to :class:`mopidy.backend.Backend` + + - From :class:`mopidy.backends.base.BaseLibraryProvider` + to :class:`mopidy.backend.LibraryProvider` + + - From :class:`mopidy.backends.base.BasePlaybackProvider` + to :class:`mopidy.backend.PlaybackProvider` + + - From :class:`mopidy.backends.base.BasePlaylistsProvider` + to :class:`mopidy.backend.PlaylistsProvider` + + - From :class:`mopidy.backends.listener.BackendListener` + to :class:`mopidy.backend.BackendListener` + + Imports from the old locations still works, but are deprecated. + +- Add :meth:`mopidy.backend.LibraryProvider.browse`, which can be implemented + by backends that wants to expose directories of tracks in Mopidy's virtual + file system. + +**Frontend API** + +- The dummy backend used for testing many frontends have moved from + :mod:`mopidy.backends.dummy` to :mod:`mopidy.backend.dummy`. + +**Commands** + +- Reduce amount of logging from dependencies when using :option:`mopidy -v`. + (Fixes: :issue:`593`) + +- Add support for additional logging verbosity levels with ``mopidy -vv`` and + ``mopidy -vvv`` which increases the amount of logging from dependencies. + (Fixes: :issue:`593`) + +**Configuration** + +- The default for the :option:`mopidy --config` option has been updated to + include ``$XDG_CONFIG_DIRS`` in addition to ``$XDG_CONFIG_DIR``. (Fixes + :issue:`431`) + +- Added support for deprecating config values in order to allow for graceful + removal of the no longer used config value :confval:`local/tag_cache_file`. + +**Extension support** + +- Switched to using a registry model for classes provided by extension. This + allows extensions to be extended by other extensions, as needed by for + example pluggable libraries for the local backend. See + :class:`mopidy.ext.Registry` for details. (Fixes :issue:`601`) + +- Added the new method :meth:`mopidy.ext.Extension.setup`. This method + replaces the now deprecated + :meth:`~mopidy.ext.Extension.get_backend_classes`, + :meth:`~mopidy.ext.Extension.get_frontend_classes`, and + :meth:`~mopidy.ext.Extension.register_gstreamer_elements`. + +**Audio** + +- Added :confval:`audio/mixer_volume` to set the initial volume of mixers. + This is especially useful for setting the software mixer volume to something + else than the default 100%. (Fixes: :issue:`633`) + +**Local backend** + +.. note:: + + After upgrading to Mopidy 0.18 you must run ``mopidy local scan`` to + reindex your local music collection. This is due to the change of storage + format. + +- Added support for browsing local directories in Mopidy's virtual file system. + +- Finished the work on creating pluggable libraries. Users can now + reconfigure Mopidy to use alternate library providers of their choosing for + local files. (Fixes issue :issue:`44`, partially resolves :issue:`397`, and + causes a temporary regression of :issue:`527`.) + +- Switched default local library provider from a "tag cache" file that closely + resembled the one used by the original MPD server to a compressed JSON file. + This greatly simplifies our library code and reuses our existing model + serialization code, as used by the HTTP API and web clients. + +- Removed our outdated and bug-ridden "tag cache" local library implementation. + +- Added the config value :confval:`local/library` to select which library to + use. It defaults to ``json``, which is the only local library bundled with + Mopidy. + +- Added the config value :confval:`local/data_dir` to have a common config for + where to store local library data. This is intended to avoid every single + local library provider having to have it's own config value for this. + +- Added the config value :confval:`local/scan_flush_threshold` to control how + often to tell local libraries to store changes when scanning local music. + +**Streaming backend** + +- Add live lookup of URI metadata. (Fixes :issue:`540`) + +- Add support for extended M3U playlist, meaning that basic track metadata + stored in playlists will be used by Mopidy. + +**HTTP frontend** + +- Upgrade Mopidy.js dependencies and add support for using Mopidy.js with + Browserify. This version has been released to npm as Mopidy.js v0.2.0. + (Fixes: :issue:`609`) + +**MPD frontend** + +- Make the ``lsinfo``, ``listall``, and ``listallinfo`` commands support + browsing of Mopidy's virtual file system. (Fixes: :issue:`145`) + +- Empty commands now return a ``ACK [5@0] {} No command given`` error instead + of ``OK``. This is consistent with the original MPD server implementation. + +**Internal changes** + +- Events from the audio actor, backends, and core actor are now emitted + asyncronously through the GObject event loop. This should resolve the issue + that has blocked the merge of the EOT-vs-EOS fix for a long time. + v0.17.0 (2013-11-23) ==================== @@ -370,7 +536,7 @@ A release with a number of small and medium fixes, with no specific focus. objects with ``tlid`` set to ``0`` to be sent to the HTTP client without the ``tlid`` field. (Fixes: :issue:`501`) -- Upgrade Mopidy.js dependencies. This version has been released to NPM as +- Upgrade Mopidy.js dependencies. This version has been released to npm as Mopidy.js v0.1.1. **Extension support** @@ -616,9 +782,9 @@ throughout Mopidy. **Stream backend** We've added a new backend for playing audio streams, the :mod:`stream backend -`. It is activated by default. The stream backend -supports the intersection of what your GStreamer installation supports and what -protocols are included in the :attr:`mopidy.settings.STREAM_PROTOCOLS` setting. +`. It is activated by default. The stream backend supports the +intersection of what your GStreamer installation supports and what protocols +are included in the :attr:`mopidy.settings.STREAM_PROTOCOLS` setting. Current limitations: @@ -1657,8 +1823,8 @@ to this problem. - Local backend: - Add :command:`mopidy-scan` command to generate ``tag_cache`` files without - any help from the original MPD server. See :ref:`generating-a-tag-cache` - for instructions on how to use it. + any help from the original MPD server. See + :ref:`generating-a-local-library` for instructions on how to use it. - Fix support for UTF-8 encoding in tag caches. @@ -1714,7 +1880,7 @@ to this problem. - Packaging and distribution: - - Setup APT repository and crate Debian packages of Mopidy. See + - Setup APT repository and create Debian packages of Mopidy. See :ref:`installation` for instructions for how to install Mopidy, including all dependencies, from APT. diff --git a/docs/_static/dz0ny-mopidy-lux.png b/docs/clients/dz0ny-mopidy-lux.png similarity index 100% rename from docs/_static/dz0ny-mopidy-lux.png rename to docs/clients/dz0ny-mopidy-lux.png diff --git a/docs/clients/http.rst b/docs/clients/http.rst index 7b4ea72b..9ef3b131 100644 --- a/docs/clients/http.rst +++ b/docs/clients/http.rst @@ -18,34 +18,62 @@ See :ref:`http-api` for details on how to build your own web client. woutervanwijk/Mopidy-Webclient ============================== -.. image:: /_static/woutervanwijk-mopidy-webclient.png - :width: 382 - :height: 621 +.. image:: woutervanwijk-mopidy-webclient.png + :width: 1275 + :height: 600 -The first web client for Mopidy is still under development, but is already very -usable. It targets both desktop and mobile browsers. +The first web client for Mopidy, made with jQuery Mobile by Wouter van Wijk. +Also the web client used for Wouter's popular `Pi Musicbox +`_ image for Raspberry Pi. -The web client used for the `Pi Musicbox -`_ is also available for other users -of Mopidy. See https://github.com/woutervanwijk/Mopidy-WebClient for details. + With Mopidy Browser Client, you can play your music on your computer (or + Rapsberry Pi) and remotely control it from a computer, phone, tablet, + laptop. From your couch. + + -- https://github.com/woutervanwijk/Mopidy-WebClient Mopidy Lux ========== -.. image:: /_static/dz0ny-mopidy-lux.png +.. image:: dz0ny-mopidy-lux.png :width: 1000 :height: 645 -New web client developed by Janez Troha. See -https://github.com/dz0ny/mopidy-lux for details. +A Mopidy web client made with AngularJS by Janez Troha. + + A shiny new remote web control interface for Mopidy player. + + -- https://github.com/dz0ny/mopidy-lux + + +Moped +===== + +.. image:: martijnboland-moped.png + :width: 720 + :height: 450 + +A Mopidy web client made with Durandal and KnockoutJS by Martijn Boland. + + Moped is a responsive web client for the Mopidy music server. It is + inspired by Mopidy-Webclient, but built from scratch based on a different + technology stack with Durandal and Bootstrap 3. + + -- https://github.com/martijnboland/moped JukePi ====== -New web client developed by Meantime IT in the UK for their office jukebox. See -https://github.com/meantimeit/jukepi for details. +A Mopidy web client made with Backbone.js by Meantime IT in the UK for their +office jukebox. + + JukePi is a web client for the Mopidy music server. Mopidy empowers you to + create a custom music server that can connect to Spotify, play local mp3s + and more. + + -- https://github.com/meantimeit/jukepi Other web clients diff --git a/docs/clients/index.rst b/docs/clients/index.rst deleted file mode 100644 index 6ebfd948..00000000 --- a/docs/clients/index.rst +++ /dev/null @@ -1,8 +0,0 @@ -******* -Clients -******* - -.. toctree:: - :glob: - - ** diff --git a/docs/clients/martijnboland-moped.png b/docs/clients/martijnboland-moped.png new file mode 100644 index 00000000..d9c87112 Binary files /dev/null and b/docs/clients/martijnboland-moped.png differ diff --git a/docs/_static/mpd-client-gmpc.png b/docs/clients/mpd-client-gmpc.png similarity index 100% rename from docs/_static/mpd-client-gmpc.png rename to docs/clients/mpd-client-gmpc.png diff --git a/docs/_static/mpd-client-mpad.jpg b/docs/clients/mpd-client-mpad.jpg similarity index 100% rename from docs/_static/mpd-client-mpad.jpg rename to docs/clients/mpd-client-mpad.jpg diff --git a/docs/_static/mpd-client-mpdroid.jpg b/docs/clients/mpd-client-mpdroid.jpg similarity index 100% rename from docs/_static/mpd-client-mpdroid.jpg rename to docs/clients/mpd-client-mpdroid.jpg diff --git a/docs/_static/mpd-client-mpod.jpg b/docs/clients/mpd-client-mpod.jpg similarity index 100% rename from docs/_static/mpd-client-mpod.jpg rename to docs/clients/mpd-client-mpod.jpg diff --git a/docs/_static/mpd-client-ncmpcpp.png b/docs/clients/mpd-client-ncmpcpp.png similarity index 100% rename from docs/_static/mpd-client-ncmpcpp.png rename to docs/clients/mpd-client-ncmpcpp.png diff --git a/docs/_static/mpd-client-sonata.png b/docs/clients/mpd-client-sonata.png similarity index 100% rename from docs/_static/mpd-client-sonata.png rename to docs/clients/mpd-client-sonata.png diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 0993303d..4a2736fe 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -51,7 +51,7 @@ ncmpcpp A console client that works well with Mopidy, and is regularly used by Mopidy developers. -.. image:: /_static/mpd-client-ncmpcpp.png +.. image:: mpd-client-ncmpcpp.png :width: 575 :height: 426 @@ -84,7 +84,7 @@ GMPC `GMPC `_ is a graphical MPD client (GTK+) which works well with Mopidy. -.. image:: /_static/mpd-client-gmpc.png +.. image:: mpd-client-gmpc.png :width: 1000 :height: 565 @@ -101,7 +101,7 @@ Sonata `Sonata `_ is a graphical MPD client (GTK+). It generally works well with Mopidy, except for search. -.. image:: /_static/mpd-client-sonata.png +.. image:: mpd-client-sonata.png :width: 475 :height: 424 @@ -140,7 +140,7 @@ Test date: Tested version: 1.03.1 (released 2012-10-16) -.. image:: /_static/mpd-client-mpdroid.jpg +.. image:: mpd-client-mpdroid.jpg :width: 288 :height: 512 @@ -269,7 +269,7 @@ Test date: Tested version: 1.7.1 -.. image:: /_static/mpd-client-mpod.jpg +.. image:: mpd-client-mpod.jpg :width: 320 :height: 480 @@ -297,7 +297,7 @@ Test date: Tested version: 1.7.1 -.. image:: /_static/mpd-client-mpad.jpg +.. image:: mpd-client-mpad.jpg :width: 480 :height: 360 @@ -332,7 +332,7 @@ other web clients, see :ref:`http-clients`. Rompr ----- -.. image:: /_static/rompr.png +.. image:: rompr.png :width: 557 :height: 600 diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst index e1bd4bff..650372e6 100644 --- a/docs/clients/mpris.rst +++ b/docs/clients/mpris.rst @@ -24,7 +24,7 @@ sound menu in Ubuntu since 10.10 or 11.04. By default, it only includes the Rhytmbox music player, but many other players can integrate with the sound menu, including the official Spotify player and Mopidy. -.. image:: /_static/ubuntu-sound-menu.png +.. image:: ubuntu-sound-menu.png :height: 480 :width: 955 diff --git a/docs/_static/rompr.png b/docs/clients/rompr.png similarity index 100% rename from docs/_static/rompr.png rename to docs/clients/rompr.png diff --git a/docs/_static/ubuntu-sound-menu.png b/docs/clients/ubuntu-sound-menu.png similarity index 100% rename from docs/_static/ubuntu-sound-menu.png rename to docs/clients/ubuntu-sound-menu.png diff --git a/docs/clients/woutervanwijk-mopidy-webclient.png b/docs/clients/woutervanwijk-mopidy-webclient.png new file mode 100644 index 00000000..d026ab2a Binary files /dev/null and b/docs/clients/woutervanwijk-mopidy-webclient.png differ diff --git a/docs/commands/mopidy.rst b/docs/commands/mopidy.rst index 44e961e6..75515a8d 100644 --- a/docs/commands/mopidy.rst +++ b/docs/commands/mopidy.rst @@ -43,7 +43,7 @@ Options .. cmdoption:: --verbose, -v - Show more output: debug level and higher. + Show more output. Repeat up to 3 times for even more. .. cmdoption:: --save-debug-log @@ -83,6 +83,10 @@ Additionally, extensions can provide extra commands. Run `mopidy --help` for a list of what is available on your system and command-specific help. Commands for disabled extensions will be listed, but can not be run. +.. cmdoption:: local clear + + Clear local media files from the local library. + .. cmdoption:: local scan Scan local media files present in your library. diff --git a/docs/conf.py b/docs/conf.py index 25e0b145..737fb07a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,6 +28,12 @@ class Mock(object): def __getattr__(self, name): if name in ('__file__', '__path__'): return '/dev/null' + elif name == 'get_system_config_dirs': + # glib.get_system_config_dirs() + return tuple + elif name == 'get_user_config_dir': + # glib.get_user_config_dir() + return str elif (name[0] == name[0].upper() # gst.interfaces.MIXER_TRACK_* and not name.startswith('MIXER_TRACK_') @@ -91,7 +97,7 @@ source_suffix = '.rst' master_doc = 'index' project = 'Mopidy' -copyright = '2009-2013, Stein Magnus Jodal and contributors' +copyright = '2009-2014, Stein Magnus Jodal and contributors' from mopidy.utils.versioning import get_version release = get_version() @@ -155,6 +161,7 @@ man_pages = [ extlinks = { 'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#'), + 'commit': ('https://github.com/mopidy/mopidy/commit/%s', 'commit '), 'mpris': ( 'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'), } diff --git a/docs/config.rst b/docs/config.rst index bad49373..d1752ba5 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -1,3 +1,5 @@ +.. _config: + ************* Configuration ************* @@ -78,6 +80,15 @@ Core configuration values Setting the config value to blank turns off volume control. +.. confval:: audio/mixer_volume + + Initial volume for the audio mixer. + + Expects an integer between 0 and 100. + + Setting the config value to blank leaves the audio mixer volume unchanged. + For the software mixer blank means 100. + .. confval:: audio/mixer_track Audio mixer track to use. @@ -220,7 +231,7 @@ Streaming through SHOUTcast/Icecast Currently, Mopidy does not handle end-of-track vs end-of-stream signalling in GStreamer correctly. This causes the SHOUTcast stream to be disconnected at the end of each track, rendering it quite useless. For further details, - see :issue:`492`. + see :issue:`492`. You can also try the workaround_ mentioned below. If you want to play the audio on another computer than the one running Mopidy, you can stream the audio from Mopidy through an SHOUTcast or Icecast audio @@ -236,17 +247,37 @@ server simultaneously. To use the SHOUTcast output, do the following: #. You might also need to change the ``shout2send`` default settings, run ``gst-inspect-0.10 shout2send`` to see the available settings. Most likely you want to change ``ip``, ``username``, ``password``, and ``mount``. For - example, to set the username and password, use: + example: .. code-block:: ini [audio] - output = lame ! shout2send username="alice" password="secret" + output = lame ! shout2send username="alice" password="secret" mount="mopidy" Other advanced setups are also possible for outputs. Basically, anything you can use with the ``gst-launch-0.10`` command can be plugged into :confval:`audio/output`. +.. _workaround: + +**Workaround for end-of-track issues - fallback streams** + +By using a *fallback stream* playing silence, you can somewhat mitigate the +signalling issues. + +Example Icecast configuration: + +.. code-block:: xml + + + /mopidy + /silence.mp3 + 1 + + +The ``silence.mp3`` file needs to be placed in the directory defined by +``...``. + New configuration values ------------------------ diff --git a/docs/contributing.rst b/docs/contributing.rst index 22df8ced..2436ffc0 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -85,7 +85,7 @@ Mopidy to come with tests. #. To run tests, you need a couple of dependencies. They can be installed using ``pip``:: - pip install -r requirements/tests.txt + pip install --upgrade coverage flake8 mock nose #. Then, to run all tests, go to the project directory and run:: diff --git a/docs/devtools.rst b/docs/devtools.rst index 6bdf9a27..64bb7e6b 100644 --- a/docs/devtools.rst +++ b/docs/devtools.rst @@ -27,51 +27,6 @@ code. So, if you're out of work, the code coverage and flake8 data at the CI server should give you a place to start. -Protocol debugger -================= - -Since the main interface provided to Mopidy is through the MPD protocol, it is -crucial that we try and stay in sync with protocol developments. In an attempt -to make it easier to debug differences Mopidy and MPD protocol handling we have -created ``tools/debug-proxy.py``. - -This tool is proxy that sits in front of two MPD protocol aware servers and -sends all requests to both, returning the primary response to the client and -then printing any diff in the two responses. - -Note that this tool depends on ``gevent`` unlike the rest of Mopidy at the time -of writing. See :option:`tools/debug-proxy.py --help` for available options. -Sample session:: - - [127.0.0.1]:59714 - listallinfo - --- Reference response - +++ Actual response - @@ -1,16 +1,1 @@ - -file: uri1 - -Time: 4 - -Artist: artist1 - -Title: track1 - -Album: album1 - -file: uri2 - -Time: 4 - -Artist: artist2 - -Title: track2 - -Album: album2 - -file: uri3 - -Time: 4 - -Artist: artist3 - -Title: track3 - -Album: album3 - -OK - +ACK [2@0] {listallinfo} incorrect arguments - -To ensure that Mopidy and MPD have comparable state it is suggested you setup -both to use ``tests/data/advanced_tag_cache`` for their tag cache and -``tests/data/scanner/advanced/`` for the music folder and ``tests/data`` for -playlists. - - Documentation writing ===================== @@ -106,15 +61,26 @@ Creating releases git checkout master git merge --no-ff -m "Release v0.16.0" develop +#. Install/upgrade tools used for packaging:: + + pip install -U twine wheel + #. Build package and test it manually in a new virtualenv. The following assumes the use of virtualenvwrapper:: - python setup.py sdist + python setup.py sdist bdist_wheel + mktmpenv pip install path/to/dist/Mopidy-0.16.0.tar.gz toggleglobalsitepackages + # do manual test + deactivate - Then test Mopidy manually to confirm that the package is working correctly. + mktmpenv + pip install path/to/dist/Mopidy-0.16.0-py27-none-any.whl + toggleglobalsitepackages + # do manual test + deactivate #. Tag the release:: @@ -125,14 +91,10 @@ Creating releases git push git push --tags -#. Build source package and upload to PyPI:: +#. Upload the previously built and tested sdist and bdist_wheel packages to + PyPI:: - python setup.py sdist upload - -#. Build wheel package and upload to PyPI:: - - pip install -U wheel - python setup.py bdist_wheel upload + twine upload dist/Mopidy-0.16.0* #. Merge ``master`` back into ``develop`` and push the branch to GitHub. diff --git a/docs/ext/index.rst b/docs/ext/external.rst similarity index 68% rename from docs/ext/index.rst rename to docs/ext/external.rst index 27fe3b45..0ead8ac2 100644 --- a/docs/ext/index.rst +++ b/docs/ext/external.rst @@ -1,37 +1,22 @@ -.. _ext: - -********** -Extensions -********** - -Here you can find a list of packages that extend Mopidy with additional -functionality. This list is moderated and updated on a regular basis. If you -want your package to show up here, follow the :ref:`guide on creating -extensions `. - - -Bundled with Mopidy -=================== - -These extensions are maintained by Mopidy's core developers. They are installed -together with Mopidy and are enabled by default. - -.. toctree:: - :maxdepth: 1 - :glob: - - ** - - +******************* External extensions -=================== +******************* -These extensions are maintained outside Mopidy's core, often by other -developers. +Here you can find a list of external packages that extend Mopidy with +additional functionality. This list is moderated and updated on a regular +basis. If you want your package to show up here, follow the :ref:`guide on +creating extensions `. + +Mopidy also bundles some extensions: + +- :ref:`ext-local` +- :ref:`ext-stream` +- :ref:`ext-http` +- :ref:`ext-mpd` Mopidy-Arcam ------------- +============ https://github.com/TooDizzy/mopidy-arcam @@ -40,7 +25,7 @@ and tested with an Arcam AVR-300. Mopidy-Beets ------------- +============ https://github.com/mopidy/mopidy-beets @@ -48,8 +33,17 @@ Provides a backend for playing music from your `Beets `_ music library through Beets' web extension. +Mopidy-Dirble +============= + +https://github.com/mopidy/mopidy-dirble + +Provides a backend for browsing the Internet radio channels from the `Dirble +`_ directory. + + Mopidy-GMusic -------------- +============= https://github.com/hechtus/mopidy-gmusic @@ -58,7 +52,7 @@ Provides a backend for playing music from `Google Play Music Mopidy-MPRIS ------------- +============ https://github.com/mopidy/mopidy-mpris @@ -67,7 +61,7 @@ D-Bus interface, for example using the Ubuntu Sound Menu. Mopidy-NAD ----------- +========== https://github.com/mopidy/mopidy-nad @@ -75,7 +69,7 @@ Extension for controlling volume using an external NAD amplifier. Mopidy-Notifier ---------------- +=============== https://github.com/sauberfred/mopidy-notifier @@ -83,7 +77,7 @@ Extension for displaying track info as User Notifications in Mac OS X. Mopidy-radio-de ---------------- +=============== https://github.com/hechtus/mopidy-radio-de @@ -93,7 +87,7 @@ Extension for listening to Internet radio stations and podcasts listed at Mopidy-Scrobbler ----------------- +================ https://github.com/mopidy/mopidy-scrobbler @@ -101,7 +95,7 @@ Extension for scrobbling played tracks to Last.fm. Mopidy-SomaFM -------------- +============= https://github.com/AlexandrePTJ/mopidy-somafm @@ -110,7 +104,7 @@ service. Mopidy-SoundCloud ------------------ +================= https://github.com/mopidy/mopidy-soundcloud @@ -119,7 +113,7 @@ Provides a backend for playing music from the `SoundCloud Mopidy-Spotify --------------- +============== https://github.com/mopidy/mopidy-spotify @@ -128,9 +122,18 @@ streaming service. Mopidy-Subsonic ---------------- +=============== https://github.com/rattboi/mopidy-subsonic Provides a backend for playing music from a `Subsonic Music Streamer `_ library. + + +Mopidy-VKontakte +================ + +https://github.com/sibuser/mopidy-vkontakte + +Provides a backend for playing music from the `VKontakte social network +`_. diff --git a/docs/ext/http.rst b/docs/ext/http.rst index ce79588e..1b5b0119 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -4,33 +4,77 @@ Mopidy-HTTP *********** -The HTTP extension lets you control Mopidy through HTTP and WebSockets, e.g. -from a web based client. See :ref:`http-api` for details on how to integrate -with Mopidy over HTTP. +Mopidy-HTTP is an extension that lets you control Mopidy through HTTP and +WebSockets, for example from a web client. It is bundled with Mopidy and +enabled by default if all dependencies are available. + +When it is enabled it starts a web server at the port specified by the +:confval:`http/port` config value. + +.. warning:: + + As a simple security measure, the web server is by default only available + from localhost. To make it available from other computers, change the + :confval:`http/hostname` config value. Before you do so, note that the HTTP + extension does not feature any form of user authentication or + authorization. Anyone able to access the web server can use the full core + API of Mopidy. Thus, you probably only want to make the web server + available from your local network or place it behind a web proxy which + takes care or user authentication. You have been warned. -Known issues -============ +Using a web based Mopidy client +=============================== -https://github.com/mopidy/mopidy/issues?labels=HTTP+frontend +Mopidy-HTTP's web server can also host any static files, for example the HTML, +CSS, JavaScript, and images needed for a web based Mopidy client. To host +static files, change the :confval:`http/static_dir` config value to point to +the root directory of your web client, for example:: + + [http] + static_dir = /home/alice/dev/the-client + +If the directory includes a file named ``index.html``, it will be served on the +root of Mopidy's web server. + +If you're making a web based client and wants to do server side development as +well, you are of course free to run your own web server and just use Mopidy's +web server to host the API end points. But, for clients implemented purely in +JavaScript, letting Mopidy host the files is a simpler solution. + +See :ref:`http-api` for details on how to integrate with Mopidy over HTTP. If +you're looking for a web based client for Mopidy, go check out +:ref:`http-clients`. Dependencies ============ -.. literalinclude:: ../../requirements/http.txt +In addition to Mopidy's dependencies, Mopidy-HTTP requires the following: + +- cherrypy >= 3.2.2. Available as python-cherrypy3 in Debian/Ubuntu. + +- ws4py >= 0.2.3. Available as python-ws4py in newer Debian/Ubuntu and from + `apt.mopidy.com `__ for older releases of + Debian/Ubuntu. + +If you're installing Mopidy with pip, you can run the following command to +install Mopidy with the extra dependencies for required for Mopidy-HTTP:: + + pip install --upgrade Mopidy[http] + +If you're installing Mopidy from APT, the additional dependencies needed for +Mopidy-HTTP are always included. -Default configuration -===================== +Configuration +============= -.. literalinclude:: ../../mopidy/frontends/http/ext.conf +See :ref:`config` for general help on configuring Mopidy. + +.. literalinclude:: ../../mopidy/http/ext.conf :language: ini - -Configuration values -==================== - .. confval:: http/enabled If the HTTP extension should be enabled or not. @@ -65,46 +109,3 @@ Configuration values ``$hostname`` and ``$port`` can be used in the name. Set to an empty string to disable Zeroconf for HTTP. - - -Usage -===== - -The extension is enabled by default if all dependencies are available. - -When it is enabled it starts a web server at the port specified by the -:confval:`http/port` config value. - -.. warning:: Security - - As a simple security measure, the web server is by default only available - from localhost. To make it available from other computers, change the - :confval:`http/hostname` config value. Before you do so, note that the HTTP - extension does not feature any form of user authentication or - authorization. Anyone able to access the web server can use the full core - API of Mopidy. Thus, you probably only want to make the web server - available from your local network or place it behind a web proxy which - takes care or user authentication. You have been warned. - - -Using a web based Mopidy client -------------------------------- - -The web server can also host any static files, for example the HTML, CSS, -JavaScript, and images needed for a web based Mopidy client. To host static -files, change the ``http/static_dir`` to point to the root directory of your -web client, e.g.:: - - [http] - static_dir = /home/alice/dev/the-client - -If the directory includes a file named ``index.html``, it will be served on the -root of Mopidy's web server. - -If you're making a web based client and wants to do server side development as -well, you are of course free to run your own web server and just use Mopidy's -web server for the APIs. But, for clients implemented purely in JavaScript, -letting Mopidy host the files is a simpler solution. - -If you're looking for a web based client for Mopidy, go check out -:ref:`http-clients`. diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 23baa5d1..31d00d66 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -4,87 +4,95 @@ Mopidy-Local ************ -Extension for playing music from a local music archive. +Mopidy-Local is an extension for playing music from your local music archive. +It is bundled with Mopidy and enabled by default. Though, you'll have to scan +your music collection to build a cache of metadata before the Mopidy-Local +will be able to play your music. This backend handles URIs starting with ``local:``. -Known issues -============ +.. _generating-a-local-library: -https://github.com/mopidy/mopidy/issues?labels=Local+backend - - -Dependencies -============ - -None. The extension just needs Mopidy. - - -Default configuration -===================== - -.. literalinclude:: ../../mopidy/backends/local/ext.conf - :language: ini - - -Configuration values -==================== - -.. confval:: local/enabled - - If the local extension should be enabled or not. - -.. confval:: local/media_dir - - Path to directory with local media files. - -.. confval:: local/playlists_dir - - Path to playlists directory with m3u files for local media. - -.. confval:: local/tag_cache_file - - Path to tag cache for local media. - -.. confval:: local/scan_timeout - - Number of milliseconds before giving up scanning a file and moving on to - the next file. - -.. confval:: local/excluded_file_extensions - - File extensions to exclude when scanning the media directory. - - -Usage -===== - -If you want use Mopidy to play music you have locally at your machine, you need -to review and maybe change some of the local extension config values. See above -for a complete list. Then you need to generate a tag cache for your local -music... - - -.. _generating-a-tag-cache: - -Generating a tag cache ----------------------- +Generating a local library +========================== The command :command:`mopidy local scan` will scan the path set in the -:confval:`local/media_dir` config value for any media files and build a MPD -compatible ``tag_cache``. +:confval:`local/media_dir` config value for any audio files and build a +library of metadata. -To make a ``tag_cache`` of your local music available for Mopidy: +To make a local library for your music available for Mopidy: #. Ensure that the :confval:`local/media_dir` config value points to where your music is located. Check the current setting by running:: mopidy config -#. Scan your media library. The command writes the ``tag_cache`` to - the :confval:`local/tag_cache_file`:: +#. Scan your media library.:: mopidy local scan #. Start Mopidy, find the music library in a client, and play some local music! + + +Pluggable library support +========================= + +Local libraries are fully pluggable. What this means is that users may opt to +disable the current default library ``json``, replacing it with a third +party one. When running :command:`mopidy local scan` Mopidy will populate +whatever the current active library is with data. Only one library may be +active at a time. + +To create a new library provider you must create class that implements the +:class:`mopidy.local.Library` interface and install it in the extension +registry under ``local:library``. Any data that the library needs to store on +disc should be stored in :confval:`local/data_dir` using the library name as +part of the filename or directory to avoid any conflicts. + + +Configuration +============= + +See :ref:`config` for general help on configuring Mopidy. + +.. literalinclude:: ../../mopidy/local/ext.conf + :language: ini + +.. confval:: local/enabled + + If the local extension should be enabled or not. + +.. confval:: local/library + + Local library provider to use, change this if you want to use a third party + library for local files. + +.. confval:: local/media_dir + + Path to directory with local media files. + +.. confval:: local/data_dir + + Path to directory to store local metadata such as libraries and playlists + in. + +.. confval:: local/playlists_dir + + Path to playlists directory with m3u files for local media. + +.. confval:: local/scan_timeout + + Number of milliseconds before giving up scanning a file and moving on to + the next file. + +.. confval:: local/scan_flush_threshold + + Number of tracks to wait before telling library it should try and store + its progress so far. Some libraries might not respect this setting. + Set this to zero to disable flushing. + +.. confval:: local/excluded_file_extensions + + File extensions to exclude when scanning the media directory. Values + should be separated by either comma or newline. diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index eb502221..ecfab949 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -4,22 +4,27 @@ Mopidy-MPD ********** -This extension implements an MPD server to make Mopidy available to :ref:`MPD -clients `. +Mopidy-MPD is an extension that provides a full MPD server implementation to +make Mopidy available to :ref:`MPD clients `. It is bundled with +Mopidy and enabled by default. + +.. warning:: + + As a simple security measure, the MPD server is by default only available + from localhost. To make it available from other computers, change the + :confval:`mpd/hostname` config value. Before you do so, note that the MPD + server does not support any form of encryption and only a single clear + text password (see :confval:`mpd/password`) for weak authentication. Anyone + able to access the MPD server can control music playback on your computer. + Thus, you probably only want to make the MPD server available from your + local network. You have been warned. MPD stands for Music Player Daemon, which is also the name of the `original MPD server project `_. Mopidy does not depend on the original MPD server, but implements the MPD protocol itself, and is thus compatible with clients for the original MPD server. -For more details on our MPD server implementation, see -:mod:`mopidy.frontends.mpd`. - - -Known issues -============ - -https://github.com/mopidy/mopidy/issues?labels=MPD+frontend +For more details on our MPD server implementation, see :mod:`mopidy.mpd`. Limitations @@ -28,6 +33,7 @@ Limitations This is a non exhaustive list of MPD features that Mopidy doesn't support. Items on this list will probably not be supported in the near future. +- Only a single password is supported. It gives all-or-nothing access. - Toggling of audio outputs is not supported - Channels for client-to-client communication are not supported - Stickers are not supported @@ -41,26 +47,17 @@ near future: - Modifying stored playlists is not supported - ``tagtypes`` is not supported -- Browsing the file system is not supported - Live update of the music database is not supported -Dependencies -============ +Configuration +============= -None. The extension just needs Mopidy. +See :ref:`config` for general help on configuring Mopidy. - -Default configuration -===================== - -.. literalinclude:: ../../mopidy/frontends/mpd/ext.conf +.. literalinclude:: ../../mopidy/mpd/ext.conf :language: ini - -Configuration values -==================== - .. confval:: mpd/enabled If the MPD extension should be enabled or not. @@ -102,27 +99,3 @@ Configuration values ``$hostname`` and ``$port`` can be used in the name. Set to an empty string to disable Zeroconf for MPD. - - -Usage -===== - -The extension is enabled by default. To connect to the server, use an :ref:`MPD -client `. - - -.. _use-mpd-on-a-network: - -Connecting from other machines on the network ---------------------------------------------- - -As a secure default, Mopidy only accepts connections from ``localhost``. If you -want to open it for connections from other machines on your network, see -the documentation for the :confval:`mpd/hostname` config value. - -If you open up Mopidy for your local network, you should consider turning on -MPD password authentication by setting the :confval:`mpd/password` config value -to the password you want to use. If the password is set, Mopidy will require -MPD clients to provide the password before they can do anything else. Mopidy -only supports a single password, and do not support different permission -schemes like the original MPD server. diff --git a/docs/ext/stream.rst b/docs/ext/stream.rst index bb30e924..88dc5ade 100644 --- a/docs/ext/stream.rst +++ b/docs/ext/stream.rst @@ -4,53 +4,41 @@ Mopidy-Stream ************* -Extension for playing streaming music. +Mopidy-Stream is an extension for playing streaming music. It is bundled with +Mopidy and enabled by default. -The stream backend will handle streaming of URIs matching the -:confval:`stream/protocols` config value, assuming the needed GStreamer plugins -are installed. +This backend does not provide a library or playlist storage. It simply accepts +any URI added to Mopidy's tracklist that matches any of the protocols in the +:confval:`stream/protocols` config value. It then tries to retrieve metadata +and play back the URI using GStreamer. For example, if you're using an MPD +client, you'll just have to find your clients "add URI" interface, and provide +it with the URI of a stream. + +In addition to playing streams, the extension also understands how to extract +streams from a lot of playlist formats. This is convenient as most Internet +radio stations links to playlists instead of directly to the radio streams. + +If you're having trouble playing back a stream, run the ``mopidy deps`` +command to check if you have all relevant GStreamer plugins installed. -Known issues -============ +Configuration +============= -https://github.com/mopidy/mopidy/issues?labels=Stream+backend +See :ref:`config` for general help on configuring Mopidy. - -Dependencies -============ - -None. The extension just needs Mopidy. - - -Default configuration -===================== - -.. literalinclude:: ../../mopidy/backends/stream/ext.conf +.. literalinclude:: ../../mopidy/stream/ext.conf :language: ini - -Configuration values -==================== - .. confval:: stream/enabled If the stream extension should be enabled or not. .. confval:: stream/protocols - Whitelist of URI schemas to allow streaming from. + Whitelist of URI schemas to allow streaming from. Values should be + separated by either comma or newline. +.. confval:: stream/timeout -Usage -===== - -This backend does not provide a library or similar. It simply takes any URI -added to Mopidy's tracklist that matches any of the protocols in the -:confval:`stream/protocols` setting and tries to play back the URI using -GStreamer. E.g. if you're using an MPD client, you'll just have to find your -clients "add URI" interface, and provide it with the direct URI of the stream. - -Currently the stream backend can only work with URIs pointing direcly at -streams, and not intermediate playlists which is often used. See :issue:`303` -to track the development of playlist expansion support. + Number of milliseconds before giving up looking up stream metadata. diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 7fa19f7a..517fd027 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -222,8 +222,10 @@ file:: include README.rst include mopidy_soundspot/ext.conf -For details on the ``MANIFEST.in`` file format, check out the `distuitls docs +For details on the ``MANIFEST.in`` file format, check out the `distutils docs `_. +`check-manifest `_ is a very +useful tool to check your ``MANIFEST.in`` file for completeness. Example __init__.py @@ -237,7 +239,7 @@ 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 should be kept inside methods. This ensures that this file can be +extension should be kept inside methods. This ensures that this file can be imported without raising :exc:`ImportError` exceptions for missing dependencies, etc. @@ -259,6 +261,7 @@ This is ``mopidy_soundspot/__init__.py``:: from __future__ import unicode_literals + import logging import os import pygst @@ -271,6 +274,9 @@ This is ``mopidy_soundspot/__init__.py``:: __version__ = '0.1' + # If you need to log, use loggers named after the current Python module + logger = logging.getLogger(__name__) + class Extension(ext.Extension): @@ -288,28 +294,29 @@ This is ``mopidy_soundspot/__init__.py``:: schema['password'] = config.Secret() return schema - def validate_environment(self): - try: - import pysoundspot - except ImportError as e: - raise exceptions.ExtensionError('pysoundspot library not found', e) - - # You will typically only implement one of the next three methods - # in a single extension. - - def get_frontend_classes(self): - from .frontend import SoundspotFrontend - return [SoundspotFrontend] - - def get_backend_classes(self): - from .backend import SoundspotBackend - return [SoundspotBackend] - def get_command(self): from .commands import SoundspotCommand return SoundspotCommand() - def register_gstreamer_elements(self): + def validate_environment(self): + # Any manual checks of the environment to fail early. + # Dependencies described by setup.py are checked by Mopidy, so you + # should not check their presence here. + pass + + def setup(self, registry): + # You will typically only do one of the following things in a + # single extension. + + # Register a frontend + from .frontend import SoundspotFrontend + registry.add('frontend', SoundspotFrontend) + + # Register a backend + from .backend import SoundspotBackend + registry.add('backend', SoundspotBackend) + + # Register a custom GStreamer element from .mixer import SoundspotMixer gobject.type_register(SoundspotMixer) gst.element_register( @@ -341,11 +348,11 @@ passed a reference to the core API when it's created. See the import pykka - from mopidy.core import CoreListener + from mopidy import core - class SoundspotFrontend(pykka.ThreadingActor, CoreListener): - def __init__(self, core): + class SoundspotFrontend(pykka.ThreadingActor, core.CoreListener): + def __init__(self, config, core): super(SoundspotFrontend, self).__init__() self.core = core @@ -367,11 +374,11 @@ details. import pykka - from mopidy.backends import base + from mopidy import backend - class SoundspotBackend(pykka.ThreadingActor, base.BaseBackend): - def __init__(self, audio): + class SoundspotBackend(pykka.ThreadingActor, backend.Backend): + def __init__(self, config, audio): super(SoundspotBackend, self).__init__() self.audio = audio @@ -413,8 +420,8 @@ If you want to extend Mopidy's GStreamer pipeline with new custom GStreamer elements, you'll need to register them in GStreamer before they can be used. Basically, you just implement your GStreamer element in Python and then make -your :meth:`~mopidy.ext.Extension.register_gstreamer_elements` method register -all your custom GStreamer elements. +your :meth:`~mopidy.ext.Extension.setup` method register all your custom +GStreamer elements. For examples of custom GStreamer elements implemented in Python, see :mod:`mopidy.audio.mixers`. @@ -434,7 +441,7 @@ 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 is not something extensions should rely on being stable. +any time, and is not something extensions should use. Logging in extensions @@ -449,6 +456,9 @@ as this will be visible in Mopidy's debug log:: logger = logging.getLogger('mopidy_soundspot') + # Or even better, use the Python module name as the logger name: + logger = logging.getLogger(__name__) + When logging at logging level ``info`` or higher (i.e. ``warning``, ``error``, and ``critical``, but not ``debug``) the log message will be displayed to all Mopidy users. Thus, the log messages at those levels should be well written and diff --git a/docs/index.rst b/docs/index.rst index dc1f7c4f..e5f98a3a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -35,12 +35,37 @@ Usage installation/index installation/raspberrypi config - ext/index running - clients/index troubleshooting +.. _ext: + +Extensions +========== + +.. toctree:: + :maxdepth: 2 + + ext/local + ext/stream + ext/http + ext/mpd + ext/external + + +Clients +======= + +.. toctree:: + :maxdepth: 2 + + clients/http + clients/mpd + clients/mpris + clients/upnp + + About ===== diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 456ae73a..73c2f08f 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -42,6 +42,19 @@ in the same way as you get updates to the rest of your distribution. sudo apt-get update sudo apt-get install mopidy + Note that this will only install the main Mopidy package. For e.g. Spotify + or SoundCloud support you need to install the respective extension packages. + To list all the extensions available from apt.mopidy.com, you can run:: + + apt-cache search mopidy + + To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: + + sudo apt-get install mopidy-spotify + + For a full list of available Mopidy extensions, including those not + installable from apt.mopidy.com, see :ref:`ext`. + #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. @@ -92,11 +105,11 @@ package found in AUR. then you're ready to :doc:`run Mopidy `. -OS X: Install from Homebrew and Pip +OS X: Install from Homebrew and pip =================================== If you are running OS X, you can install everything needed with Homebrew and -Pip. +pip. #. Install `Homebrew `_. @@ -114,7 +127,7 @@ Pip. #. Install the required packages from Homebrew:: - brew install gst-python010 gst-plugins-good010 gst-plugins-ugly010 libspotify + brew install gst-python010 gst-plugins-good010 gst-plugins-ugly010 #. Make sure to include Homebrew's Python ``site-packages`` directory in your ``PYTHONPATH``. If you don't include this, Mopidy will not find GStreamer @@ -129,32 +142,48 @@ Pip. PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy -#. Next up, you need to install some Python packages. To do so, we use Pip. If +#. Next up, you need to install some Python packages. To do so, we use pip. If you don't have the ``pip`` command, you can install it now:: sudo easy_install pip -#. Then get, build, and install the latest release of pyspotify, pylast, - and Mopidy using Pip:: +#. Then, install the latest release of Mopidy using pip:: - sudo pip install -U pyspotify pylast cherrypy ws4py mopidy + sudo pip install -U mopidy + +#. Optionally, install additional extensions to Mopidy. + + For HTTP frontend support, so you can run Mopidy web clients:: + + sudo pip install -U mopidy[http] + + For playing music from Spotify:: + + brew install libspotify + sudo pip install -U mopidy-spotify + + For scrobbling to Last.fm:: + + sudo pip install -U mopidy-scrobbler + + For more extensions, see :ref:`ext`. #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. -Otherwise: Install from source using Pip +Otherwise: Install from source using pip ======================================== If you are on on Linux, but can't install from the APT archive or from AUR, you -can install Mopidy from PyPI using Pip. +can install Mopidy from PyPI using pip. #. First of all, you need Python 2.7. Check if you have Python and what version by running:: python --version -#. When you install using Pip, you need to make sure you have Pip. You'll also +#. When you install using pip, you need to make sure you have pip. You'll also need a C compiler and the Python development headers to build pyspotify later. @@ -170,49 +199,63 @@ can install Mopidy from PyPI using Pip. sudo yum install -y gcc python-devel python-pip -#. Then you'll need to install all of Mopidy's hard non-Python dependencies: + .. note:: - - GStreamer 0.10 (>= 0.10.31, < 0.11), with Python bindings. GStreamer is - packaged for most popular Linux distributions. Search for GStreamer in - your package manager, and make sure to install the Python bindings, and - the "good" and "ugly" plugin sets. + On Fedora Linux, you must replace ``pip`` with ``pip-python`` in the + following steps. - If you use Debian/Ubuntu you can install GStreamer like this:: +#. Then you'll need to install GStreamer 0.10 (>= 0.10.31, < 0.11), with Python + bindings. GStreamer is packaged for most popular Linux distributions. Search + for GStreamer in your package manager, and make sure to install the Python + bindings, and the "good" and "ugly" plugin sets. - sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly gstreamer0.10-tools + If you use Debian/Ubuntu you can install GStreamer like this:: - If you use Arch Linux, install the following packages from the official - repository:: + sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ + gstreamer0.10-plugins-ugly gstreamer0.10-tools - sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ - gstreamer0.10-ugly-plugins + If you use Arch Linux, install the following packages from the official + repository:: - If you use Fedora you can install GStreamer like this:: + sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ + gstreamer0.10-ugly-plugins - sudo yum install -y python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly gstreamer0.10-tools + If you use Fedora you can install GStreamer like this:: - If you use Gentoo you need to be careful because GStreamer 0.10 is in - a different lower slot than 1.0, the default. Your emerge commands will - need to include the slot:: + sudo yum install -y python-gst0.10 gstreamer0.10-plugins-good \ + gstreamer0.10-plugins-ugly gstreamer0.10-tools - emerge -av gst-python gst-plugins-bad:0.10 gst-plugins-good:0.10 \ - gst-plugins-ugly:0.10 gst-plugins-meta:0.10 + If you use Gentoo you need to be careful because GStreamer 0.10 is in a + different lower slot than 1.0, the default. Your emerge commands will need + to include the slot:: - gst-plugins-meta:0.10 is the one that actually pulls in the plugins - you want, so pay attention to the use flags, e.g. ``alsa``, ``mp3``, etc. + emerge -av gst-python gst-plugins-bad:0.10 gst-plugins-good:0.10 \ + gst-plugins-ugly:0.10 gst-plugins-meta:0.10 + + ``gst-plugins-meta:0.10`` is the one that actually pulls in the plugins you + want, so pay attention to the use flags, e.g. ``alsa``, ``mp3``, etc. + +#. Install the latest release of Mopidy:: + + sudo pip install -U mopidy + + To upgrade Mopidy to future releases, just rerun this command. + + Alternatively, if you want to track Mopidy development closer, you may + install a snapshot of Mopidy's ``develop`` Git branch using pip:: + + sudo pip install mopidy==dev + +#. Optional: If you want to use the HTTP frontend and web clients, you need + some additional dependencies:: + + sudo pip install -U mopidy[http] #. Optional: If you want Spotify support in Mopidy, you'll need to install - libspotify and the Python bindings, pyspotify. + libspotify and the Mopidy-Spotify extension. - #. First, check `pyspotify's changelog `_ to - see what's the latest version of libspotify which it supports. The - versions of libspotify and pyspotify are tightly coupled, so you'll need - to get this right. - - #. Download and install the appropriate version of libspotify for your OS - and CPU architecture from `Spotify + #. Download and install the latest version of libspotify for your OS and CPU + architecture from `Spotify `_. For libspotify 12.1.51 for 64-bit Linux the process is as follows:: @@ -221,7 +264,6 @@ can install Mopidy from PyPI using Pip. tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz cd libspotify-12.1.51-Linux-x86_64-release/ sudo make install prefix=/usr/local - sudo ldconfig Remember to adjust the above example for the latest libspotify version supported by pyspotify, your OS, and your CPU architecture. @@ -232,55 +274,31 @@ can install Mopidy from PyPI using Pip. su -c 'echo "/usr/local/lib" > /etc/ld.so.conf.d/libspotify.conf' sudo ldconfig - #. Then get, build, and install the latest release of pyspotify using Pip:: + #. Then install the latest release of Mopidy-Spotify using pip:: - sudo pip install -U pyspotify - - On Fedora the binary is called ``pip-python``:: - - sudo pip-python install -U pyspotify + sudo pip install -U mopidy-spotify #. Optional: If you want to scrobble your played tracks to Last.fm, you need - pylast:: + to install Mopidy-Scrobbler:: - sudo pip install -U pylast - - On Fedora the binary is called ``pip-python``:: - - sudo pip-python install -U pylast - -#. Optional: If you want to use the HTTP frontend and web clients, you need - cherrypy and ws4py:: - - sudo pip install -U cherrypy ws4py - - On Fedora the binary is called ``pip-python``:: - - sudo pip-python install -U cherrypy ws4py + sudo pip install -U mopidy-scrobbler #. Optional: To use Mopidy-MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound Menu or from an UPnP client via Rygel, you need some additional - dependencies: the Python bindings for libindicate, and the Python bindings - for libdbus, the reference D-Bus library. + dependencies and the Mopidy-MPRIS extension. - On Debian/Ubuntu:: + #. Install the Python bindings for libindicate, and the Python bindings for + libdbus, the reference D-Bus library. - sudo apt-get install python-dbus python-indicate + On Debian/Ubuntu:: -#. Then, to install the latest release of Mopidy:: + sudo apt-get install python-dbus python-indicate - sudo pip install -U mopidy + #. Then install the latest release of Mopidy-MPRIS using pip:: - On Fedora the binary is called ``pip-python``:: + sudo pip install -U mopidy-mpris - sudo pip-python install -U mopidy - - To upgrade Mopidy to future releases, just rerun this command. - - Alternatively, if you want to track Mopidy development closer, you may - install a snapshot of Mopidy's ``develop`` Git branch using Pip:: - - sudo pip install mopidy==dev +#. For more Mopidy extensions, see :ref:`ext`. #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. diff --git a/docs/_static/raspberry-pi-by-jwrodgers.jpg b/docs/installation/raspberry-pi-by-jwrodgers.jpg similarity index 100% rename from docs/_static/raspberry-pi-by-jwrodgers.jpg rename to docs/installation/raspberry-pi-by-jwrodgers.jpg diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index e266dee2..fe958e81 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -9,7 +9,7 @@ January 2013, Mopidy will run with Spotify support on both the armel (soft-float) and armhf (hard-float) architectures, which includes the Raspbian distribution. -.. image:: /_static/raspberry-pi-by-jwrodgers.jpg +.. image:: raspberry-pi-by-jwrodgers.jpg :width: 640 :height: 427 @@ -54,14 +54,6 @@ you a lot better performance. echo ipv6 | sudo tee -a /etc/modules -#. Installing Mopidy and its dependencies from `apt.mopidy.com - `_, as described in :ref:`installation`. In short:: - - wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add - - sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list - sudo apt-get update - sudo apt-get install mopidy - #. Since I have a HDMI cable connected, but want the sound on the analog sound connector, I have to run:: @@ -79,9 +71,15 @@ you a lot better performance. command to e.g. ``/etc/rc.local``, which will be executed when the system is booting. +#. Install Mopidy and its dependencies from `apt.mopidy.com + `_, as described in :ref:`installation`. -Fixing audio quality issues -=========================== +#. Finally, you need to set a couple of :doc:`config values `, and + then you're ready to :doc:`run Mopidy `. + + +Appendix: Fixing audio quality issues +===================================== As of about April 2013 the following steps should resolve any audio issues for HDMI and analog without the use of an external USB sound diff --git a/docs/modules/local.rst b/docs/modules/local.rst new file mode 100644 index 00000000..31ca6498 --- /dev/null +++ b/docs/modules/local.rst @@ -0,0 +1,9 @@ +************************************ +: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 + :members: diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/mpd.rst similarity index 57% rename from docs/modules/frontends/mpd.rst rename to docs/modules/mpd.rst index 750d19bb..4a9eb7e8 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/mpd.rst @@ -1,17 +1,17 @@ -***************************************** -:mod:`mopidy.frontends.mpd` -- MPD server -***************************************** +******************************* +:mod:`mopidy.mpd` -- MPD server +******************************* For details on how to use Mopidy's MPD server, see :ref:`ext-mpd`. -.. automodule:: mopidy.frontends.mpd +.. automodule:: mopidy.mpd :synopsis: MPD server frontend MPD dispatcher ============== -.. automodule:: mopidy.frontends.mpd.dispatcher +.. automodule:: mopidy.mpd.dispatcher :synopsis: MPD request dispatcher :members: @@ -19,7 +19,7 @@ MPD dispatcher MPD protocol ============ -.. automodule:: mopidy.frontends.mpd.protocol +.. automodule:: mopidy.mpd.protocol :synopsis: MPD protocol :members: @@ -27,7 +27,7 @@ MPD protocol Audio output ------------ -.. automodule:: mopidy.frontends.mpd.protocol.audio_output +.. automodule:: mopidy.mpd.protocol.audio_output :synopsis: MPD protocol: audio output :members: @@ -35,7 +35,7 @@ Audio output Channels -------- -.. automodule:: mopidy.frontends.mpd.protocol.channels +.. automodule:: mopidy.mpd.protocol.channels :synopsis: MPD protocol: channels -- client to client communication :members: @@ -43,7 +43,7 @@ Channels Command list ------------ -.. automodule:: mopidy.frontends.mpd.protocol.command_list +.. automodule:: mopidy.mpd.protocol.command_list :synopsis: MPD protocol: command list :members: @@ -51,7 +51,7 @@ Command list Connection ---------- -.. automodule:: mopidy.frontends.mpd.protocol.connection +.. automodule:: mopidy.mpd.protocol.connection :synopsis: MPD protocol: connection :members: @@ -59,7 +59,7 @@ Connection Current playlist ---------------- -.. automodule:: mopidy.frontends.mpd.protocol.current_playlist +.. automodule:: mopidy.mpd.protocol.current_playlist :synopsis: MPD protocol: current playlist :members: @@ -67,7 +67,7 @@ Current playlist Music database -------------- -.. automodule:: mopidy.frontends.mpd.protocol.music_db +.. automodule:: mopidy.mpd.protocol.music_db :synopsis: MPD protocol: music database :members: @@ -75,7 +75,7 @@ Music database Playback -------- -.. automodule:: mopidy.frontends.mpd.protocol.playback +.. automodule:: mopidy.mpd.protocol.playback :synopsis: MPD protocol: playback :members: @@ -83,7 +83,7 @@ Playback Reflection ---------- -.. automodule:: mopidy.frontends.mpd.protocol.reflection +.. automodule:: mopidy.mpd.protocol.reflection :synopsis: MPD protocol: reflection :members: @@ -91,7 +91,7 @@ Reflection Status ------ -.. automodule:: mopidy.frontends.mpd.protocol.status +.. automodule:: mopidy.mpd.protocol.status :synopsis: MPD protocol: status :members: @@ -99,7 +99,7 @@ Status Stickers -------- -.. automodule:: mopidy.frontends.mpd.protocol.stickers +.. automodule:: mopidy.mpd.protocol.stickers :synopsis: MPD protocol: stickers :members: @@ -107,6 +107,6 @@ Stickers Stored playlists ---------------- -.. automodule:: mopidy.frontends.mpd.protocol.stored_playlists +.. automodule:: mopidy.mpd.protocol.stored_playlists :synopsis: MPD protocol: stored playlists :members: diff --git a/requirements/docs.txt b/docs/requirements.txt similarity index 100% rename from requirements/docs.txt rename to docs/requirements.txt diff --git a/docs/running.rst b/docs/running.rst index 266545b2..d357afe6 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -20,8 +20,25 @@ Stopping Mopidy To stop Mopidy, press ``CTRL+C`` in the terminal where you started Mopidy. Mopidy will also shut down properly if you send it the TERM signal, e.g. by -using ``kill``:: +using ``pkill``:: - kill `ps ax | grep mopidy | grep -v grep | cut -d' ' -f1` + pkill mopidy -This can be useful e.g. if you create init script for managing Mopidy. + +Init scripts +============ + +- The ``mopidy`` package at `apt.mopidy.com `__ comes + with an `sysvinit init script + `_. + +- The ``mopidy`` package in `Arch Linux AUR + `__ comes with a systemd init + script. + +- A blog post by Benjamin Guillet explains how to `Daemonize Mopidy and Launch + It at Login on OS X + `_. + +- Issue :issue:`266` contains a bunch of init scripts for Mopidy, including + Upstart init scripts. diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index f344b1cf..9e065ed7 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -25,8 +25,8 @@ mailing list or when reporting an issue, somewhat longer text dumps are accepted, but large logs should still be shared through a pastebin. -Effective configuration -======================= +Show effective configuration +============================ The command ``mopidy config`` will print your full effective configuration the way Mopidy sees it after all defaults and all config files @@ -35,8 +35,8 @@ passwords are masked out, so the output of the command should be safe to share with others for debugging. -Installed dependencies -====================== +Show installed dependencies +=========================== The command ``mopidy deps`` will list the paths to and versions of any dependency Mopidy or the extensions might need to work. This is very useful @@ -48,11 +48,16 @@ your system. Debug logging ============= -If you run :option:`mopidy -v`, Mopidy will output debug log to stdout. If you -run :option:`mopidy --save-debug-log`, it will save the debug log to the file -``mopidy.log`` in the directory you ran the command from. +If you run :option:`mopidy -v` or ``mopidy -vv`` or ``mopidy -vvv`` Mopidy will +print more and more debug log to stdout. All three options will give you debug +level output from Mopidy and extensions, while ``-vv`` and ``-vvv`` will give +you more log output from their dependencies as well. -If you want to turn on more or less logging for some component, see the +If you run :option:`mopidy --save-debug-log`, it will save the log equivalent +with ``-vvv`` to the file ``mopidy.log`` in the directory you ran the command +from. + +If you want to reduce the logging for some component, see the docs for the :confval:`loglevels/*` config section. diff --git a/js/Gruntfile.js b/js/Gruntfile.js index 43a4770b..812ecec4 100644 --- a/js/Gruntfile.js +++ b/js/Gruntfile.js @@ -11,26 +11,43 @@ module.exports = function (grunt) { " * Licensed under the Apache License, Version 2.0 */\n", files: { own: ["Gruntfile.js", "src/**/*.js", "test/**/*-test.js"], - concat: "../mopidy/frontends/http/data/mopidy.js", - minified: "../mopidy/frontends/http/data/mopidy.min.js" + main: "src/mopidy.js", + concat: "../mopidy/http/data/mopidy.js", + minified: "../mopidy/http/data/mopidy.min.js" } }, buster: { all: {} }, - concat: { - options: { - banner: "<%= meta.banner %>", - stripBanners: true - }, - all: { + browserify: { + test_mopidy: { files: { - "<%= meta.files.concat %>": [ - "lib/bane-*.js", - "lib/when-define-shim.js", - "lib/when-*.js", - "src/mopidy.js" - ] + "test/lib/mopidy.js": "<%= meta.files.main %>" + }, + options: { + postBundleCB: function (err, src, next) { + next(null, grunt.template.process("<%= meta.banner %>") + src); + }, + standalone: "Mopidy" + } + }, + test_when: { + files: { + "test/lib/when.js": "node_modules/when/when.js" + }, + options: { + standalone: "when" + } + }, + dist: { + files: { + "<%= meta.files.concat %>": "<%= meta.files.main %>" + }, + options: { + postBundleCB: function (err, src, next) { + next(null, grunt.template.process("<%= meta.banner %>") + src); + }, + standalone: "Mopidy" } } }, @@ -70,12 +87,13 @@ module.exports = function (grunt) { } }); - grunt.registerTask("test", ["jshint", "buster"]); - grunt.registerTask("build", ["test", "concat", "uglify"]); + grunt.registerTask("test_build", ["browserify:test_when", "browserify:test_mopidy"]); + grunt.registerTask("test", ["jshint", "test_build", "buster"]); + grunt.registerTask("build", ["test", "browserify:dist", "uglify"]); grunt.registerTask("default", ["build"]); grunt.loadNpmTasks("grunt-buster"); - grunt.loadNpmTasks("grunt-contrib-concat"); + grunt.loadNpmTasks("grunt-browserify"); grunt.loadNpmTasks("grunt-contrib-jshint"); grunt.loadNpmTasks("grunt-contrib-uglify"); grunt.loadNpmTasks("grunt-contrib-watch"); diff --git a/js/README.md b/js/README.md index eddfa99f..5a04cd66 100644 --- a/js/README.md +++ b/js/README.md @@ -21,8 +21,8 @@ You may need to adjust hostname and port for your local setup. In the source repo, you can find the files at: -- `mopidy/frontends/http/data/mopidy.js` -- `mopidy/frontends/http/data/mopidy.min.js` +- `mopidy/http/data/mopidy.js` +- `mopidy/http/data/mopidy.min.js` Getting it for Node.js use @@ -35,7 +35,7 @@ Mopidy.js using npm: After npm completes, you can import Mopidy.js using ``require()``: - var Mopidy = require("mopidy").Mopidy; + var Mopidy = require("mopidy"); Using the library @@ -72,7 +72,7 @@ To run tests automatically when you save a file: npm start To run tests, concatenate, minify the source, and update the JavaScript files -in `mopidy/frontends/http/data/`: +in `mopidy/http/data/`: npm run-script build @@ -80,3 +80,26 @@ To run other [grunt](http://gruntjs.com/) targets which isn't predefined in `package.json` and thus isn't available through `npm run-script`: PATH=./node_modules/.bin:$PATH grunt foo + + +Changelog +--------- + +### 0.2.0 (2014-01-04) + +- **Backwards incompatible change for Node.js users:** + `var Mopidy = require('mopidy').Mopidy;` must be changed to + `var Mopidy = require('mopidy');` + +- Add support for [Browserify](http://browserify.org/). + +- Upgrade dependencies. + +### 0.1.1 (2013-09-17) + +- Upgrade dependencies. + +### 0.1.0 (2013-03-31) + +- Initial release as a Node.js module to the + [npm registry](https://npmjs.org/). diff --git a/js/buster.js b/js/buster.js index 1cc517c8..c5dec850 100644 --- a/js/buster.js +++ b/js/buster.js @@ -2,23 +2,13 @@ var config = module.exports; config.browser_tests = { environment: "browser", - libs: [ - "lib/bane-*.js", - "lib/when-define-shim.js", - "lib/when-*.js" - ], - sources: ["src/**/*.js"], + libs: ["test/lib/*.js"], testHelpers: ["test/**/*-helper.js"], tests: ["test/**/*-test.js"] }; config.node_tests = { environment: "node", - libs: [ - "lib/bane-*.js", - "lib/when-define-shim.js", - "lib/when-*.js" - ], sources: ["src/**/*.js"], testHelpers: ["test/**/*-helper.js"], tests: ["test/**/*-test.js"] diff --git a/js/lib/bane-1.0.0.js b/js/lib/bane-1.0.0.js deleted file mode 100644 index 8051764d..00000000 --- a/js/lib/bane-1.0.0.js +++ /dev/null @@ -1,171 +0,0 @@ -/** - * BANE - Browser globals, AMD and Node Events - * - * https://github.com/busterjs/bane - * - * @version 1.0.0 - */ - -((typeof define === "function" && define.amd && function (m) { define("bane", m); }) || - (typeof module === "object" && function (m) { module.exports = m(); }) || - function (m) { this.bane = m(); } -)(function () { - "use strict"; - var slice = Array.prototype.slice; - - function handleError(event, error, errbacks) { - var i, l = errbacks.length; - if (l > 0) { - for (i = 0; i < l; ++i) { errbacks[i](event, error); } - return; - } - setTimeout(function () { - error.message = event + " listener threw error: " + error.message; - throw error; - }, 0); - } - - function assertFunction(fn) { - if (typeof fn !== "function") { - throw new TypeError("Listener is not function"); - } - return fn; - } - - function supervisors(object) { - if (!object.supervisors) { object.supervisors = []; } - return object.supervisors; - } - - function listeners(object, event) { - if (!object.listeners) { object.listeners = {}; } - if (event && !object.listeners[event]) { object.listeners[event] = []; } - return event ? object.listeners[event] : object.listeners; - } - - function errbacks(object) { - if (!object.errbacks) { object.errbacks = []; } - return object.errbacks; - } - - /** - * @signature var emitter = bane.createEmitter([object]); - * - * Create a new event emitter. If an object is passed, it will be modified - * by adding the event emitter methods (see below). - */ - function createEventEmitter(object) { - object = object || {}; - - function notifyListener(event, listener, args) { - try { - listener.listener.apply(listener.thisp || object, args); - } catch (e) { - handleError(event, e, errbacks(object)); - } - } - - object.on = function (event, listener, thisp) { - if (typeof event === "function") { - return supervisors(this).push({ - listener: event, - thisp: listener - }); - } - listeners(this, event).push({ - listener: assertFunction(listener), - thisp: thisp - }); - }; - - object.off = function (event, listener) { - var fns, events, i, l; - if (!event) { - fns = supervisors(this); - fns.splice(0, fns.length); - - events = listeners(this); - for (i in events) { - if (events.hasOwnProperty(i)) { - fns = listeners(this, i); - fns.splice(0, fns.length); - } - } - - fns = errbacks(this); - fns.splice(0, fns.length); - - return; - } - if (typeof event === "function") { - fns = supervisors(this); - listener = event; - } else { - fns = listeners(this, event); - } - if (!listener) { - fns.splice(0, fns.length); - return; - } - for (i = 0, l = fns.length; i < l; ++i) { - if (fns[i].listener === listener) { - fns.splice(i, 1); - return; - } - } - }; - - object.once = function (event, listener, thisp) { - var wrapper = function () { - object.off(event, wrapper); - listener.apply(this, arguments); - }; - - object.on(event, wrapper, thisp); - }; - - object.bind = function (object, events) { - var prop, i, l; - if (!events) { - for (prop in object) { - if (typeof object[prop] === "function") { - this.on(prop, object[prop], object); - } - } - } else { - for (i = 0, l = events.length; i < l; ++i) { - if (typeof object[events[i]] === "function") { - this.on(events[i], object[events[i]], object); - } else { - throw new Error("No such method " + events[i]); - } - } - } - return object; - }; - - object.emit = function (event) { - var toNotify = supervisors(this); - var args = slice.call(arguments), i, l; - - for (i = 0, l = toNotify.length; i < l; ++i) { - notifyListener(event, toNotify[i], args); - } - - toNotify = listeners(this, event).slice(); - args = slice.call(arguments, 1); - for (i = 0, l = toNotify.length; i < l; ++i) { - notifyListener(event, toNotify[i], args); - } - }; - - object.errback = function (listener) { - if (!this.errbacks) { this.errbacks = []; } - this.errbacks.push(assertFunction(listener)); - }; - - return object; - } - - return { createEventEmitter: createEventEmitter }; -}); diff --git a/js/lib/websocket/browser.js b/js/lib/websocket/browser.js new file mode 100644 index 00000000..e594246c --- /dev/null +++ b/js/lib/websocket/browser.js @@ -0,0 +1 @@ +module.exports = { Client: window.WebSocket }; diff --git a/js/lib/websocket/package.json b/js/lib/websocket/package.json new file mode 100644 index 00000000..d1e2ac63 --- /dev/null +++ b/js/lib/websocket/package.json @@ -0,0 +1,4 @@ +{ + "browser": "browser.js", + "main": "server.js" +} diff --git a/js/lib/websocket/server.js b/js/lib/websocket/server.js new file mode 100644 index 00000000..dd24f4be --- /dev/null +++ b/js/lib/websocket/server.js @@ -0,0 +1 @@ +module.exports = require('faye-websocket'); diff --git a/js/lib/when-2.4.0.js b/js/lib/when-2.4.0.js deleted file mode 100644 index aa386275..00000000 --- a/js/lib/when-2.4.0.js +++ /dev/null @@ -1,922 +0,0 @@ -/** @license MIT License (c) copyright 2011-2013 original author or authors */ - -/** - * A lightweight CommonJS Promises/A and when() implementation - * when is part of the cujo.js family of libraries (http://cujojs.com/) - * - * Licensed under the MIT License at: - * http://www.opensource.org/licenses/mit-license.php - * - * @author Brian Cavalier - * @author John Hann - * @version 2.4.0 - */ -(function(define, global) { 'use strict'; -define(function (require) { - - // Public API - - when.promise = promise; // Create a pending promise - when.resolve = resolve; // Create a resolved promise - when.reject = reject; // Create a rejected promise - when.defer = defer; // Create a {promise, resolver} pair - - when.join = join; // Join 2 or more promises - - when.all = all; // Resolve a list of promises - when.map = map; // Array.map() for promises - when.reduce = reduce; // Array.reduce() for promises - when.settle = settle; // Settle a list of promises - - when.any = any; // One-winner race - when.some = some; // Multi-winner race - - when.isPromise = isPromiseLike; // DEPRECATED: use isPromiseLike - when.isPromiseLike = isPromiseLike; // Is something promise-like, aka thenable - - /** - * Register an observer for a promise or immediate value. - * - * @param {*} promiseOrValue - * @param {function?} [onFulfilled] callback to be called when promiseOrValue is - * successfully fulfilled. If promiseOrValue is an immediate value, callback - * will be invoked immediately. - * @param {function?} [onRejected] callback to be called when promiseOrValue is - * rejected. - * @param {function?} [onProgress] callback to be called when progress updates - * are issued for promiseOrValue. - * @returns {Promise} a new {@link Promise} that will complete with the return - * value of callback or errback or the completion value of promiseOrValue if - * callback and/or errback is not supplied. - */ - function when(promiseOrValue, onFulfilled, onRejected, onProgress) { - // Get a trusted promise for the input promiseOrValue, and then - // register promise handlers - return resolve(promiseOrValue).then(onFulfilled, onRejected, onProgress); - } - - /** - * Trusted Promise constructor. A Promise created from this constructor is - * a trusted when.js promise. Any other duck-typed promise is considered - * untrusted. - * @constructor - * @param {function} sendMessage function to deliver messages to the promise's handler - * @param {function?} inspect function that reports the promise's state - * @name Promise - */ - function Promise(sendMessage, inspect) { - this._message = sendMessage; - this.inspect = inspect; - } - - Promise.prototype = { - /** - * Register handlers for this promise. - * @param [onFulfilled] {Function} fulfillment handler - * @param [onRejected] {Function} rejection handler - * @param [onProgress] {Function} progress handler - * @return {Promise} new Promise - */ - then: function(onFulfilled, onRejected, onProgress) { - /*jshint unused:false*/ - var args, sendMessage; - - args = arguments; - sendMessage = this._message; - - return _promise(function(resolve, reject, notify) { - sendMessage('when', args, resolve, notify); - }, this._status && this._status.observed()); - }, - - /** - * Register a rejection handler. Shortcut for .then(undefined, onRejected) - * @param {function?} onRejected - * @return {Promise} - */ - otherwise: function(onRejected) { - return this.then(undef, onRejected); - }, - - /** - * Ensures that onFulfilledOrRejected will be called regardless of whether - * this promise is fulfilled or rejected. onFulfilledOrRejected WILL NOT - * receive the promises' value or reason. Any returned value will be disregarded. - * onFulfilledOrRejected may throw or return a rejected promise to signal - * an additional error. - * @param {function} onFulfilledOrRejected handler to be called regardless of - * fulfillment or rejection - * @returns {Promise} - */ - ensure: function(onFulfilledOrRejected) { - return this.then(injectHandler, injectHandler)['yield'](this); - - function injectHandler() { - return resolve(onFulfilledOrRejected()); - } - }, - - /** - * Shortcut for .then(function() { return value; }) - * @param {*} value - * @return {Promise} a promise that: - * - is fulfilled if value is not a promise, or - * - if value is a promise, will fulfill with its value, or reject - * with its reason. - */ - 'yield': function(value) { - return this.then(function() { - return value; - }); - }, - - /** - * Runs a side effect when this promise fulfills, without changing the - * fulfillment value. - * @param {function} onFulfilledSideEffect - * @returns {Promise} - */ - tap: function(onFulfilledSideEffect) { - return this.then(onFulfilledSideEffect)['yield'](this); - }, - - /** - * Assumes that this promise will fulfill with an array, and arranges - * for the onFulfilled to be called with the array as its argument list - * i.e. onFulfilled.apply(undefined, array). - * @param {function} onFulfilled function to receive spread arguments - * @return {Promise} - */ - spread: function(onFulfilled) { - return this.then(function(array) { - // array may contain promises, so resolve its contents. - return all(array, function(array) { - return onFulfilled.apply(undef, array); - }); - }); - }, - - /** - * Shortcut for .then(onFulfilledOrRejected, onFulfilledOrRejected) - * @deprecated - */ - always: function(onFulfilledOrRejected, onProgress) { - return this.then(onFulfilledOrRejected, onFulfilledOrRejected, onProgress); - } - }; - - /** - * Returns a resolved promise. The returned promise will be - * - fulfilled with promiseOrValue if it is a value, or - * - if promiseOrValue is a promise - * - fulfilled with promiseOrValue's value after it is fulfilled - * - rejected with promiseOrValue's reason after it is rejected - * @param {*} value - * @return {Promise} - */ - function resolve(value) { - return promise(function(resolve) { - resolve(value); - }); - } - - /** - * Returns a rejected promise for the supplied promiseOrValue. The returned - * promise will be rejected with: - * - promiseOrValue, if it is a value, or - * - if promiseOrValue is a promise - * - promiseOrValue's value after it is fulfilled - * - promiseOrValue's reason after it is rejected - * @param {*} promiseOrValue the rejected value of the returned {@link Promise} - * @return {Promise} rejected {@link Promise} - */ - function reject(promiseOrValue) { - return when(promiseOrValue, rejected); - } - - /** - * Creates a {promise, resolver} pair, either or both of which - * may be given out safely to consumers. - * The resolver has resolve, reject, and progress. The promise - * has then plus extended promise API. - * - * @return {{ - * promise: Promise, - * resolve: function:Promise, - * reject: function:Promise, - * notify: function:Promise - * resolver: { - * resolve: function:Promise, - * reject: function:Promise, - * notify: function:Promise - * }}} - */ - function defer() { - var deferred, pending, resolved; - - // Optimize object shape - deferred = { - promise: undef, resolve: undef, reject: undef, notify: undef, - resolver: { resolve: undef, reject: undef, notify: undef } - }; - - deferred.promise = pending = promise(makeDeferred); - - return deferred; - - function makeDeferred(resolvePending, rejectPending, notifyPending) { - deferred.resolve = deferred.resolver.resolve = function(value) { - if(resolved) { - return resolve(value); - } - resolved = true; - resolvePending(value); - return pending; - }; - - deferred.reject = deferred.resolver.reject = function(reason) { - if(resolved) { - return resolve(rejected(reason)); - } - resolved = true; - rejectPending(reason); - return pending; - }; - - deferred.notify = deferred.resolver.notify = function(update) { - notifyPending(update); - return update; - }; - } - } - - /** - * Creates a new promise whose fate is determined by resolver. - * @param {function} resolver function(resolve, reject, notify) - * @returns {Promise} promise whose fate is determine by resolver - */ - function promise(resolver) { - return _promise(resolver, monitorApi.PromiseStatus && monitorApi.PromiseStatus()); - } - - /** - * Creates a new promise, linked to parent, whose fate is determined - * by resolver. - * @param {function} resolver function(resolve, reject, notify) - * @param {Promise?} status promise from which the new promise is begotten - * @returns {Promise} promise whose fate is determine by resolver - * @private - */ - function _promise(resolver, status) { - var self, value, consumers = []; - - self = new Promise(_message, inspect); - self._status = status; - - // Call the provider resolver to seal the promise's fate - try { - resolver(promiseResolve, promiseReject, promiseNotify); - } catch(e) { - promiseReject(e); - } - - // Return the promise - return self; - - /** - * Private message delivery. Queues and delivers messages to - * the promise's ultimate fulfillment value or rejection reason. - * @private - * @param {String} type - * @param {Array} args - * @param {Function} resolve - * @param {Function} notify - */ - function _message(type, args, resolve, notify) { - consumers ? consumers.push(deliver) : enqueue(function() { deliver(value); }); - - function deliver(p) { - p._message(type, args, resolve, notify); - } - } - - /** - * Returns a snapshot of the promise's state at the instant inspect() - * is called. The returned object is not live and will not update as - * the promise's state changes. - * @returns {{ state:String, value?:*, reason?:* }} status snapshot - * of the promise. - */ - function inspect() { - return value ? value.inspect() : toPendingState(); - } - - /** - * Transition from pre-resolution state to post-resolution state, notifying - * all listeners of the ultimate fulfillment or rejection - * @param {*|Promise} val resolution value - */ - function promiseResolve(val) { - if(!consumers) { - return; - } - - value = coerce(val); - scheduleConsumers(consumers, value); - consumers = undef; - - if(status) { - updateStatus(value, status); - } - } - - /** - * Reject this promise with the supplied reason, which will be used verbatim. - * @param {*} reason reason for the rejection - */ - function promiseReject(reason) { - promiseResolve(rejected(reason)); - } - - /** - * Issue a progress event, notifying all progress listeners - * @param {*} update progress event payload to pass to all listeners - */ - function promiseNotify(update) { - if(consumers) { - scheduleConsumers(consumers, progressed(update)); - } - } - } - - /** - * Creates a fulfilled, local promise as a proxy for a value - * NOTE: must never be exposed - * @param {*} value fulfillment value - * @returns {Promise} - */ - function fulfilled(value) { - return near( - new NearFulfilledProxy(value), - function() { return toFulfilledState(value); } - ); - } - - /** - * Creates a rejected, local promise with the supplied reason - * NOTE: must never be exposed - * @param {*} reason rejection reason - * @returns {Promise} - */ - function rejected(reason) { - return near( - new NearRejectedProxy(reason), - function() { return toRejectedState(reason); } - ); - } - - /** - * Creates a near promise using the provided proxy - * NOTE: must never be exposed - * @param {object} proxy proxy for the promise's ultimate value or reason - * @param {function} inspect function that returns a snapshot of the - * returned near promise's state - * @returns {Promise} - */ - function near(proxy, inspect) { - return new Promise(function (type, args, resolve) { - try { - resolve(proxy[type].apply(proxy, args)); - } catch(e) { - resolve(rejected(e)); - } - }, inspect); - } - - /** - * Create a progress promise with the supplied update. - * @private - * @param {*} update - * @return {Promise} progress promise - */ - function progressed(update) { - return new Promise(function (type, args, _, notify) { - var onProgress = args[2]; - try { - notify(typeof onProgress === 'function' ? onProgress(update) : update); - } catch(e) { - notify(e); - } - }); - } - - /** - * Coerces x to a trusted Promise - * - * @private - * @param {*} x thing to coerce - * @returns {*} Guaranteed to return a trusted Promise. If x - * is trusted, returns x, otherwise, returns a new, trusted, already-resolved - * Promise whose resolution value is: - * * the resolution value of x if it's a foreign promise, or - * * x if it's a value - */ - function coerce(x) { - if (x instanceof Promise) { - return x; - } - - if (!(x === Object(x) && 'then' in x)) { - return fulfilled(x); - } - - return promise(function(resolve, reject, notify) { - enqueue(function() { - try { - // We must check and assimilate in the same tick, but not the - // current tick, careful only to access promiseOrValue.then once. - var untrustedThen = x.then; - - if(typeof untrustedThen === 'function') { - fcall(untrustedThen, x, resolve, reject, notify); - } else { - // It's a value, create a fulfilled wrapper - resolve(fulfilled(x)); - } - - } catch(e) { - // Something went wrong, reject - reject(e); - } - }); - }); - } - - /** - * Proxy for a near, fulfilled value - * @param {*} value - * @constructor - */ - function NearFulfilledProxy(value) { - this.value = value; - } - - NearFulfilledProxy.prototype.when = function(onResult) { - return typeof onResult === 'function' ? onResult(this.value) : this.value; - }; - - /** - * Proxy for a near rejection - * @param {*} reason - * @constructor - */ - function NearRejectedProxy(reason) { - this.reason = reason; - } - - NearRejectedProxy.prototype.when = function(_, onError) { - if(typeof onError === 'function') { - return onError(this.reason); - } else { - throw this.reason; - } - }; - - /** - * Schedule a task that will process a list of handlers - * in the next queue drain run. - * @private - * @param {Array} handlers queue of handlers to execute - * @param {*} value passed as the only arg to each handler - */ - function scheduleConsumers(handlers, value) { - enqueue(function() { - var handler, i = 0; - while (handler = handlers[i++]) { - handler(value); - } - }); - } - - function updateStatus(value, status) { - value.then(statusFulfilled, statusRejected); - - function statusFulfilled() { status.fulfilled(); } - function statusRejected(r) { status.rejected(r); } - } - - /** - * Determines if x is promise-like, i.e. a thenable object - * NOTE: Will return true for *any thenable object*, and isn't truly - * safe, since it may attempt to access the `then` property of x (i.e. - * clever/malicious getters may do weird things) - * @param {*} x anything - * @returns {boolean} true if x is promise-like - */ - function isPromiseLike(x) { - return x && typeof x.then === 'function'; - } - - /** - * Initiates a competitive race, returning a promise that will resolve when - * howMany of the supplied promisesOrValues have resolved, or will reject when - * it becomes impossible for howMany to resolve, for example, when - * (promisesOrValues.length - howMany) + 1 input promises reject. - * - * @param {Array} promisesOrValues array of anything, may contain a mix - * of promises and values - * @param howMany {number} number of promisesOrValues to resolve - * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() - * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() - * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() - * @returns {Promise} promise that will resolve to an array of howMany values that - * resolved first, or will reject with an array of - * (promisesOrValues.length - howMany) + 1 rejection reasons. - */ - function some(promisesOrValues, howMany, onFulfilled, onRejected, onProgress) { - - return when(promisesOrValues, function(promisesOrValues) { - - return promise(resolveSome).then(onFulfilled, onRejected, onProgress); - - function resolveSome(resolve, reject, notify) { - var toResolve, toReject, values, reasons, fulfillOne, rejectOne, len, i; - - len = promisesOrValues.length >>> 0; - - toResolve = Math.max(0, Math.min(howMany, len)); - values = []; - - toReject = (len - toResolve) + 1; - reasons = []; - - // No items in the input, resolve immediately - if (!toResolve) { - resolve(values); - - } else { - rejectOne = function(reason) { - reasons.push(reason); - if(!--toReject) { - fulfillOne = rejectOne = identity; - reject(reasons); - } - }; - - fulfillOne = function(val) { - // This orders the values based on promise resolution order - values.push(val); - if (!--toResolve) { - fulfillOne = rejectOne = identity; - resolve(values); - } - }; - - for(i = 0; i < len; ++i) { - if(i in promisesOrValues) { - when(promisesOrValues[i], fulfiller, rejecter, notify); - } - } - } - - function rejecter(reason) { - rejectOne(reason); - } - - function fulfiller(val) { - fulfillOne(val); - } - } - }); - } - - /** - * Initiates a competitive race, returning a promise that will resolve when - * any one of the supplied promisesOrValues has resolved or will reject when - * *all* promisesOrValues have rejected. - * - * @param {Array|Promise} promisesOrValues array of anything, may contain a mix - * of {@link Promise}s and values - * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() - * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() - * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() - * @returns {Promise} promise that will resolve to the value that resolved first, or - * will reject with an array of all rejected inputs. - */ - function any(promisesOrValues, onFulfilled, onRejected, onProgress) { - - function unwrapSingleResult(val) { - return onFulfilled ? onFulfilled(val[0]) : val[0]; - } - - return some(promisesOrValues, 1, unwrapSingleResult, onRejected, onProgress); - } - - /** - * Return a promise that will resolve only once all the supplied promisesOrValues - * have resolved. The resolution value of the returned promise will be an array - * containing the resolution values of each of the promisesOrValues. - * @memberOf when - * - * @param {Array|Promise} promisesOrValues array of anything, may contain a mix - * of {@link Promise}s and values - * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() - * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() - * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() - * @returns {Promise} - */ - function all(promisesOrValues, onFulfilled, onRejected, onProgress) { - return _map(promisesOrValues, identity).then(onFulfilled, onRejected, onProgress); - } - - /** - * Joins multiple promises into a single returned promise. - * @return {Promise} a promise that will fulfill when *all* the input promises - * have fulfilled, or will reject when *any one* of the input promises rejects. - */ - function join(/* ...promises */) { - return _map(arguments, identity); - } - - /** - * Settles all input promises such that they are guaranteed not to - * be pending once the returned promise fulfills. The returned promise - * will always fulfill, except in the case where `array` is a promise - * that rejects. - * @param {Array|Promise} array or promise for array of promises to settle - * @returns {Promise} promise that always fulfills with an array of - * outcome snapshots for each input promise. - */ - function settle(array) { - return _map(array, toFulfilledState, toRejectedState); - } - - /** - * Promise-aware array map function, similar to `Array.prototype.map()`, - * but input array may contain promises or values. - * @param {Array|Promise} array array of anything, may contain promises and values - * @param {function} mapFunc map function which may return a promise or value - * @returns {Promise} promise that will fulfill with an array of mapped values - * or reject if any input promise rejects. - */ - function map(array, mapFunc) { - return _map(array, mapFunc); - } - - /** - * Internal map that allows a fallback to handle rejections - * @param {Array|Promise} array array of anything, may contain promises and values - * @param {function} mapFunc map function which may return a promise or value - * @param {function?} fallback function to handle rejected promises - * @returns {Promise} promise that will fulfill with an array of mapped values - * or reject if any input promise rejects. - */ - function _map(array, mapFunc, fallback) { - return when(array, function(array) { - - return _promise(resolveMap); - - function resolveMap(resolve, reject, notify) { - var results, len, toResolve, i; - - // Since we know the resulting length, we can preallocate the results - // array to avoid array expansions. - toResolve = len = array.length >>> 0; - results = []; - - if(!toResolve) { - resolve(results); - return; - } - - // Since mapFunc may be async, get all invocations of it into flight - for(i = 0; i < len; i++) { - if(i in array) { - resolveOne(array[i], i); - } else { - --toResolve; - } - } - - function resolveOne(item, i) { - when(item, mapFunc, fallback).then(function(mapped) { - results[i] = mapped; - notify(mapped); - - if(!--toResolve) { - resolve(results); - } - }, reject); - } - } - }); - } - - /** - * Traditional reduce function, similar to `Array.prototype.reduce()`, but - * input may contain promises and/or values, and reduceFunc - * may return either a value or a promise, *and* initialValue may - * be a promise for the starting value. - * - * @param {Array|Promise} promise array or promise for an array of anything, - * may contain a mix of promises and values. - * @param {function} reduceFunc reduce function reduce(currentValue, nextValue, index, total), - * where total is the total number of items being reduced, and will be the same - * in each call to reduceFunc. - * @returns {Promise} that will resolve to the final reduced value - */ - function reduce(promise, reduceFunc /*, initialValue */) { - var args = fcall(slice, arguments, 1); - - return when(promise, function(array) { - var total; - - total = array.length; - - // Wrap the supplied reduceFunc with one that handles promises and then - // delegates to the supplied. - args[0] = function (current, val, i) { - return when(current, function (c) { - return when(val, function (value) { - return reduceFunc(c, value, i, total); - }); - }); - }; - - return reduceArray.apply(array, args); - }); - } - - // Snapshot states - - /** - * Creates a fulfilled state snapshot - * @private - * @param {*} x any value - * @returns {{state:'fulfilled',value:*}} - */ - function toFulfilledState(x) { - return { state: 'fulfilled', value: x }; - } - - /** - * Creates a rejected state snapshot - * @private - * @param {*} x any reason - * @returns {{state:'rejected',reason:*}} - */ - function toRejectedState(x) { - return { state: 'rejected', reason: x }; - } - - /** - * Creates a pending state snapshot - * @private - * @returns {{state:'pending'}} - */ - function toPendingState() { - return { state: 'pending' }; - } - - // - // Internals, utilities, etc. - // - - var reduceArray, slice, fcall, nextTick, handlerQueue, - setTimeout, funcProto, call, arrayProto, monitorApi, - cjsRequire, undef; - - cjsRequire = require; - - // - // Shared handler queue processing - // - // Credit to Twisol (https://github.com/Twisol) for suggesting - // this type of extensible queue + trampoline approach for - // next-tick conflation. - - handlerQueue = []; - - /** - * Enqueue a task. If the queue is not currently scheduled to be - * drained, schedule it. - * @param {function} task - */ - function enqueue(task) { - if(handlerQueue.push(task) === 1) { - nextTick(drainQueue); - } - } - - /** - * Drain the handler queue entirely, being careful to allow the - * queue to be extended while it is being processed, and to continue - * processing until it is truly empty. - */ - function drainQueue() { - var task, i = 0; - - while(task = handlerQueue[i++]) { - task(); - } - - handlerQueue = []; - } - - // capture setTimeout to avoid being caught by fake timers - // used in time based tests - setTimeout = global.setTimeout; - - // Allow attaching the monitor to when() if env has no console - monitorApi = typeof console != 'undefined' ? console : when; - - // Prefer setImmediate or MessageChannel, cascade to node, - // vertx and finally setTimeout - /*global setImmediate,MessageChannel,process*/ - if (typeof setImmediate === 'function') { - nextTick = setImmediate.bind(global); - } else if(typeof MessageChannel !== 'undefined') { - var channel = new MessageChannel(); - channel.port1.onmessage = drainQueue; - nextTick = function() { channel.port2.postMessage(0); }; - } else if (typeof process === 'object' && process.nextTick) { - nextTick = process.nextTick; - } else { - try { - // vert.x 1.x || 2.x - nextTick = cjsRequire('vertx').runOnLoop || cjsRequire('vertx').runOnContext; - } catch(ignore) { - nextTick = function(t) { setTimeout(t, 0); }; - } - } - - // - // Capture/polyfill function and array utils - // - - // Safe function calls - funcProto = Function.prototype; - call = funcProto.call; - fcall = funcProto.bind - ? call.bind(call) - : function(f, context) { - return f.apply(context, slice.call(arguments, 2)); - }; - - // Safe array ops - arrayProto = []; - slice = arrayProto.slice; - - // ES5 reduce implementation if native not available - // See: http://es5.github.com/#x15.4.4.21 as there are many - // specifics and edge cases. ES5 dictates that reduce.length === 1 - // This implementation deviates from ES5 spec in the following ways: - // 1. It does not check if reduceFunc is a Callable - reduceArray = arrayProto.reduce || - function(reduceFunc /*, initialValue */) { - /*jshint maxcomplexity: 7*/ - var arr, args, reduced, len, i; - - i = 0; - arr = Object(this); - len = arr.length >>> 0; - args = arguments; - - // If no initialValue, use first item of array (we know length !== 0 here) - // and adjust i to start at second item - if(args.length <= 1) { - // Skip to the first real element in the array - for(;;) { - if(i in arr) { - reduced = arr[i++]; - break; - } - - // If we reached the end of the array without finding any real - // elements, it's a TypeError - if(++i >= len) { - throw new TypeError(); - } - } - } else { - // If initialValue provided, use it - reduced = args[1]; - } - - // Do the actual reduce - for(;i < len; ++i) { - if(i in arr) { - reduced = reduceFunc(reduced, arr[i], i, arr); - } - } - - return reduced; - }; - - function identity(x) { - return x; - } - - return when; -}); -})(typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(require); }, this); diff --git a/js/lib/when-define-shim.js b/js/lib/when-define-shim.js deleted file mode 100644 index ad135517..00000000 --- a/js/lib/when-define-shim.js +++ /dev/null @@ -1,11 +0,0 @@ -if (typeof window !== "undefined") { - window.define = function (factory) { - try { - delete window.define; - } catch (e) { - window.define = void 0; // IE - } - window.when = factory(); - }; - window.define.amd = {}; -} diff --git a/js/package.json b/js/package.json index 5b8e46d8..d16cfaa9 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "mopidy", - "version": "0.1.1", + "version": "0.2.0", "description": "Client lib for controlling a Mopidy music server over a WebSocket", "homepage": "http://www.mopidy.com/", "author": { @@ -14,19 +14,19 @@ }, "main": "src/mopidy.js", "dependencies": { - "bane": "~1.0.0", - "faye-websocket": "~0.7.0", - "when": "~2.4.0" + "bane": "~1.1.0", + "faye-websocket": "~0.7.2", + "when": "~2.7.1" }, "devDependencies": { - "buster": "~0.6.13", - "grunt": "~0.4.1", - "grunt-buster": "~0.2.1", - "grunt-contrib-concat": "~0.3.0", - "grunt-contrib-jshint": "~0.6.4", - "grunt-contrib-uglify": "~0.2.4", + "buster": "~0.7.8", + "grunt": "~0.4.2", + "grunt-buster": "~0.3.1", + "grunt-browserify": "~1.3.0", + "grunt-contrib-jshint": "~0.8.0", + "grunt-contrib-uglify": "~0.2.7", "grunt-contrib-watch": "~0.5.3", - "phantomjs": "~1.9.2-0" + "phantomjs": "~1.9.2-6" }, "scripts": { "test": "grunt test", diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 980256b5..1667f9b1 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -1,10 +1,8 @@ -/*global exports:false, require:false*/ +/*global module:true, require:false*/ -if (typeof module === "object" && typeof require === "function") { - var bane = require("bane"); - var websocket = require("faye-websocket"); - var when = require("when"); -} +var bane = require("bane"); +var websocket = require("../lib/websocket/"); +var when = require("when"); function Mopidy(settings) { if (!(this instanceof Mopidy)) { @@ -26,11 +24,7 @@ function Mopidy(settings) { } } -if (typeof module === "object" && typeof require === "function") { - Mopidy.WebSocket = websocket.Client; -} else { - Mopidy.WebSocket = window.WebSocket; -} +Mopidy.WebSocket = websocket.Client; Mopidy.prototype._configure = function (settings) { var currentHost = (typeof document !== "undefined" && @@ -295,6 +289,4 @@ Mopidy.prototype._snakeToCamel = function (name) { }); }; -if (typeof exports === "object") { - exports.Mopidy = Mopidy; -} +module.exports = Mopidy; diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 0bf97f60..9f2509fc 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -1,11 +1,14 @@ -/*global require:false, assert:false, refute:false*/ +/*global require:false */ if (typeof module === "object" && typeof require === "function") { var buster = require("buster"); - var Mopidy = require("../src/mopidy").Mopidy; + var Mopidy = require("../src/mopidy"); var when = require("when"); } +var assert = buster.assert; +var refute = buster.refute; + buster.testCase("Mopidy", { setUp: function () { // Sinon.JS doesn't manage to stub PhantomJS' WebSocket implementation, diff --git a/mopidy/__init__.py b/mopidy/__init__.py index a03e1cb9..623e202e 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.17.0' +__version__ = '0.18.0' diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 82e4569b..ac5e2102 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import print_function, unicode_literals import logging import os @@ -29,7 +29,7 @@ from mopidy import commands, ext from mopidy import config as config_lib from mopidy.utils import log, path, process, versioning -logger = logging.getLogger('mopidy.main') +logger = logging.getLogger(__name__) def main(): @@ -40,11 +40,13 @@ def main(): signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks) try: + registry = ext.Registry() + root_cmd = commands.RootCommand() config_cmd = commands.ConfigCommand() deps_cmd = commands.DepsCommand() - root_cmd.set(extension=None) + root_cmd.set(extension=None, registry=registry) root_cmd.add_child('config', config_cmd) root_cmd.add_child('deps', deps_cmd) @@ -68,7 +70,8 @@ def main(): if args.verbosity_level: verbosity_level += args.verbosity_level - log.setup_logging(config, verbosity_level, args.save_debug_log) + log.setup_logging( + config, installed_extensions, verbosity_level, args.save_debug_log) enabled_extensions = [] for extension in installed_extensions: @@ -84,7 +87,6 @@ def main(): enabled_extensions.append(extension) log_extension_info(installed_extensions, enabled_extensions) - ext.register_gstreamer_elements(enabled_extensions) # Config and deps commands are simply special cased for now. if args.command == config_cmd: @@ -108,12 +110,15 @@ def main(): args.extension.ext_name) return 1 + for extension in enabled_extensions: + extension.setup(registry) + # Anything that wants to exit after this point must use # mopidy.utils.process.exit_process as actors can have been started. try: - return args.command.run(args, proxied_config, enabled_extensions) + return args.command.run(args, proxied_config) except NotImplementedError: - print root_cmd.format_help() + print(root_cmd.format_help()) return 1 except KeyboardInterrupt: @@ -129,7 +134,7 @@ def create_file_structures_and_config(args, extensions): # Initialize whatever the last config file is with defaults config_file = args.config_files[-1] - if os.path.exists(config_file): + if os.path.exists(path.expand_path(config_file)): return try: @@ -161,9 +166,9 @@ def log_extension_info(all_extensions, enabled_extensions): # TODO: distinguish disabled vs blocked by env? enabled_names = set(e.ext_name for e in enabled_extensions) disabled_names = set(e.ext_name for e in all_extensions) - enabled_names - logging.info( + logger.info( 'Enabled extensions: %s', ', '.join(enabled_names) or 'none') - logging.info( + logger.info( 'Disabled extensions: %s', ', '.join(disabled_names) or 'none') diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 5c931865..ca023125 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -15,7 +15,7 @@ from . import mixers, playlists, utils from .constants import PlaybackState from .listener import AudioListener -logger = logging.getLogger('mopidy.audio') +logger = logging.getLogger(__name__) mixers.register_mixers() @@ -184,6 +184,7 @@ class Audio(pykka.ThreadingActor): def _setup_mixer(self): mixer_desc = self._config['audio']['mixer'] track_desc = self._config['audio']['mixer_track'] + volume = self._config['audio']['mixer_volume'] if mixer_desc is None: logger.info('Not setting up audio mixer') @@ -192,6 +193,9 @@ class Audio(pykka.ThreadingActor): if mixer_desc == 'software': self._software_mixing = True logger.info('Audio mixer is using software mixing') + if volume is not None: + self.set_volume(volume) + logger.info('Audio mixer volume set to %d', volume) return try: @@ -223,11 +227,16 @@ class Audio(pykka.ThreadingActor): self._mixer_track = track self._mixer_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) + logger.info( 'Audio mixer set to "%s" using track "%s"', str(mixer.get_factory().get_name()).decode('utf-8'), str(track.label).decode('utf-8')) + if volume is not None: + self.set_volume(volume) + logger.info('Audio mixer volume set to %d', volume) + def _select_mixer_track(self, mixer, track_label): # Ignore tracks without volumes, then look for track with # label equal to the audio/mixer_track config value, otherwise fallback diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 464407b4..537a81dd 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals -import pykka +from mopidy import listener -class AudioListener(object): +class AudioListener(listener.Listener): """ Marker interface for recipients of events sent by the audio actor. @@ -17,25 +17,7 @@ class AudioListener(object): @staticmethod def send(event, **kwargs): """Helper to allow calling of audio listener events""" - listeners = pykka.ActorRegistry.get_by_class(AudioListener) - for listener in listeners: - listener.proxy().on_event(event, **kwargs) - - def on_event(self, event, **kwargs): - """ - Called on all events. - - *MAY* be implemented by actor. By default, this method forwards the - event to the specific event methods. - - For a list of what event names to expect, see the names of the other - methods in :class:`AudioListener`. - - :param event: the event name - :type event: string - :param kwargs: any other arguments to the specific event handlers - """ - getattr(self, event)(**kwargs) + listener.send_async(AudioListener, event, **kwargs) def reached_end_of_stream(self): """ diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index 023674bf..7e59b602 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -12,7 +12,7 @@ import gst import logging -logger = logging.getLogger('mopidy.audio.mixers.auto') +logger = logging.getLogger(__name__) # TODO: we might want to add some ranking to the mixers we know about? diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 6eb5576c..56f385e3 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -24,24 +24,24 @@ class Scanner(object): """ def __init__(self, timeout=1000, min_duration=100): - self.timeout_ms = timeout - self.min_duration_ms = min_duration + self._timeout_ms = timeout + self._min_duration_ms = min_duration sink = gst.element_factory_make('fakesink') audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') pad_added = lambda src, pad: pad.link(sink.get_pad('sink')) - self.uribin = gst.element_factory_make('uridecodebin') - self.uribin.set_property('caps', audio_caps) - self.uribin.connect('pad-added', pad_added) + self._uribin = gst.element_factory_make('uridecodebin') + self._uribin.set_property('caps', audio_caps) + self._uribin.connect('pad-added', pad_added) - self.pipe = gst.element_factory_make('pipeline') - self.pipe.add(self.uribin) - self.pipe.add(sink) + self._pipe = gst.element_factory_make('pipeline') + self._pipe.add(self._uribin) + self._pipe.add(sink) - self.bus = self.pipe.get_bus() - self.bus.set_flushing(True) + self._bus = self._pipe.get_bus() + self._bus.set_flushing(True) def scan(self, uri): """ @@ -53,60 +53,71 @@ class Scanner(object): """ try: self._setup(uri) - data = self._collect() - # Make sure uri and duration does not come from tags. - data[b'uri'] = uri - data[b'mtime'] = self._query_mtime(uri) - data[gst.TAG_DURATION] = self._query_duration() + tags = self._collect() # Ensure collect before queries. + data = {'uri': uri, 'tags': tags, + 'mtime': self._query_mtime(uri), + 'duration': self._query_duration()} finally: self._reset() - if data[gst.TAG_DURATION] < self.min_duration_ms * gst.MSECOND: - raise exceptions.ScannerError('Rejecting file with less than %dms ' - 'audio data.' % self.min_duration_ms) - return data + if self._min_duration_ms is None: + return data + elif data['duration'] >= self._min_duration_ms * gst.MSECOND: + return data + + raise exceptions.ScannerError('Rejecting file with less than %dms ' + 'audio data.' % self._min_duration_ms) def _setup(self, uri): """Primes the pipeline for collection.""" - self.pipe.set_state(gst.STATE_READY) - self.uribin.set_property(b'uri', uri) - self.bus.set_flushing(False) - self.pipe.set_state(gst.STATE_PAUSED) + self._pipe.set_state(gst.STATE_READY) + self._uribin.set_property(b'uri', uri) + self._bus.set_flushing(False) + result = self._pipe.set_state(gst.STATE_PAUSED) + if result == gst.STATE_CHANGE_NO_PREROLL: + # Live sources don't pre-roll, so set to playing to get data. + self._pipe.set_state(gst.STATE_PLAYING) def _collect(self): """Polls for messages to collect data.""" start = time.time() - timeout_s = self.timeout_ms / float(1000) - poll_timeout_ns = 1000 - data = {} + timeout_s = self._timeout_ms / float(1000) + tags = {} while time.time() - start < timeout_s: - message = self.bus.poll(gst.MESSAGE_ANY, poll_timeout_ns) + if not self._bus.have_pending(): + continue + message = self._bus.pop() - if message is None: - pass # polling the bus timed out. - elif message.type == gst.MESSAGE_ERROR: + if message.type == gst.MESSAGE_ERROR: raise exceptions.ScannerError(message.parse_error()[0]) elif message.type == gst.MESSAGE_EOS: - return data + return tags elif message.type == gst.MESSAGE_ASYNC_DONE: - if message.src == self.pipe: - return data + if message.src == self._pipe: + return tags elif message.type == gst.MESSAGE_TAG: + # Taglists are not really dicts, hence the lack of .items() and + # explicit .keys. We only keep the last tag for each key, as we + # assume this is the best, some formats will produce multiple + # taglists. Lastly we force everything to lists for conformity. taglist = message.parse_tag() for key in taglist.keys(): - data[key] = taglist[key] + value = taglist[key] + if not isinstance(value, list): + value = [value] + tags[key] = value - raise exceptions.ScannerError('Timeout after %dms' % self.timeout_ms) + raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) def _reset(self): """Ensures we cleanup child elements and flush the bus.""" - self.bus.set_flushing(True) - self.pipe.set_state(gst.STATE_NULL) + self._bus.set_flushing(True) + self._pipe.set_state(gst.STATE_NULL) def _query_duration(self): try: - return self.pipe.query_duration(gst.FORMAT_TIME, None)[0] + return self._pipe.query_duration(gst.FORMAT_TIME, None)[0] except gst.QueryError: return None @@ -116,78 +127,70 @@ class Scanner(object): return os.path.getmtime(path.uri_to_path(uri)) +def _artists(tags, artist_name, artist_id=None): + # Name missing, don't set artist + if not tags.get(artist_name): + return None + # One artist name and id, provide artist with id. + if len(tags[artist_name]) == 1 and artist_id in tags: + return [Artist(name=tags[artist_name][0], + musicbrainz_id=tags[artist_id][0])] + # Multiple artist, provide artists without id. + return [Artist(name=name) for name in tags[artist_name]] + + +def _date(tags): + if not tags.get(gst.TAG_DATE): + return None + try: + date = tags[gst.TAG_DATE][0] + return datetime.date(date.year, date.month, date.day).isoformat() + except ValueError: + return None + + def audio_data_to_track(data): """Convert taglist data + our extras to a track.""" - albumartist_kwargs = {} + tags = data['tags'] album_kwargs = {} - artist_kwargs = {} - composer_kwargs = {} - performer_kwargs = {} track_kwargs = {} - def _retrieve(source_key, target_key, target): - if source_key in data: - target[target_key] = data[source_key] + track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER) + track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER) + track_kwargs['artists'] = _artists( + tags, gst.TAG_ARTIST, 'musicbrainz-artistid') + album_kwargs['artists'] = _artists( + tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid') - _retrieve(gst.TAG_ALBUM, 'name', album_kwargs) - _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) - _retrieve(gst.TAG_ALBUM_VOLUME_COUNT, 'num_discs', album_kwargs) - _retrieve(gst.TAG_ARTIST, 'name', artist_kwargs) - _retrieve(gst.TAG_COMPOSER, 'name', composer_kwargs) - _retrieve(gst.TAG_PERFORMER, 'name', performer_kwargs) - _retrieve(gst.TAG_ALBUM_ARTIST, 'name', albumartist_kwargs) - _retrieve(gst.TAG_TITLE, 'name', track_kwargs) - _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs) - _retrieve(gst.TAG_ALBUM_VOLUME_NUMBER, 'disc_no', track_kwargs) - _retrieve(gst.TAG_GENRE, 'genre', track_kwargs) - _retrieve(gst.TAG_BITRATE, 'bitrate', track_kwargs) + track_kwargs['genre'] = '; '.join(tags.get(gst.TAG_GENRE, [])) + track_kwargs['name'] = '; '.join(tags.get(gst.TAG_TITLE, [])) + if not track_kwargs['name']: + track_kwargs['name'] = '; '.join(tags.get(gst.TAG_ORGANIZATION, [])) - # Following keys don't seem to have TAG_* constant. - _retrieve('comment', 'comment', track_kwargs) - _retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs) - _retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs) - _retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs) - _retrieve( - 'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) + track_kwargs['comment'] = '; '.join(tags.get('comment', [])) + if not track_kwargs['comment']: + track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_LOCATION, [])) + if not track_kwargs['comment']: + track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_COPYRIGHT, [])) - if gst.TAG_DATE in data and data[gst.TAG_DATE]: - date = data[gst.TAG_DATE] - try: - date = datetime.date(date.year, date.month, date.day) - except ValueError: - pass # Ignore invalid dates - else: - track_kwargs['date'] = date.isoformat() + track_kwargs['track_no'] = tags.get(gst.TAG_TRACK_NUMBER, [None])[0] + track_kwargs['disc_no'] = tags.get(gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0] + track_kwargs['bitrate'] = tags.get(gst.TAG_BITRATE, [None])[0] + track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0] - if albumartist_kwargs: - album_kwargs['artists'] = [Artist(**albumartist_kwargs)] + album_kwargs['name'] = tags.get(gst.TAG_ALBUM, [None])[0] + album_kwargs['num_tracks'] = tags.get(gst.TAG_TRACK_COUNT, [None])[0] + album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0] + album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0] + + track_kwargs['date'] = _date(tags) + track_kwargs['last_modified'] = int(data.get('mtime') or 0) + track_kwargs['length'] = (data.get(gst.TAG_DURATION) or 0) // gst.MSECOND + + # Clear out any empty values we found + track_kwargs = {k: v for k, v in track_kwargs.items() if v} + album_kwargs = {k: v for k, v in album_kwargs.items() if v} track_kwargs['uri'] = data['uri'] - track_kwargs['last_modified'] = int(data['mtime']) - track_kwargs['length'] = data[gst.TAG_DURATION] // gst.MSECOND track_kwargs['album'] = Album(**album_kwargs) - - if ('name' in artist_kwargs - and not isinstance(artist_kwargs['name'], basestring)): - track_kwargs['artists'] = [Artist(name=artist) - for artist in artist_kwargs['name']] - else: - track_kwargs['artists'] = [Artist(**artist_kwargs)] - - if ('name' in composer_kwargs - and not isinstance(composer_kwargs['name'], basestring)): - track_kwargs['composers'] = [Artist(name=artist) - for artist in composer_kwargs['name']] - else: - track_kwargs['composers'] = \ - [Artist(**composer_kwargs)] if composer_kwargs else '' - - if ('name' in performer_kwargs - and not isinstance(performer_kwargs['name'], basestring)): - track_kwargs['performers'] = [Artist(name=artist) - for artist in performer_kwargs['name']] - else: - track_kwargs['performers'] = \ - [Artist(**performer_kwargs)] if performer_kwargs else '' - return Track(**track_kwargs) diff --git a/mopidy/backend/__init__.py b/mopidy/backend/__init__.py new file mode 100644 index 00000000..6f895985 --- /dev/null +++ b/mopidy/backend/__init__.py @@ -0,0 +1,300 @@ +from __future__ import unicode_literals + +import copy + +from mopidy import listener + + +class Backend(object): + #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. + #: + #: Should be passed to the backend constructor as the kwarg ``audio``, + #: which will then set this field. + audio = None + + #: The library provider. An instance of + #: :class:`~mopidy.backend.LibraryProvider`, or :class:`None` if + #: the backend doesn't provide a library. + library = None + + #: The playback provider. An instance of + #: :class:`~mopidy.backend.PlaybackProvider`, or :class:`None` if + #: the backend doesn't provide playback. + playback = None + + #: The playlists provider. An instance of + #: :class:`~mopidy.backend.PlaylistsProvider`, or class:`None` if + #: the backend doesn't provide playlists. + playlists = None + + #: List of URI schemes this backend can handle. + uri_schemes = [] + + # Because the providers is marked as pykka_traversible, we can't get() them + # from another actor, and need helper methods to check if the providers are + # set or None. + + def has_library(self): + return self.library is not None + + def has_library_browse(self): + return self.has_library() and self.library.root_directory is not None + + def has_playback(self): + return self.playback is not None + + def has_playlists(self): + return self.playlists is not None + + +class LibraryProvider(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backend.Backend` + """ + + pykka_traversable = True + + root_directory = None + """ + :class:`models.Ref.directory` instance with a URI and name set + representing the root of this library's browse tree. URIs must + use one of the schemes supported by the backend, and name should + be set to a human friendly value. + + *MUST be set by any class that implements :meth:`LibraryProvider.browse`.* + """ + + def __init__(self, backend): + self.backend = backend + + def browse(self, path): + """ + See :meth:`mopidy.core.LibraryController.browse`. + + If you implement this method, make sure to also set + :attr:`root_directory_name`. + + *MAY be implemented by subclass.* + """ + return [] + + # TODO: replace with search(query, exact=True, ...) + def find_exact(self, query=None, uris=None): + """ + See :meth:`mopidy.core.LibraryController.find_exact`. + + *MAY be implemented by subclass.* + """ + pass + + def lookup(self, uri): + """ + See :meth:`mopidy.core.LibraryController.lookup`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def refresh(self, uri=None): + """ + See :meth:`mopidy.core.LibraryController.refresh`. + + *MAY be implemented by subclass.* + """ + pass + + def search(self, query=None, uris=None): + """ + See :meth:`mopidy.core.LibraryController.search`. + + *MAY be implemented by subclass.* + """ + pass + + +class PlaybackProvider(object): + """ + :param audio: the audio actor + :type audio: actor proxy to an instance of :class:`mopidy.audio.Audio` + :param backend: the backend + :type backend: :class:`mopidy.backend.Backend` + """ + + pykka_traversable = True + + def __init__(self, audio, backend): + self.audio = audio + self.backend = backend + + def pause(self): + """ + Pause playback. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.pause_playback().get() + + def play(self, track): + """ + Play given track. + + *MAY be reimplemented by subclass.* + + :param track: the track to play + :type track: :class:`mopidy.models.Track` + :rtype: :class:`True` if successful, else :class:`False` + """ + self.audio.prepare_change() + self.change_track(track) + return self.audio.start_playback().get() + + def change_track(self, track): + """ + Swith to provided track. + + *MAY be reimplemented by subclass.* + + :param track: the track to play + :type track: :class:`mopidy.models.Track` + :rtype: :class:`True` if successful, else :class:`False` + """ + self.audio.set_uri(track.uri).get() + return True + + def resume(self): + """ + Resume playback at the same time position playback was paused. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.start_playback().get() + + def seek(self, time_position): + """ + Seek to a given time position. + + *MAY be reimplemented by subclass.* + + :param time_position: time position in milliseconds + :type time_position: int + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.set_position(time_position).get() + + def stop(self): + """ + Stop playback. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.stop_playback().get() + + def get_time_position(self): + """ + Get the current time position in milliseconds. + + *MAY be reimplemented by subclass.* + + :rtype: int + """ + return self.audio.get_position().get() + + +class PlaylistsProvider(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backend.Backend` instance + """ + + pykka_traversable = True + + def __init__(self, backend): + self.backend = backend + self._playlists = [] + + @property + def playlists(self): + """ + Currently available playlists. + + Read/write. List of :class:`mopidy.models.Playlist`. + """ + return copy.copy(self._playlists) + + @playlists.setter # noqa + def playlists(self, playlists): + self._playlists = playlists + + def create(self, name): + """ + See :meth:`mopidy.core.PlaylistsController.create`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def delete(self, uri): + """ + See :meth:`mopidy.core.PlaylistsController.delete`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def lookup(self, uri): + """ + See :meth:`mopidy.core.PlaylistsController.lookup`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def refresh(self): + """ + See :meth:`mopidy.core.PlaylistsController.refresh`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def save(self, playlist): + """ + See :meth:`mopidy.core.PlaylistsController.save`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + +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 + 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. + + Normally, only the Core actor should mix in this class. + """ + + @staticmethod + def send(event, **kwargs): + """Helper to allow calling of backend listener events""" + listener.send_async(BackendListener, event, **kwargs) + + def playlists_loaded(self): + """ + Called when playlists are loaded or refreshed. + + *MAY* be implemented by actor. + """ + pass diff --git a/mopidy/backend/dummy.py b/mopidy/backend/dummy.py new file mode 100644 index 00000000..94b01433 --- /dev/null +++ b/mopidy/backend/dummy.py @@ -0,0 +1,111 @@ +"""A dummy backend for use in tests. + +This backend implements the backend API in the simplest way possible. It is +used in tests of the frontends. +""" + +from __future__ import unicode_literals + +import pykka + +from mopidy import backend +from mopidy.models import Playlist, Ref, SearchResult + + +def create_dummy_backend_proxy(config=None, audio=None): + return DummyBackend.start(config=config, audio=audio).proxy() + + +class DummyBackend(pykka.ThreadingActor, backend.Backend): + def __init__(self, config, audio): + super(DummyBackend, self).__init__() + + self.library = DummyLibraryProvider(backend=self) + self.playback = DummyPlaybackProvider(audio=audio, backend=self) + self.playlists = DummyPlaylistsProvider(backend=self) + + self.uri_schemes = ['dummy'] + + +class DummyLibraryProvider(backend.LibraryProvider): + root_directory = Ref.directory(uri='dummy:/', name='dummy') + + def __init__(self, *args, **kwargs): + super(DummyLibraryProvider, self).__init__(*args, **kwargs) + self.dummy_library = [] + self.dummy_browse_result = {} + self.dummy_find_exact_result = SearchResult() + self.dummy_search_result = SearchResult() + + def browse(self, path): + return self.dummy_browse_result.get(path, []) + + def find_exact(self, **query): + return self.dummy_find_exact_result + + def lookup(self, uri): + return filter(lambda t: uri == t.uri, self.dummy_library) + + def refresh(self, uri=None): + pass + + def search(self, **query): + return self.dummy_search_result + + +class DummyPlaybackProvider(backend.PlaybackProvider): + def __init__(self, *args, **kwargs): + super(DummyPlaybackProvider, self).__init__(*args, **kwargs) + self._time_position = 0 + + def pause(self): + return True + + def play(self, track): + """Pass a track with URI 'dummy:error' to force failure""" + self._time_position = 0 + return track.uri != 'dummy:error' + + def resume(self): + return True + + def seek(self, time_position): + self._time_position = time_position + return True + + def stop(self): + return True + + def get_time_position(self): + return self._time_position + + +class DummyPlaylistsProvider(backend.PlaylistsProvider): + def create(self, name): + playlist = Playlist(name=name, uri='dummy:%s' % name) + self._playlists.append(playlist) + return playlist + + def delete(self, uri): + playlist = self.lookup(uri) + if playlist: + self._playlists.remove(playlist) + + def lookup(self, uri): + for playlist in self._playlists: + if playlist.uri == uri: + return playlist + + def refresh(self): + pass + + def save(self, playlist): + old_playlist = self.lookup(playlist.uri) + + if old_playlist is not None: + index = self._playlists.index(old_playlist) + self._playlists[index] = playlist + else: + self._playlists.append(playlist) + + return playlist diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 6b980f06..aed6ce3e 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -1,281 +1,17 @@ from __future__ import unicode_literals -import copy - - -class Backend(object): - #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. - #: - #: Should be passed to the backend constructor as the kwarg ``audio``, - #: which will then set this field. - audio = None - - #: The library provider. An instance of - #: :class:`~mopidy.backends.base.BaseLibraryProvider`, or :class:`None` if - #: the backend doesn't provide a library. - library = None - - #: The playback provider. An instance of - #: :class:`~mopidy.backends.base.BasePlaybackProvider`, or :class:`None` if - #: the backend doesn't provide playback. - playback = None - - #: The playlists provider. An instance of - #: :class:`~mopidy.backends.base.BasePlaylistsProvider`, or class:`None` if - #: the backend doesn't provide playlists. - playlists = None - - #: List of URI schemes this backend can handle. - uri_schemes = [] - - # Because the providers is marked as pykka_traversible, we can't get() them - # from another actor, and need helper methods to check if the providers are - # set or None. - - def has_library(self): - return self.library is not None - - def has_playback(self): - return self.playback is not None - - def has_playlists(self): - return self.playlists is not None - - -class BaseLibraryProvider(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, backend): - self.backend = backend - - # TODO: replace with search(query, exact=True, ...) - def find_exact(self, query=None, uris=None): - """ - See :meth:`mopidy.core.LibraryController.find_exact`. - - *MAY be implemented by subclass.* - """ - pass - - def lookup(self, uri): - """ - See :meth:`mopidy.core.LibraryController.lookup`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def refresh(self, uri=None): - """ - See :meth:`mopidy.core.LibraryController.refresh`. - - *MAY be implemented by subclass.* - """ - pass - - def search(self, query=None, uris=None): - """ - See :meth:`mopidy.core.LibraryController.search`. - - *MAY be implemented by subclass.* - """ - pass - - -class BaseLibraryUpdateProvider(object): - uri_schemes = [] - - def load(self): - """Loads the library and returns all tracks in it. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def add(self, track): - """Adds given track to library. - - Overwrites any existing track with same URI. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def remove(self, uri): - """Removes given track from library. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def commit(self): - """Persist changes to library. - - *MAY be implemented by subclass.* - """ - pass - - -class BasePlaybackProvider(object): - """ - :param audio: the audio actor - :type audio: actor proxy to an instance of :class:`mopidy.audio.Audio` - :param backend: the backend - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, audio, backend): - self.audio = audio - self.backend = backend - - def pause(self): - """ - Pause playback. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.pause_playback().get() - - def play(self, track): - """ - Play given track. - - *MAY be reimplemented by subclass.* - - :param track: the track to play - :type track: :class:`mopidy.models.Track` - :rtype: :class:`True` if successful, else :class:`False` - """ - self.audio.prepare_change() - self.change_track(track) - return self.audio.start_playback().get() - - def change_track(self, track): - """ - Swith to provided track. - - *MAY be reimplemented by subclass.* - - :param track: the track to play - :type track: :class:`mopidy.models.Track` - :rtype: :class:`True` if successful, else :class:`False` - """ - self.audio.set_uri(track.uri).get() - return True - - def resume(self): - """ - Resume playback at the same time position playback was paused. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.start_playback().get() - - def seek(self, time_position): - """ - Seek to a given time position. - - *MAY be reimplemented by subclass.* - - :param time_position: time position in milliseconds - :type time_position: int - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.set_position(time_position).get() - - def stop(self): - """ - Stop playback. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.stop_playback().get() - - def get_time_position(self): - """ - Get the current time position in milliseconds. - - *MAY be reimplemented by subclass.* - - :rtype: int - """ - return self.audio.get_position().get() - - -class BasePlaylistsProvider(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, backend): - self.backend = backend - self._playlists = [] - - @property - def playlists(self): - """ - Currently available playlists. - - Read/write. List of :class:`mopidy.models.Playlist`. - """ - return copy.copy(self._playlists) - - @playlists.setter # noqa - def playlists(self, playlists): - self._playlists = playlists - - def create(self, name): - """ - See :meth:`mopidy.core.PlaylistsController.create`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def delete(self, uri): - """ - See :meth:`mopidy.core.PlaylistsController.delete`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def lookup(self, uri): - """ - See :meth:`mopidy.core.PlaylistsController.lookup`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def refresh(self): - """ - See :meth:`mopidy.core.PlaylistsController.refresh`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def save(self, playlist): - """ - See :meth:`mopidy.core.PlaylistsController.save`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError +from mopidy.backend import ( + Backend, + LibraryProvider as BaseLibraryProvider, + PlaybackProvider as BasePlaybackProvider, + PlaylistsProvider as BasePlaylistsProvider) + + +# Make classes previously residing here available in the old location for +# backwards compatibility with extensions targeting Mopidy < 0.18. +__all__ = [ + 'Backend', + 'BaseLibraryProvider', + 'BasePlaybackProvider', + 'BasePlaylistsProvider', +] diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 65477ea2..7c13c9b1 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -1,115 +1,5 @@ -"""A dummy backend for use in tests. - -This backend implements the backend API in the simplest way possible. It is -used in tests of the frontends. - -The backend handles URIs starting with ``dummy:``. - -**Dependencies** - -None - -**Default config** - -None -""" - from __future__ import unicode_literals -import pykka - -from mopidy.backends import base -from mopidy.models import Playlist, SearchResult - - -def create_dummy_backend_proxy(config=None, audio=None): - return DummyBackend.start(config=config, audio=audio).proxy() - - -class DummyBackend(pykka.ThreadingActor, base.Backend): - def __init__(self, config, audio): - super(DummyBackend, self).__init__() - - self.library = DummyLibraryProvider(backend=self) - self.playback = DummyPlaybackProvider(audio=audio, backend=self) - self.playlists = DummyPlaylistsProvider(backend=self) - - self.uri_schemes = ['dummy'] - - -class DummyLibraryProvider(base.BaseLibraryProvider): - def __init__(self, *args, **kwargs): - super(DummyLibraryProvider, self).__init__(*args, **kwargs) - self.dummy_library = [] - self.dummy_find_exact_result = SearchResult() - self.dummy_search_result = SearchResult() - - def find_exact(self, **query): - return self.dummy_find_exact_result - - def lookup(self, uri): - return filter(lambda t: uri == t.uri, self.dummy_library) - - def refresh(self, uri=None): - pass - - def search(self, **query): - return self.dummy_search_result - - -class DummyPlaybackProvider(base.BasePlaybackProvider): - def __init__(self, *args, **kwargs): - super(DummyPlaybackProvider, self).__init__(*args, **kwargs) - self._time_position = 0 - - def pause(self): - return True - - def play(self, track): - """Pass a track with URI 'dummy:error' to force failure""" - self._time_position = 0 - return track.uri != 'dummy:error' - - def resume(self): - return True - - def seek(self, time_position): - self._time_position = time_position - return True - - def stop(self): - return True - - def get_time_position(self): - return self._time_position - - -class DummyPlaylistsProvider(base.BasePlaylistsProvider): - def create(self, name): - playlist = Playlist(name=name, uri='dummy:%s' % name) - self._playlists.append(playlist) - return playlist - - def delete(self, uri): - playlist = self.lookup(uri) - if playlist: - self._playlists.remove(playlist) - - def lookup(self, uri): - for playlist in self._playlists: - if playlist.uri == uri: - return playlist - - def refresh(self): - pass - - def save(self, playlist): - old_playlist = self.lookup(playlist.uri) - - if old_playlist is not None: - index = self._playlists.index(old_playlist) - self._playlists[index] = playlist - else: - self._playlists.append(playlist) - - return playlist +# Make classes previously residing here available in the old location for +# backwards compatibility with extensions targeting Mopidy < 0.18. +from mopidy.backend.dummy import * # noqa diff --git a/mopidy/backends/listener.py b/mopidy/backends/listener.py index d9043079..0b551f26 100644 --- a/mopidy/backends/listener.py +++ b/mopidy/backends/listener.py @@ -1,45 +1,8 @@ from __future__ import unicode_literals -import pykka +from mopidy.backend import BackendListener -class BackendListener(object): - """ - 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 - 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. - - Normally, only the Core actor should mix in this class. - """ - - @staticmethod - def send(event, **kwargs): - """Helper to allow calling of backend listener events""" - listeners = pykka.ActorRegistry.get_by_class(BackendListener) - for listener in listeners: - listener.proxy().on_event(event, **kwargs) - - def on_event(self, event, **kwargs): - """ - Called on all events. - - *MAY* be implemented by actor. By default, this method forwards the - event to the specific event methods. - - :param event: the event name - :type event: string - :param kwargs: any other arguments to the specific event handlers - """ - getattr(self, event)(**kwargs) - - def playlists_loaded(self): - """ - Called when playlists are loaded or refreshed. - - *MAY* be implemented by actor. - """ - pass +# Make classes previously residing here available in the old location for +# backwards compatibility with extensions targeting Mopidy < 0.18. +__all__ = ['BackendListener'] diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py deleted file mode 100644 index 703b2562..00000000 --- a/mopidy/backends/local/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import unicode_literals - -import os - -import mopidy -from mopidy import config, ext - - -class Extension(ext.Extension): - - dist_name = 'Mopidy-Local' - ext_name = 'local' - 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_dir'] = config.Path() - schema['playlists_dir'] = config.Path() - schema['tag_cache_file'] = config.Path() - schema['scan_timeout'] = config.Integer( - minimum=1000, maximum=1000*60*60) - schema['excluded_file_extensions'] = config.List(optional=True) - return schema - - def validate_environment(self): - pass - - def get_backend_classes(self): - from .actor import LocalBackend - return [LocalBackend] - - def get_library_updaters(self): - from .library import LocalLibraryUpdateProvider - return [LocalLibraryUpdateProvider] - - def get_command(self): - from .commands import LocalCommand - return LocalCommand() diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py deleted file mode 100644 index c2ef143c..00000000 --- a/mopidy/backends/local/commands.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import unicode_literals - -import logging -import os -import time - -from mopidy import commands, exceptions -from mopidy.audio import scan -from mopidy.utils import path - -from . import translator - -logger = logging.getLogger('mopidy.backends.local.commands') - - -class LocalCommand(commands.Command): - def __init__(self): - super(LocalCommand, self).__init__() - self.add_child('scan', ScanCommand()) - - -class ScanCommand(commands.Command): - help = "Scan local media files and populate the local library." - - def run(self, args, config, extensions): - media_dir = config['local']['media_dir'] - scan_timeout = config['local']['scan_timeout'] - excluded_file_extensions = set( - ext.lower() for ext in config['local']['excluded_file_extensions']) - - updaters = {} - for e in extensions: - for updater_class in e.get_library_updaters(): - if updater_class and 'local' in updater_class.uri_schemes: - updaters[e.ext_name] = updater_class - - if not updaters: - logger.error('No usable library updaters found.') - return 1 - elif len(updaters) > 1: - logger.error('More than one library updater found. ' - 'Provided by: %s', ', '.join(updaters.keys())) - return 1 - - local_updater = updaters.values()[0](config) - - # TODO: cleanup to consistently use local urls, not a random mix of - # local and file uris depending on how the data was loaded. - uris_library = set() - uris_update = set() - uris_remove = set() - - tracks = local_updater.load() - logger.info('Checking %d tracks from library.', len(tracks)) - for track in tracks: - try: - uri = translator.local_to_file_uri(track.uri, media_dir) - stat = os.stat(path.uri_to_path(uri)) - if int(stat.st_mtime) > track.last_modified: - uris_update.add(uri) - uris_library.add(uri) - except OSError: - logger.debug('Missing file %s', track.uri) - uris_remove.add(track.uri) - - logger.info('Removing %d missing tracks.', len(uris_remove)) - for uri in uris_remove: - local_updater.remove(uri) - - logger.info('Checking %s for unknown tracks.', media_dir) - for uri in path.find_uris(media_dir): - file_extension = os.path.splitext(path.uri_to_path(uri))[1] - if file_extension.lower() in excluded_file_extensions: - logger.debug('Skipped %s: File extension excluded.', uri) - continue - - if uri not in uris_library: - uris_update.add(uri) - - logger.info('Found %d unknown tracks.', len(uris_update)) - logger.info('Scanning...') - - scanner = scan.Scanner(scan_timeout) - progress = Progress(len(uris_update)) - - for uri in sorted(uris_update): - try: - data = scanner.scan(uri) - track = scan.audio_data_to_track(data) - local_updater.add(track) - logger.debug('Added %s', track.uri) - except exceptions.ScannerError as error: - logger.warning('Failed %s: %s', uri, error) - - progress.increment() - - logger.info('Commiting changes.') - local_updater.commit() - return 0 - - -# TODO: move to utils? -class Progress(object): - def __init__(self, total): - self.count = 0 - self.total = total - self.start = time.time() - - def increment(self): - self.count += 1 - if self.count % 1000 == 0 or self.count == self.total: - duration = time.time() - self.start - remainder = duration / self.count * (self.total - self.count) - logger.info('Scanned %d of %d files in %ds, ~%ds left.', - self.count, self.total, duration, remainder) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py deleted file mode 100644 index da4e4bfd..00000000 --- a/mopidy/backends/local/library.py +++ /dev/null @@ -1,265 +0,0 @@ -from __future__ import unicode_literals - -import logging -import os -import tempfile - -from mopidy.backends import base -from mopidy.frontends.mpd import translator as mpd_translator -from mopidy.models import Album, SearchResult - -from .translator import local_to_file_uri, parse_mpd_tag_cache - -logger = logging.getLogger('mopidy.backends.local') - - -class LocalLibraryProvider(base.BaseLibraryProvider): - def __init__(self, *args, **kwargs): - super(LocalLibraryProvider, self).__init__(*args, **kwargs) - self._uri_mapping = {} - self._media_dir = self.backend.config['local']['media_dir'] - self._tag_cache_file = self.backend.config['local']['tag_cache_file'] - self.refresh() - - def _convert_to_int(self, string): - try: - return int(string) - except ValueError: - return object() - - def refresh(self, uri=None): - logger.debug( - 'Loading local tracks from %s using %s', - self._media_dir, self._tag_cache_file) - - tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) - uris_to_remove = set(self._uri_mapping) - - for track in tracks: - self._uri_mapping[track.uri] = track - uris_to_remove.discard(track.uri) - - for uri in uris_to_remove: - del self._uri_mapping[uri] - - logger.info( - 'Loaded %d local tracks from %s using %s', - len(tracks), self._media_dir, self._tag_cache_file) - - def lookup(self, uri): - try: - return [self._uri_mapping[uri]] - except KeyError: - logger.debug('Failed to lookup %r', uri) - return [] - - def find_exact(self, query=None, uris=None): - # TODO Only return results within URI roots given by ``uris`` - - if query is None: - query = {} - self._validate_query(query) - result_tracks = self._uri_mapping.values() - - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - # FIXME this is bound to be slow for large libraries - for value in values: - if field == 'track_no': - q = self._convert_to_int(value) - else: - q = value.strip() - - uri_filter = lambda t: q == t.uri - track_name_filter = lambda t: q == t.name - album_filter = lambda t: q == getattr(t, 'album', Album()).name - artist_filter = lambda t: filter( - lambda a: q == a.name, t.artists) - albumartist_filter = lambda t: any([ - q == a.name - for a in getattr(t.album, 'artists', [])]) - composer_filter = lambda t: any([ - q == a.name - for a in getattr(t, 'composers', [])]) - performer_filter = lambda t: any([ - q == a.name - for a in getattr(t, 'performers', [])]) - track_no_filter = lambda t: q == t.track_no - genre_filter = lambda t: t.genre and q == t.genre - date_filter = lambda t: q == t.date - comment_filter = lambda t: q == t.comment - any_filter = lambda t: ( - uri_filter(t) or - track_name_filter(t) or - album_filter(t) or - artist_filter(t) or - albumartist_filter(t) or - composer_filter(t) or - performer_filter(t) or - track_no_filter(t) or - genre_filter(t) or - date_filter(t) or - comment_filter(t)) - - if field == 'uri': - result_tracks = filter(uri_filter, result_tracks) - elif field == 'track_name': - result_tracks = filter(track_name_filter, result_tracks) - elif field == 'album': - result_tracks = filter(album_filter, result_tracks) - elif field == 'artist': - result_tracks = filter(artist_filter, result_tracks) - elif field == 'albumartist': - result_tracks = filter(albumartist_filter, result_tracks) - elif field == 'composer': - result_tracks = filter(composer_filter, result_tracks) - elif field == 'performer': - result_tracks = filter(performer_filter, result_tracks) - elif field == 'track_no': - result_tracks = filter(track_no_filter, result_tracks) - elif field == 'genre': - result_tracks = filter(genre_filter, result_tracks) - elif field == 'date': - result_tracks = filter(date_filter, result_tracks) - elif field == 'comment': - result_tracks = filter(comment_filter, result_tracks) - elif field == 'any': - result_tracks = filter(any_filter, result_tracks) - else: - raise LookupError('Invalid lookup field: %s' % field) - # TODO: add local:search: - return SearchResult(uri='local:search', tracks=result_tracks) - - def search(self, query=None, uris=None): - # TODO Only return results within URI roots given by ``uris`` - - if query is None: - query = {} - self._validate_query(query) - result_tracks = self._uri_mapping.values() - - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - # FIXME this is bound to be slow for large libraries - for value in values: - if field == 'track_no': - q = self._convert_to_int(value) - else: - q = value.strip().lower() - - uri_filter = lambda t: q in t.uri.lower() - track_name_filter = lambda t: q in t.name.lower() - album_filter = lambda t: q in getattr( - t, 'album', Album()).name.lower() - artist_filter = lambda t: filter( - lambda a: q in a.name.lower(), t.artists) - albumartist_filter = lambda t: any([ - q in a.name.lower() - for a in getattr(t.album, 'artists', [])]) - composer_filter = lambda t: any([ - q in a.name.lower() - for a in getattr(t, 'composers', [])]) - performer_filter = lambda t: any([ - q in a.name.lower() - for a in getattr(t, 'performers', [])]) - track_no_filter = lambda t: q == t.track_no - genre_filter = lambda t: t.genre and q in t.genre.lower() - date_filter = lambda t: t.date and t.date.startswith(q) - comment_filter = lambda t: t.comment and q in t.comment.lower() - any_filter = lambda t: ( - uri_filter(t) or - track_name_filter(t) or - album_filter(t) or - artist_filter(t) or - albumartist_filter(t) or - composer_filter(t) or - performer_filter(t) or - track_no_filter(t) or - genre_filter(t) or - date_filter(t) or - comment_filter(t)) - - if field == 'uri': - result_tracks = filter(uri_filter, result_tracks) - elif field == 'track_name': - result_tracks = filter(track_name_filter, result_tracks) - elif field == 'album': - result_tracks = filter(album_filter, result_tracks) - elif field == 'artist': - result_tracks = filter(artist_filter, result_tracks) - elif field == 'albumartist': - result_tracks = filter(albumartist_filter, result_tracks) - elif field == 'composer': - result_tracks = filter(composer_filter, result_tracks) - elif field == 'performer': - result_tracks = filter(performer_filter, result_tracks) - elif field == 'track_no': - result_tracks = filter(track_no_filter, result_tracks) - elif field == 'genre': - result_tracks = filter(genre_filter, result_tracks) - elif field == 'date': - result_tracks = filter(date_filter, result_tracks) - elif field == 'comment': - result_tracks = filter(comment_filter, result_tracks) - elif field == 'any': - result_tracks = filter(any_filter, result_tracks) - else: - raise LookupError('Invalid lookup field: %s' % field) - # TODO: add local:search: - return SearchResult(uri='local:search', tracks=result_tracks) - - def _validate_query(self, query): - for (_, values) in query.iteritems(): - if not values: - raise LookupError('Missing query') - for value in values: - if not value: - raise LookupError('Missing query') - - -# TODO: rename and move to tagcache extension. -class LocalLibraryUpdateProvider(base.BaseLibraryProvider): - uri_schemes = ['local'] - - def __init__(self, config): - self._tracks = {} - self._media_dir = config['local']['media_dir'] - self._tag_cache_file = config['local']['tag_cache_file'] - - def load(self): - tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) - for track in tracks: - # TODO: this should use uris as is, i.e. hack that should go away - # with tag caches. - uri = local_to_file_uri(track.uri, self._media_dir) - self._tracks[uri] = track.copy(uri=uri) - return tracks - - def add(self, track): - self._tracks[track.uri] = track - - def remove(self, uri): - if uri in self._tracks: - del self._tracks[uri] - - def commit(self): - directory, basename = os.path.split(self._tag_cache_file) - - # TODO: cleanup directory/basename.* files. - tmp = tempfile.NamedTemporaryFile( - prefix=basename + '.', dir=directory, delete=False) - - try: - for row in mpd_translator.tracks_to_tag_cache_format( - self._tracks.values(), self._media_dir): - if len(row) == 1: - tmp.write(('%s\n' % row).encode('utf-8')) - else: - tmp.write(('%s: %s\n' % row).encode('utf-8')) - - os.rename(tmp.name, self._tag_cache_file) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) diff --git a/mopidy/backends/local/playback.py b/mopidy/backends/local/playback.py deleted file mode 100644 index b264dac7..00000000 --- a/mopidy/backends/local/playback.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import unicode_literals - -import logging - -from mopidy.backends import base - -from . import translator - -logger = logging.getLogger('mopidy.backends.local') - - -class LocalPlaybackProvider(base.BasePlaybackProvider): - def change_track(self, track): - media_dir = self.backend.config['local']['media_dir'] - uri = translator.local_to_file_uri(track.uri, media_dir) - track = track.copy(uri=uri) - return super(LocalPlaybackProvider, self).change_track(track) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py deleted file mode 100644 index b9aad3e0..00000000 --- a/mopidy/backends/local/translator.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import unicode_literals - -import logging -import os -import urlparse - -from mopidy.models import Track, Artist, Album -from mopidy.utils.encoding import locale_decode -from mopidy.utils.path import path_to_uri, uri_to_path - -logger = logging.getLogger('mopidy.backends.local') - - -def local_to_file_uri(uri, media_dir): - # TODO: check that type is correct. - file_path = uri_to_path(uri).split(b':', 1)[1] - file_path = os.path.join(media_dir, file_path) - return path_to_uri(file_path) - - -def parse_m3u(file_path, media_dir): - r""" - Convert M3U file list of uris - - Example M3U data:: - - # This is a comment - Alternative\Band - Song.mp3 - Classical\Other Band - New Song.mp3 - Stuff.mp3 - D:\More Music\Foo.mp3 - http://www.example.com:8000/Listen.pls - http://www.example.com/~user/Mine.mp3 - - - Relative paths of songs should be with respect to location of M3U. - - Paths are normaly platform specific. - - Lines starting with # should be ignored. - - m3u files are latin-1. - - This function does not bother with Extended M3U directives. - """ - # TODO: uris as bytes - uris = [] - try: - with open(file_path) as m3u: - contents = m3u.readlines() - except IOError as error: - logger.warning('Couldn\'t open m3u: %s', locale_decode(error)) - return uris - - for line in contents: - line = line.strip().decode('latin1') - - if line.startswith('#'): - continue - - if urlparse.urlsplit(line).scheme: - uris.append(line) - elif os.path.normpath(line) == os.path.abspath(line): - path = path_to_uri(line) - uris.append(path) - else: - path = path_to_uri(os.path.join(media_dir, line)) - uris.append(path) - - return uris - - -# TODO: remove music_dir from API -def parse_mpd_tag_cache(tag_cache, music_dir=''): - """ - Converts a MPD tag_cache into a lists of tracks, artists and albums. - """ - tracks = set() - - try: - with open(tag_cache) as library: - contents = library.read() - except IOError as error: - logger.warning('Could not open tag cache: %s', locale_decode(error)) - return tracks - - current = {} - state = None - - # TODO: uris as bytes - for line in contents.split(b'\n'): - if line == b'songList begin': - state = 'songs' - continue - elif line == b'songList end': - state = None - continue - elif not state: - continue - - key, value = line.split(b': ', 1) - - if key == b'key': - _convert_mpd_data(current, tracks) - current.clear() - - current[key.lower()] = value.decode('utf-8') - - _convert_mpd_data(current, tracks) - - return tracks - - -def _convert_mpd_data(data, tracks): - if not data: - return - - track_kwargs = {} - album_kwargs = {} - artist_kwargs = {} - albumartist_kwargs = {} - - if 'track' in data: - if '/' in data['track']: - album_kwargs['num_tracks'] = int(data['track'].split('/')[1]) - track_kwargs['track_no'] = int(data['track'].split('/')[0]) - else: - track_kwargs['track_no'] = int(data['track']) - - if 'mtime' in data: - track_kwargs['last_modified'] = int(data['mtime']) - - if 'artist' in data: - artist_kwargs['name'] = data['artist'] - - if 'albumartist' in data: - albumartist_kwargs['name'] = data['albumartist'] - - if 'composer' in data: - track_kwargs['composers'] = [Artist(name=data['composer'])] - - if 'performer' in data: - track_kwargs['performers'] = [Artist(name=data['performer'])] - - if 'album' in data: - album_kwargs['name'] = data['album'] - - if 'title' in data: - track_kwargs['name'] = data['title'] - - if 'genre' in data: - track_kwargs['genre'] = data['genre'] - - if 'date' in data: - track_kwargs['date'] = data['date'] - - if 'comment' in data: - track_kwargs['comment'] = data['comment'] - - if 'musicbrainz_trackid' in data: - track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid'] - - if 'musicbrainz_albumid' in data: - album_kwargs['musicbrainz_id'] = data['musicbrainz_albumid'] - - if 'musicbrainz_artistid' in data: - artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid'] - - if 'musicbrainz_albumartistid' in data: - albumartist_kwargs['musicbrainz_id'] = ( - data['musicbrainz_albumartistid']) - - if artist_kwargs: - artist = Artist(**artist_kwargs) - track_kwargs['artists'] = [artist] - - if albumartist_kwargs: - albumartist = Artist(**albumartist_kwargs) - album_kwargs['artists'] = [albumartist] - - if album_kwargs: - album = Album(**album_kwargs) - track_kwargs['album'] = album - - if data['file'][0] == '/': - path = data['file'][1:] - else: - path = data['file'] - - track_kwargs['uri'] = 'local:track:%s' % path - track_kwargs['length'] = int(data.get('time', 0)) * 1000 - - track = Track(**track_kwargs) - tracks.add(track) diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py deleted file mode 100644 index 86df447d..00000000 --- a/mopidy/backends/stream/actor.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import unicode_literals - -import logging -import urlparse - -import pykka - -from mopidy import audio as audio_lib -from mopidy.backends import base -from mopidy.models import Track - -logger = logging.getLogger('mopidy.backends.stream') - - -class StreamBackend(pykka.ThreadingActor, base.Backend): - def __init__(self, config, audio): - super(StreamBackend, self).__init__() - - self.library = StreamLibraryProvider(backend=self) - self.playback = base.BasePlaybackProvider(audio=audio, backend=self) - self.playlists = None - - self.uri_schemes = audio_lib.supported_uri_schemes( - config['stream']['protocols']) - - -# TODO: Should we consider letting lookup know how to expand common playlist -# formats (m3u, pls, etc) for http(s) URIs? -class StreamLibraryProvider(base.BaseLibraryProvider): - def lookup(self, uri): - if urlparse.urlsplit(uri).scheme not in self.backend.uri_schemes: - return [] - # TODO: actually lookup the stream metadata by getting tags in same - # way as we do for updating the local library with mopidy.scanner - # Note that we would only want the stream metadata at this stage, - # not the currently playing track's. - return [Track(uri=uri, name=uri)] diff --git a/mopidy/commands.py b/mopidy/commands.py index 36f5ae1a..ad20c47f 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import print_function, unicode_literals import argparse import collections @@ -6,6 +6,7 @@ import logging import os import sys +import glib import gobject from mopidy import config as config_lib @@ -13,7 +14,12 @@ from mopidy.audio import Audio from mopidy.core import Core from mopidy.utils import deps, process, versioning -logger = logging.getLogger('mopidy.commands') +logger = logging.getLogger(__name__) + +_default_config = [] +for base in glib.get_system_config_dirs() + (glib.get_user_config_dir(),): + _default_config.append(os.path.join(base, b'mopidy', b'mopidy.conf')) +DEFAULT_CONFIG = b':'.join(_default_config) def config_files_type(value): @@ -106,7 +112,7 @@ class Command(object): def exit(self, status_code=0, message=None, usage=None): """Optionally print a message and exit.""" - print '\n\n'.join(m for m in (usage, message) if m) + print('\n\n'.join(m for m in (usage, message) if m)) sys.exit(status_code) def format_usage(self, prog=None): @@ -235,7 +241,7 @@ class RootCommand(Command): self.add_argument( '-v', '--verbose', action='count', dest='verbosity_level', default=0, - help='more output (debug level)') + help='more output (repeat up to 3 times for even more)') self.add_argument( '--save-debug-log', action='store_true', dest='save_debug_log', @@ -243,7 +249,7 @@ class RootCommand(Command): self.add_argument( '--config', action='store', dest='config_files', type=config_files_type, - default=b'$XDG_CONFIG_DIR/mopidy/mopidy.conf', metavar='FILES', + default=DEFAULT_CONFIG, metavar='FILES', help='config files to use, colon seperated, later files override') self.add_argument( '-o', '--option', @@ -251,22 +257,26 @@ class RootCommand(Command): type=config_override_type, metavar='OPTIONS', help='`section/key=value` values to override config options') - def run(self, args, config, extensions): + def run(self, args, config): loop = gobject.MainLoop() + + backend_classes = args.registry['backend'] + frontend_classes = args.registry['frontend'] + try: audio = self.start_audio(config) - backends = self.start_backends(config, extensions, audio) + backends = self.start_backends(config, backend_classes, audio) core = self.start_core(audio, backends) - self.start_frontends(config, extensions, core) + self.start_frontends(config, frontend_classes, core) loop.run() except KeyboardInterrupt: logger.info('Interrupted. Exiting...') return finally: loop.quit() - self.stop_frontends(extensions) + self.stop_frontends(frontend_classes) self.stop_core() - self.stop_backends(extensions) + self.stop_backends(backend_classes) self.stop_audio() process.stop_remaining_actors() @@ -274,11 +284,7 @@ class RootCommand(Command): logger.info('Starting Mopidy audio') return Audio.start(config=config).proxy() - def start_backends(self, config, extensions, audio): - backend_classes = [] - for extension in extensions: - backend_classes.extend(extension.get_backend_classes()) - + def start_backends(self, config, backend_classes, audio): logger.info( 'Starting Mopidy backends: %s', ', '.join(b.__name__ for b in backend_classes) or 'none') @@ -294,11 +300,7 @@ class RootCommand(Command): logger.info('Starting Mopidy core') return Core.start(audio=audio, backends=backends).proxy() - def start_frontends(self, config, extensions, core): - frontend_classes = [] - for extension in extensions: - frontend_classes.extend(extension.get_frontend_classes()) - + def start_frontends(self, config, frontend_classes, core): logger.info( 'Starting Mopidy frontends: %s', ', '.join(f.__name__ for f in frontend_classes) or 'none') @@ -306,21 +308,19 @@ class RootCommand(Command): for frontend_class in frontend_classes: frontend_class.start(config=config, core=core) - def stop_frontends(self, extensions): + def stop_frontends(self, frontend_classes): logger.info('Stopping Mopidy frontends') - for extension in extensions: - for frontend_class in extension.get_frontend_classes(): - process.stop_actors_by_class(frontend_class) + for frontend_class in frontend_classes: + process.stop_actors_by_class(frontend_class) def stop_core(self): logger.info('Stopping Mopidy core') process.stop_actors_by_class(Core) - def stop_backends(self, extensions): + def stop_backends(self, backend_classes): logger.info('Stopping Mopidy backends') - for extension in extensions: - for backend_class in extension.get_backend_classes(): - process.stop_actors_by_class(backend_class) + for backend_class in backend_classes: + process.stop_actors_by_class(backend_class) def stop_audio(self): logger.info('Stopping Mopidy audio') @@ -335,7 +335,7 @@ class ConfigCommand(Command): self.set(base_verbosity_level=-1) def run(self, config, errors, extensions): - print config_lib.format(config, extensions, errors) + print(config_lib.format(config, extensions, errors)) return 0 @@ -347,5 +347,5 @@ class DepsCommand(Command): self.set(base_verbosity_level=-1) def run(self): - print deps.format_dependency_list() + print(deps.format_dependency_list()) return 0 diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index be2205ca..2b740549 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -12,7 +12,7 @@ from mopidy.config.schemas import * # noqa from mopidy.config.types import * # noqa from mopidy.utils import path, versioning -logger = logging.getLogger('mopidy.config') +logger = logging.getLogger(__name__) _logging_schema = ConfigSchema('logging') _logging_schema['console_format'] = String() @@ -25,6 +25,7 @@ _loglevels_schema = LogLevelConfigSchema('loglevels') _audio_schema = ConfigSchema('audio') _audio_schema['mixer'] = String() _audio_schema['mixer_track'] = String(optional=True) +_audio_schema['mixer_volume'] = Integer(optional=True, minimum=0, maximum=100) _audio_schema['output'] = String() _audio_schema['visualizer'] = String(optional=True) @@ -119,8 +120,8 @@ def _load(files, defaults, overrides): with io.open(filename, 'rb') as filehandle: parser.readfp(filehandle) except configparser.MissingSectionHeaderError as e: - logging.warning('%s does not have a config section, not loaded.', - filename) + logger.warning('%s does not have a config section, not loaded.', + filename) except configparser.ParsingError as e: linenos = ', '.join(str(lineno) for lineno, line in e.errors) logger.warning( @@ -167,6 +168,8 @@ def _format(config, comments, schemas, display, disable): continue output.append(b'[%s]' % bytes(schema.name)) for key, value in serialized.items(): + if isinstance(value, types.DeprecatedValue): + continue comment = bytes(comments.get(schema.name, {}).get(key, '')) output.append(b'%s =' % bytes(key)) if value is not None: diff --git a/mopidy/config/convert.py b/mopidy/config/convert.py index 3c3edb85..a3ae5273 100644 --- a/mopidy/config/convert.py +++ b/mopidy/config/convert.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import print_function, unicode_literals import io import os.path @@ -10,13 +10,13 @@ from mopidy.utils import path def load(): settings_file = path.expand_path(b'$XDG_CONFIG_DIR/mopidy/settings.py') - print 'Checking %s' % settings_file + print('Checking %s' % settings_file) setting_globals = {} try: execfile(settings_file, setting_globals) except Exception as e: - print 'Problem loading settings: %s' % e + print('Problem loading settings: %s' % e) return setting_globals @@ -36,6 +36,7 @@ def convert(settings): helper('audio/mixer', 'MIXER') helper('audio/mixer_track', 'MIXER_TRACK') + helper('audio/mixer_volume', 'MIXER_VOLUME') helper('audio/output', 'OUTPUT') helper('proxy/hostname', 'SPOTIFY_PROXY_HOST') @@ -45,7 +46,6 @@ def convert(settings): helper('local/media_dir', 'LOCAL_MUSIC_PATH') helper('local/playlists_dir', 'LOCAL_PLAYLIST_PATH') - helper('local/tag_cache_file', 'LOCAL_TAG_CACHE_FILE') helper('spotify/username', 'SPOTIFY_USERNAME') helper('spotify/password', 'SPOTIFY_PASSWORD') @@ -107,20 +107,20 @@ def main(): 'spotify', 'scrobbler', 'mpd', 'mpris', 'local', 'stream', 'http'] extensions = [e for e in ext.load_extensions() if e.ext_name in known] - print b'Converted config:\n' - print config_lib.format(config, extensions) + print(b'Converted config:\n') + print(config_lib.format(config, extensions)) conf_file = path.expand_path(b'$XDG_CONFIG_DIR/mopidy/mopidy.conf') if os.path.exists(conf_file): - print '%s exists, exiting.' % conf_file + print('%s exists, exiting.' % conf_file) sys.exit(1) - print 'Write new config to %s? [yN]' % conf_file, + print('Write new config to %s? [yN]' % conf_file, end=' ') if raw_input() != 'y': - print 'Not saving, exiting.' + print('Not saving, exiting.') sys.exit(0) serialized_config = config_lib.format(config, extensions, display=False) with io.open(conf_file, 'wb') as filehandle: filehandle.write(serialized_config) - print 'Done.' + print('Done.') diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 26b9f2e7..839c983d 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -4,12 +4,10 @@ debug_format = %(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s debug_file = mopidy.log config_file = -[loglevels] -pykka = info - [audio] mixer = software mixer_track = +mixer_volume = output = autoaudiosink visualizer = diff --git a/mopidy/config/keyring.py b/mopidy/config/keyring.py index 169ffdd1..6800d2c4 100644 --- a/mopidy/config/keyring.py +++ b/mopidy/config/keyring.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import logging -logger = logging.getLogger('mopidy.config.keyring') +logger = logging.getLogger(__name__) try: import dbus diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index a535b493..12536c0c 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -54,7 +54,8 @@ class ConfigSchema(collections.OrderedDict): def deserialize(self, values): """Validates the given ``values`` using the config schema. - Returns a tuple with cleaned values and errors.""" + Returns a tuple with cleaned values and errors. + """ errors = {} result = {} @@ -71,7 +72,9 @@ class ConfigSchema(collections.OrderedDict): errors[key] = str(e) for key in self.keys(): - if key not in result and key not in errors: + if isinstance(self[key], types.Deprecated): + result.pop(key, None) + elif key not in result and key not in errors: result[key] = None errors[key] = 'config key not found.' diff --git a/mopidy/config/types.py b/mopidy/config/types.py index d264de30..6aeaaaa7 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -31,6 +31,10 @@ class ExpandedPath(bytes): self.original = original +class DeprecatedValue(object): + pass + + class ConfigValue(object): """Represents a config key's value and how to handle it. @@ -59,6 +63,20 @@ class ConfigValue(object): return bytes(value) +class Deprecated(ConfigValue): + """Deprecated value + + Used for ignoring old config values that are no longer in use, but should + not cause the config parser to crash. + """ + + def deserialize(self, value): + return DeprecatedValue() + + def serialize(self, value, display=False): + return DeprecatedValue() + + class String(ConfigValue): """String value. diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index cd4ba180..b27bb3cc 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,11 +1,13 @@ from __future__ import unicode_literals +import collections import itertools import pykka -from mopidy.audio import AudioListener, PlaybackState -from mopidy.backends.listener import BackendListener +from mopidy import audio, backend +from mopidy.audio import PlaybackState +from mopidy.utils import versioning from .library import LibraryController from .listener import CoreListener @@ -14,22 +16,22 @@ from .playlists import PlaylistsController from .tracklist import TracklistController -class Core(pykka.ThreadingActor, AudioListener, BackendListener): - #: The library controller. An instance of - # :class:`mopidy.core.LibraryController`. +class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener): library = None + """The library controller. An instance of + :class:`mopidy.core.LibraryController`.""" - #: The playback controller. An instance of - #: :class:`mopidy.core.PlaybackController`. playback = None + """The playback controller. An instance of + :class:`mopidy.core.PlaybackController`.""" - #: The playlists controller. An instance of - #: :class:`mopidy.core.PlaylistsController`. playlists = None + """The playlists controller. An instance of + :class:`mopidy.core.PlaylistsController`.""" - #: The tracklist controller. An instance of - #: :class:`mopidy.core.TracklistController`. tracklist = None + """The tracklist controller. An instance of + :class:`mopidy.core.TracklistController`.""" def __init__(self, audio=None, backends=None): super(Core, self).__init__() @@ -55,6 +57,12 @@ class Core(pykka.ThreadingActor, AudioListener, BackendListener): uri_schemes = property(get_uri_schemes) """List of URI schemes we can handle""" + def get_version(self): + return versioning.get_version() + + version = property(get_version) + """Version of the Mopidy core API""" + def reached_end_of_stream(self): self.playback.on_end_of_track() @@ -79,34 +87,32 @@ class Backends(list): def __init__(self, backends): super(Backends, self).__init__(backends) - # These lists keeps the backends in the original order, but only - # includes those which implements the required backend provider. Since - # it is important to keep the order, we can't simply use .values() on - # the X_by_uri_scheme dicts below. - self.with_library = [b for b in backends if b.has_library().get()] - self.with_playback = [b for b in backends if b.has_playback().get()] - self.with_playlists = [ - b for b in backends if b.has_playlists().get()] + self.with_library = collections.OrderedDict() + self.with_library_browse = collections.OrderedDict() + self.with_playback = collections.OrderedDict() + self.with_playlists = collections.OrderedDict() + + backends_by_scheme = {} + name = lambda backend: backend.actor_ref.actor_class.__name__ - self.by_uri_scheme = {} for backend in backends: - for uri_scheme in backend.uri_schemes.get(): - assert uri_scheme not in self.by_uri_scheme, ( + has_library = backend.has_library().get() + has_library_browse = backend.has_library_browse().get() + has_playback = backend.has_playback().get() + has_playlists = backend.has_playlists().get() + + for scheme in backend.uri_schemes.get(): + assert scheme not in backends_by_scheme, ( 'Cannot add URI scheme %s for %s, ' 'it is already handled by %s' - ) % ( - uri_scheme, backend.__class__.__name__, - self.by_uri_scheme[uri_scheme].__class__.__name__) - self.by_uri_scheme[uri_scheme] = backend + ) % (scheme, name(backend), name(backends_by_scheme[scheme])) + backends_by_scheme[scheme] = backend - self.with_library_by_uri_scheme = {} - self.with_playback_by_uri_scheme = {} - self.with_playlists_by_uri_scheme = {} - - for uri_scheme, backend in self.by_uri_scheme.items(): - if backend.has_library().get(): - self.with_library_by_uri_scheme[uri_scheme] = backend - if backend.has_playback().get(): - self.with_playback_by_uri_scheme[uri_scheme] = backend - if backend.has_playlists().get(): - self.with_playlists_by_uri_scheme[uri_scheme] = backend + if has_library: + self.with_library[scheme] = backend + if has_library_browse: + self.with_library_browse[scheme] = backend + if has_playback: + self.with_playback[scheme] = backend + if has_playlists: + self.with_playlists[scheme] = backend diff --git a/mopidy/core/library.py b/mopidy/core/library.py index cdc3f53a..1ff4e874 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from collections import defaultdict +import collections import urlparse import pykka @@ -15,20 +15,61 @@ class LibraryController(object): def _get_backend(self, uri): uri_scheme = urlparse.urlparse(uri).scheme - return self.backends.with_library_by_uri_scheme.get(uri_scheme, None) + return self.backends.with_library.get(uri_scheme, None) def _get_backends_to_uris(self, uris): if uris: - backends_to_uris = defaultdict(list) + backends_to_uris = collections.defaultdict(list) for uri in uris: backend = self._get_backend(uri) if backend is not None: backends_to_uris[backend].append(uri) else: backends_to_uris = dict([ - (b, None) for b in self.backends.with_library]) + (b, None) for b in self.backends.with_library.values()]) return backends_to_uris + def browse(self, uri): + """ + 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. + + Returns a list of :class:`mopidy.models.Ref` objects for the + directories and tracks at the given ``uri``. + + The :class:`~mopidy.models.Ref` objects representing tracks keep the + track's original URI. A matching pair of objects can look like this:: + + Track(uri='dummy:/foo.mp3', name='foo', artists=..., album=...) + Ref.track(uri='dummy:/foo.mp3', name='foo') + + The :class:`~mopidy.models.Ref` objects representing directories have + backend specific URIs. These are opaque values, so no one but the + backend that created them should try and derive any meaning from them. + The only valid exception to this is checking the scheme, as it is used + to route browse requests to the correct backend. + + For example, the dummy library's ``/bar`` directory could be returned + like this:: + + Ref.directory(uri='dummy:directory:/bar', name='bar') + + :param string uri: URI to browse + :rtype: list of :class:`mopidy.models.Ref` + """ + if uri is None: + backends = self.backends.with_library_browse.values() + return [b.library.root_directory.get() for b in backends] + + scheme = urlparse.urlparse(uri).scheme + backend = self.backends.with_library_browse.get(scheme) + if not backend: + return [] + return backend.library.browse(uri).get() + def find_exact(self, query=None, uris=None, **kwargs): """ Search the library for tracks where ``field`` is ``values``. @@ -103,8 +144,8 @@ class LibraryController(object): if backend: backend.library.refresh(uri).get() else: - futures = [ - b.library.refresh(uri) for b in self.backends.with_library] + futures = [b.library.refresh(uri) + for b in self.backends.with_library.values()] pykka.get_all(futures) def search(self, query=None, uris=None, **kwargs): diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 40c78540..f0bb1ea3 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals -import pykka +from mopidy import listener -class CoreListener(object): +class CoreListener(listener.Listener): """ Marker interface for recipients of events sent by the core actor. @@ -17,9 +17,7 @@ class CoreListener(object): @staticmethod def send(event, **kwargs): """Helper to allow calling of core listener events""" - listeners = pykka.ActorRegistry.get_by_class(CoreListener) - for listener in listeners: - listener.proxy().on_event(event, **kwargs) + listener.send_async(CoreListener, event, **kwargs) def on_event(self, event, **kwargs): """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index d127fbbe..b2acb35a 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -8,7 +8,7 @@ from mopidy.audio import PlaybackState from . import listener -logger = logging.getLogger('mopidy.core') +logger = logging.getLogger(__name__) class PlaybackController(object): @@ -28,7 +28,7 @@ class PlaybackController(object): return None uri = self.current_tl_track.track.uri uri_scheme = urlparse.urlparse(uri).scheme - return self.backends.with_playback_by_uri_scheme.get(uri_scheme, None) + return self.backends.with_playback.get(uri_scheme, None) ### Properties @@ -160,8 +160,7 @@ class PlaybackController(object): next_tl_track = self.core.tracklist.eot_track(original_tl_track) if next_tl_track: - self._trigger_track_playback_ended() - self.play(next_tl_track) + self.change_track(next_tl_track) else: self.stop(clear_current_track=True) @@ -185,7 +184,6 @@ class PlaybackController(object): """ tl_track = self.core.tracklist.next_track(self.current_tl_track) if tl_track: - self._trigger_track_playback_ended() self.change_track(tl_track) else: self.stop(clear_current_track=True) @@ -228,6 +226,9 @@ class PlaybackController(object): assert tl_track in self.core.tracklist.tl_tracks + if self.state == PlaybackState.PLAYING: + self.stop() + self.current_tl_track = tl_track self.state = PlaybackState.PLAYING backend = self._get_backend() @@ -251,7 +252,6 @@ class PlaybackController(object): The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ - self._trigger_track_playback_ended() tl_track = self.current_tl_track self.change_track( self.core.tracklist.previous_track(tl_track), on_error_step=-1) @@ -307,8 +307,8 @@ class PlaybackController(object): if self.state != PlaybackState.STOPPED: backend = self._get_backend() if not backend or backend.playback.stop().get(): - self._trigger_track_playback_ended() self.state = PlaybackState.STOPPED + self._trigger_track_playback_ended() if clear_current_track: self.current_tl_track = None diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index f0187d44..d5c03bb3 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -16,8 +16,8 @@ class PlaylistsController(object): self.core = core def get_playlists(self, include_tracks=True): - futures = [ - b.playlists.playlists for b in self.backends.with_playlists] + futures = [b.playlists.playlists + for b in self.backends.with_playlists.values()] results = pykka.get_all(futures) playlists = list(itertools.chain(*results)) if not include_tracks: @@ -49,10 +49,11 @@ class PlaylistsController(object): :type uri_scheme: string :rtype: :class:`mopidy.models.Playlist` """ - if uri_scheme in self.backends.with_playlists_by_uri_scheme: - backend = self.backends.by_uri_scheme[uri_scheme] + if uri_scheme in self.backends.with_playlists: + backend = self.backends.with_playlists[uri_scheme] else: - backend = self.backends.with_playlists[0] + # TODO: this fallback looks suspicious + backend = self.backends.with_playlists.values()[0] playlist = backend.playlists.create(name).get() listener.CoreListener.send('playlist_changed', playlist=playlist) return playlist @@ -68,8 +69,7 @@ class PlaylistsController(object): :type uri: string """ uri_scheme = urlparse.urlparse(uri).scheme - backend = self.backends.with_playlists_by_uri_scheme.get( - uri_scheme, None) + backend = self.backends.with_playlists.get(uri_scheme, None) if backend: backend.playlists.delete(uri).get() @@ -111,8 +111,7 @@ class PlaylistsController(object): :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ uri_scheme = urlparse.urlparse(uri).scheme - backend = self.backends.with_playlists_by_uri_scheme.get( - uri_scheme, None) + backend = self.backends.with_playlists.get(uri_scheme, None) if backend: return backend.playlists.lookup(uri).get() else: @@ -131,13 +130,12 @@ class PlaylistsController(object): :type uri_scheme: string """ if uri_scheme is None: - futures = [ - b.playlists.refresh() for b in self.backends.with_playlists] + futures = [b.playlists.refresh() + for b in self.backends.with_playlists.values()] pykka.get_all(futures) listener.CoreListener.send('playlists_loaded') else: - backend = self.backends.with_playlists_by_uri_scheme.get( - uri_scheme, None) + backend = self.backends.with_playlists.get(uri_scheme, None) if backend: backend.playlists.refresh().get() listener.CoreListener.send('playlists_loaded') @@ -167,8 +165,7 @@ class PlaylistsController(object): if playlist.uri is None: return uri_scheme = urlparse.urlparse(playlist.uri).scheme - backend = self.backends.with_playlists_by_uri_scheme.get( - uri_scheme, None) + backend = self.backends.with_playlists.get(uri_scheme, None) if backend: playlist = backend.playlists.save(playlist).get() listener.CoreListener.send('playlist_changed', playlist=playlist) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index d3cc0d75..816e7b65 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -9,7 +9,7 @@ from mopidy.models import TlTrack from . import listener -logger = logging.getLogger('mopidy.core') +logger = logging.getLogger(__name__) class TracklistController(object): diff --git a/mopidy/ext.py b/mopidy/ext.py index e0f50c67..a58090cc 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import collections import logging import pkg_resources @@ -7,7 +8,7 @@ from mopidy import exceptions from mopidy import config as config_lib -logger = logging.getLogger('mopidy.ext') +logger = logging.getLogger(__name__) class Extension(object): @@ -50,43 +51,6 @@ class Extension(object): schema['enabled'] = config_lib.Boolean() return schema - def validate_environment(self): - """Checks if the extension can run in the current environment - - For example, this method can be used to check if all dependencies that - are needed are installed. - - :raises: :class:`~mopidy.exceptions.ExtensionError` - :returns: :class:`None` - """ - pass - - def get_frontend_classes(self): - """List of frontend actor classes - - Mopidy will take care of starting the actors. - - :returns: list of :class:`pykka.Actor` subclasses - """ - return [] - - def get_backend_classes(self): - """List of backend actor classes - - Mopidy will take care of starting the actors. - - :returns: list of :class:`~mopidy.backends.base.Backend` subclasses - """ - return [] - - def get_library_updaters(self): - """List of library updater classes - - :returns: list of - :class:`~mopidy.backends.base.BaseLibraryUpdateProvider` subclasses - """ - return [] - def get_command(self): """Command to expose to command line users running mopidy. @@ -95,23 +59,124 @@ class Extension(object): """ pass - def register_gstreamer_elements(self): - """Hook for registering custom GStreamer elements + def validate_environment(self): + """Checks if the extension can run in the current environment - Register custom GStreamer elements by implementing this method. - Example:: + For example, this method can be used to check if all dependencies that + are needed are installed. If a problem is found, raise + :exc:`~mopidy.exceptions.ExtensionError` with a message explaining the + issue. - def register_gstreamer_elements(self): + :raises: :exc:`~mopidy.exceptions.ExtensionError` + :returns: :class:`None` + """ + pass + + def setup(self, registry): + """ + Register the extension's components in the extension :class:`Registry`. + + For example, to register a backend:: + + def setup(self, registry): + from .backend import SoundspotBackend + registry.add('backend', SoundspotBackend) + + See :class:`Registry` for a list of registry keys with a special + meaning. Mopidy will instantiate and start any classes registered under + 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) + :param registry: the extension registry + :type registry: :class:`Registry` + """ + for backend_class in self.get_backend_classes(): + registry.add('backend', backend_class) + + for frontend_class in self.get_frontend_classes(): + registry.add('frontend', frontend_class) + + self.register_gstreamer_elements() + + def get_frontend_classes(self): + """List of frontend actor classes + + .. deprecated:: 0.18 + Use :meth:`setup` instead. + + :returns: list of :class:`pykka.Actor` subclasses + """ + return [] + + def get_backend_classes(self): + """List of backend actor classes + + .. deprecated:: 0.18 + Use :meth:`setup` instead. + + :returns: list of :class:`~mopidy.backend.Backend` subclasses + """ + return [] + + def register_gstreamer_elements(self): + """Hook for registering custom GStreamer elements. + + .. deprecated:: 0.18 + Use :meth:`setup` instead. + :returns: :class:`None` """ pass +class Registry(collections.Mapping): + """Registry of components provided by Mopidy extensions. + + Passed to the :meth:`~Extension.setup` method of all extensions. The + registry can be used like a dict of string keys and lists. + + Some keys have a special meaning, including, but not limited to: + + - ``backend`` is used for Mopidy backend classes. + - ``frontend`` is used for Mopidy frontend classes. + - ``local:library`` is used for Mopidy-Local libraries. + + Extensions can use the registry for allow other to extend the extension + itself. For example the ``Mopidy-Local`` use the ``local:library`` key to + allow other extensions to register library providers for ``Mopidy-Local`` + to use. Extensions should namespace custom keys with the extension's + :attr:`~Extension.ext_name`, e.g. ``local:foo`` or ``http:bar``. + """ + + def __init__(self): + self._registry = {} + + def add(self, name, cls): + """Add a component to the registry. + + Multiple classes can be registered to the same name. + """ + self._registry.setdefault(name, []).append(cls) + + def __getitem__(self, name): + return self._registry.setdefault(name, []) + + def __iter__(self): + return iter(self._registry) + + def __len__(self): + return len(self._registry) + + def load_extensions(): """Find all installed extensions. @@ -130,7 +195,7 @@ def load_extensions(): 'Loaded extension: %s %s', extension.dist_name, extension.version) names = (e.ext_name for e in installed_extensions) - logging.debug('Discovered extensions: %s', ', '.join(names)) + logger.debug('Discovered extensions: %s', ', '.join(names)) return installed_extensions @@ -166,15 +231,3 @@ def validate_extension(extension): return False return True - - -def register_gstreamer_elements(enabled_extensions): - """Registers custom GStreamer elements from extensions. - - :param enabled_extensions: list of enabled extensions - """ - - for extension in enabled_extensions: - logger.debug( - 'Registering GStreamer elements for: %s', extension.ext_name) - extension.register_gstreamer_elements() diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js deleted file mode 100644 index 75d9fff1..00000000 --- a/mopidy/frontends/http/data/mopidy.min.js +++ /dev/null @@ -1,5 +0,0 @@ -/*! Mopidy.js - built 2013-09-17 - * http://www.mopidy.com/ - * Copyright (c) 2013 Stein Magnus Jodal and contributors - * Licensed under the Apache License, Version 2.0 */ -function Mopidy(a){return this instanceof Mopidy?(this._settings=this._configure(a||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this.connect(),void 0):new Mopidy(a)}if(("function"==typeof define&&define.amd&&function(a){define("bane",a)}||"object"==typeof module&&function(a){module.exports=a()}||function(a){this.bane=a()})(function(){"use strict";function a(a,b,c){var d,e=c.length;if(e>0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):(d(this,a).push({listener:b(e),thisp:f}),void 0)},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),f.splice(0,f.length),void 0}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return f.splice(0,f.length),void 0;for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return f.splice(h,1),void 0},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f}}),"undefined"!=typeof window&&(window.define=function(a){try{delete window.define}catch(b){window.define=void 0}window.when=a()},window.define.amd={}),function(a,b){"use strict";a(function(a){function c(a,b,c,d){return e(a).then(b,c,d)}function d(a,b){this._message=a,this.inspect=b}function e(a){return h(function(b){b(a)})}function f(a){return c(a,k)}function g(){function a(a,f,g){b.resolve=b.resolver.resolve=function(b){return d?e(b):(d=!0,a(b),c)},b.reject=b.resolver.reject=function(a){return d?e(k(a)):(d=!0,f(a),c)},b.notify=b.resolver.notify=function(a){return g(a),a}}var b,c,d;return b={promise:S,resolve:S,reject:S,notify:S,resolver:{resolve:S,reject:S,notify:S}},b.promise=c=h(a),b}function h(a){return i(a,Q.PromiseStatus&&Q.PromiseStatus())}function i(a,b){function c(a,b,c,d){function e(e){e._message(a,b,c,d)}l?l.push(e):E(function(){e(j)})}function e(){return j?j.inspect():D()}function f(a){l&&(j=n(a),q(l,j),l=S,b&&r(j,b))}function g(a){f(k(a))}function h(a){l&&q(l,m(a))}var i,j,l=[];i=new d(c,e),i._status=b;try{a(f,g,h)}catch(o){g(o)}return i}function j(a){return l(new o(a),function(){return B(a)})}function k(a){return l(new p(a),function(){return C(a)})}function l(a,b){return new d(function(b,c,d){try{d(a[b].apply(a,c))}catch(e){d(k(e))}},b)}function m(a){return new d(function(b,c,d,e){var f=c[2];try{e("function"==typeof f?f(a):a)}catch(g){e(g)}})}function n(a){return a instanceof d?a:a===Object(a)&&"then"in a?h(function(b,c,d){E(function(){try{var e=a.then;"function"==typeof e?J(e,a,b,c,d):b(j(a))}catch(f){c(f)}})}):j(a)}function o(a){this.value=a}function p(a){this.reason=a}function q(a,b){E(function(){for(var c,d=0;c=a[d++];)c(b)})}function r(a,b){function c(){b.fulfilled()}function d(a){b.rejected(a)}a.then(c,d)}function s(a){return a&&"function"==typeof a.then}function t(a,b,d,e,f){return c(a,function(a){function g(d,e,f){function g(a){n(a)}function h(a){m(a)}var i,j,k,l,m,n,o,p;if(o=a.length>>>0,i=Math.max(0,Math.min(b,o)),k=[],j=o-i+1,l=[],i)for(n=function(a){l.push(a),--j||(m=n=G,e(l))},m=function(a){k.push(a),--i||(m=n=G,d(k))},p=0;o>p;++p)p in a&&c(a[p],h,g,f);else d(k)}return h(g).then(d,e,f)})}function u(a,b,c,d){function e(a){return b?b(a[0]):a[0]}return t(a,1,e,c,d)}function v(a,b,c,d){return z(a,G).then(b,c,d)}function w(){return z(arguments,G)}function x(a){return z(a,B,C)}function y(a,b){return z(a,b)}function z(a,b,d){return c(a,function(a){function e(e,f,g){function h(a,h){c(a,b,d).then(function(a){i[h]=a,g(a),--k||e(i)},f)}var i,j,k,l;if(k=j=a.length>>>0,i=[],!k)return e(i),void 0;for(l=0;j>l;l++)l in a?h(a[l],l):--k}return i(e)})}function A(a,b){var d=J(I,arguments,1);return c(a,function(a){var e;return e=a.length,d[0]=function(a,d,f){return c(a,function(a){return c(d,function(c){return b(a,c,f,e)})})},H.apply(a,d)})}function B(a){return{state:"fulfilled",value:a}}function C(a){return{state:"rejected",reason:a}}function D(){return{state:"pending"}}function E(a){1===L.push(a)&&K(F)}function F(){for(var a,b=0;a=L[b++];)a();L=[]}function G(a){return a}c.promise=h,c.resolve=e,c.reject=f,c.defer=g,c.join=w,c.all=v,c.map=y,c.reduce=A,c.settle=x,c.any=u,c.some=t,c.isPromise=s,c.isPromiseLike=s,d.prototype={then:function(){var a,b;return a=arguments,b=this._message,i(function(c,d,e){b("when",a,c,e)},this._status&&this._status.observed())},otherwise:function(a){return this.then(S,a)},ensure:function(a){function b(){return e(a())}return this.then(b,b).yield(this)},yield:function(a){return this.then(function(){return a})},tap:function(a){return this.then(a).yield(this)},spread:function(a){return this.then(function(b){return v(b,function(b){return a.apply(S,b)})})},always:function(a,b){return this.then(a,a,b)}},o.prototype.when=function(a){return"function"==typeof a?a(this.value):this.value},p.prototype.when=function(a,b){if("function"==typeof b)return b(this.reason);throw this.reason};var H,I,J,K,L,M,N,O,P,Q,R,S;if(R=a,L=[],M=b.setTimeout,Q="undefined"!=typeof console?console:c,"function"==typeof setImmediate)K=setImmediate.bind(b);else if("undefined"!=typeof MessageChannel){var T=new MessageChannel;T.port1.onmessage=F,K=function(){T.port2.postMessage(0)}}else if("object"==typeof process&&process.nextTick)K=process.nextTick;else try{K=R("vertx").runOnLoop||R("vertx").runOnContext}catch(U){K=function(a){M(a,0)}}return N=Function.prototype,O=N.call,J=N.bind?O.bind(O):function(a,b){return a.apply(b,I.call(arguments,2))},P=[],I=P.slice,H=P.reduce||function(a){var b,c,d,e,f;if(f=0,b=Object(this),e=b.length>>>0,c=arguments,c.length<=1)for(;;){if(f in b){d=b[f++];break}if(++f>=e)throw new TypeError}else d=c[1];for(;e>f;++f)f in b&&(d=a(d,b[f],f,b));return d},c})}("function"==typeof define&&define.amd?define:function(a){module.exports=a(require)},this),"object"==typeof module&&"function"==typeof require)var bane=require("bane"),websocket=require("faye-websocket"),when=require("when");Mopidy.WebSocket="object"==typeof module&&"function"==typeof require?websocket.Client:window.WebSocket,Mopidy.prototype._configure=function(a){var b="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||"ws://"+b+"/mopidy/ws/",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,a},Mopidy.prototype._getConsole=function(){var a="undefined"!=typeof a&&a||{};return a.log=a.log||function(){},a.warn=a.warn||function(){},a.error=a.error||function(){},a},Mopidy.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},Mopidy.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===Mopidy.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new Mopidy.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},Mopidy.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var c=this._pendingRequests[b];delete this._pendingRequests[b],c.reject({message:"WebSocket closed",closeEvent:a})}.bind(this)),this.emit("state:offline")},Mopidy.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},Mopidy.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},Mopidy.prototype._send=function(a){var b=when.defer();switch(this._webSocket.readyState){case Mopidy.WebSocket.CONNECTING:b.resolver.reject({message:"WebSocket is still connecting"});break;case Mopidy.WebSocket.CLOSING:b.resolver.reject({message:"WebSocket is closing"});break;case Mopidy.WebSocket.CLOSED:b.resolver.reject({message:"WebSocket is closed"});break;default:a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a)}return b.promise},Mopidy.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),Mopidy.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},Mopidy.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return this._console.warn("Unexpected response received. Message was:",a),void 0;var b=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?b.resolve(a.result):a.hasOwnProperty("error")?(b.reject(a.error),this._console.warn("Server returned error:",a.error)):(b.reject({message:"Response without 'result' or 'error' received",data:{response:a}}),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},Mopidy.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},Mopidy.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(a){var b=function(a){return function(){var b=Array.prototype.slice.call(arguments);return this._send({method:a,params:b})}.bind(this)}.bind(this),c=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},d=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),e=function(e){var f=c(e),g=this._snakeToCamel(f.slice(-1)[0]),h=d(f.slice(0,-1));h[g]=b(e),h[g].description=a[e].description,h[g].params=a[e].params}.bind(this);Object.keys(a).forEach(e),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},"object"==typeof exports&&(exports.Mopidy=Mopidy); \ No newline at end of file diff --git a/mopidy/frontends/mpd/protocol/empty.py b/mopidy/frontends/mpd/protocol/empty.py deleted file mode 100644 index b2bb9482..00000000 --- a/mopidy/frontends/mpd/protocol/empty.py +++ /dev/null @@ -1,9 +0,0 @@ -from __future__ import unicode_literals - -from mopidy.frontends.mpd.protocol import handle_request - - -@handle_request(r'[\ ]*$') -def empty(context): - """The original MPD server returns ``OK`` on an empty request.""" - pass diff --git a/mopidy/frontends/http/__init__.py b/mopidy/http/__init__.py similarity index 93% rename from mopidy/frontends/http/__init__.py rename to mopidy/http/__init__.py index 64cb88f9..25e2dd46 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/http/__init__.py @@ -35,6 +35,6 @@ class Extension(ext.Extension): except ImportError as e: raise exceptions.ExtensionError('ws4py library not found', e) - def get_frontend_classes(self): + def setup(self, registry): from .actor import HttpFrontend - return [HttpFrontend] + registry.add('frontend', HttpFrontend) diff --git a/mopidy/frontends/http/actor.py b/mopidy/http/actor.py similarity index 95% rename from mopidy/frontends/http/actor.py rename to mopidy/http/actor.py index 4e3493d4..e7b5cb66 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/http/actor.py @@ -9,13 +9,12 @@ import pykka from ws4py.messaging import TextMessage from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool -from mopidy import models +from mopidy import models, zeroconf from mopidy.core import CoreListener -from mopidy.utils import zeroconf from . import ws -logger = logging.getLogger('mopidy.frontends.http') +logger = logging.getLogger(__name__) class HttpFrontend(pykka.ThreadingActor, CoreListener): @@ -104,7 +103,7 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): logger.info('Registered HTTP with Zeroconf as "%s"', self.zeroconf_service.name) else: - logger.warning('Registering HTTP with Zeroconf failed.') + logger.info('Registering HTTP with Zeroconf failed.') def on_stop(self): if self.zeroconf_service: diff --git a/mopidy/frontends/http/data/favicon.png b/mopidy/http/data/favicon.png similarity index 100% rename from mopidy/frontends/http/data/favicon.png rename to mopidy/http/data/favicon.png diff --git a/mopidy/frontends/http/data/index.html b/mopidy/http/data/index.html similarity index 100% rename from mopidy/frontends/http/data/index.html rename to mopidy/http/data/index.html diff --git a/mopidy/frontends/http/data/mopidy.css b/mopidy/http/data/mopidy.css similarity index 100% rename from mopidy/frontends/http/data/mopidy.css rename to mopidy/http/data/mopidy.css diff --git a/mopidy/frontends/http/data/mopidy.html b/mopidy/http/data/mopidy.html similarity index 100% rename from mopidy/frontends/http/data/mopidy.html rename to mopidy/http/data/mopidy.html diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/http/data/mopidy.js similarity index 73% rename from mopidy/frontends/http/data/mopidy.js rename to mopidy/http/data/mopidy.js index 3e4e832e..cc72e3e6 100644 --- a/mopidy/frontends/http/data/mopidy.js +++ b/mopidy/http/data/mopidy.js @@ -1,7 +1,11 @@ -/*! Mopidy.js - built 2013-09-17 +/*! Mopidy.js - built 2014-01-04 * http://www.mopidy.com/ - * Copyright (c) 2013 Stein Magnus Jodal and contributors + * Copyright (c) 2014 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ +!function(e){if("object"==typeof exports)module.exports=e();else if("function"==typeof define&&define.amd)define(e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.Mopidy=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o 0) { + var fn = queue.shift(); + fn(); + } + } + }, true); + + return function nextTick(fn) { + queue.push(fn); + window.postMessage('process-tick', '*'); + }; + } + + return function nextTick(fn) { + setTimeout(fn, 0); }; - window.define.amd = {}; +})(); + +process.title = 'browser'; +process.browser = true; +process.env = {}; +process.argv = []; + +process.binding = function (name) { + throw new Error('process.binding is not supported'); } +// TODO(shtylman) +process.cwd = function () { return '/' }; +process.chdir = function (dir) { + throw new Error('process.chdir is not supported'); +}; + +},{}],4:[function(require,module,exports){ +var process=require("__browserify_process");/** @license MIT License (c) copyright 2011-2013 original author or authors */ + /** * A lightweight CommonJS Promises/A and when() implementation * when is part of the cujo.js family of libraries (http://cujojs.com/) @@ -187,9 +248,9 @@ if (typeof window !== "undefined") { * * @author Brian Cavalier * @author John Hann - * @version 2.4.0 + * @version 2.7.1 */ -(function(define, global) { 'use strict'; +(function(define) { 'use strict'; define(function (require) { // Public API @@ -230,7 +291,17 @@ define(function (require) { function when(promiseOrValue, onFulfilled, onRejected, onProgress) { // Get a trusted promise for the input promiseOrValue, and then // register promise handlers - return resolve(promiseOrValue).then(onFulfilled, onRejected, onProgress); + return cast(promiseOrValue).then(onFulfilled, onRejected, onProgress); + } + + /** + * Creates a new promise whose fate is determined by resolver. + * @param {function} resolver function(resolve, reject, notify) + * @returns {Promise} promise whose fate is determine by resolver + */ + function promise(resolver) { + return new Promise(resolver, + monitorApi.PromiseStatus && monitorApi.PromiseStatus()); } /** @@ -238,117 +309,214 @@ define(function (require) { * a trusted when.js promise. Any other duck-typed promise is considered * untrusted. * @constructor - * @param {function} sendMessage function to deliver messages to the promise's handler - * @param {function?} inspect function that reports the promise's state + * @returns {Promise} promise whose fate is determine by resolver * @name Promise */ - function Promise(sendMessage, inspect) { - this._message = sendMessage; + function Promise(resolver, status) { + var self, value, consumers = []; + + self = this; + this._status = status; this.inspect = inspect; + this._when = _when; + + // Call the provider resolver to seal the promise's fate + try { + resolver(promiseResolve, promiseReject, promiseNotify); + } catch(e) { + promiseReject(e); + } + + /** + * Returns a snapshot of this promise's current status at the instant of call + * @returns {{state:String}} + */ + function inspect() { + return value ? value.inspect() : toPendingState(); + } + + /** + * Private message delivery. Queues and delivers messages to + * the promise's ultimate fulfillment value or rejection reason. + * @private + */ + function _when(resolve, notify, onFulfilled, onRejected, onProgress) { + consumers ? consumers.push(deliver) : enqueue(function() { deliver(value); }); + + function deliver(p) { + p._when(resolve, notify, onFulfilled, onRejected, onProgress); + } + } + + /** + * Transition from pre-resolution state to post-resolution state, notifying + * all listeners of the ultimate fulfillment or rejection + * @param {*} val resolution value + */ + function promiseResolve(val) { + if(!consumers) { + return; + } + + var queue = consumers; + consumers = undef; + + enqueue(function () { + value = coerce(self, val); + if(status) { + updateStatus(value, status); + } + runHandlers(queue, value); + }); + } + + /** + * Reject this promise with the supplied reason, which will be used verbatim. + * @param {*} reason reason for the rejection + */ + function promiseReject(reason) { + promiseResolve(new RejectedPromise(reason)); + } + + /** + * Issue a progress event, notifying all progress listeners + * @param {*} update progress event payload to pass to all listeners + */ + function promiseNotify(update) { + if(consumers) { + var queue = consumers; + enqueue(function () { + runHandlers(queue, new ProgressingPromise(update)); + }); + } + } } - Promise.prototype = { - /** - * Register handlers for this promise. - * @param [onFulfilled] {Function} fulfillment handler - * @param [onRejected] {Function} rejection handler - * @param [onProgress] {Function} progress handler - * @return {Promise} new Promise - */ - then: function(onFulfilled, onRejected, onProgress) { - /*jshint unused:false*/ - var args, sendMessage; + promisePrototype = Promise.prototype; - args = arguments; - sendMessage = this._message; + /** + * Register handlers for this promise. + * @param [onFulfilled] {Function} fulfillment handler + * @param [onRejected] {Function} rejection handler + * @param [onProgress] {Function} progress handler + * @return {Promise} new Promise + */ + promisePrototype.then = function(onFulfilled, onRejected, onProgress) { + var self = this; - return _promise(function(resolve, reject, notify) { - sendMessage('when', args, resolve, notify); - }, this._status && this._status.observed()); - }, + return new Promise(function(resolve, reject, notify) { + self._when(resolve, notify, onFulfilled, onRejected, onProgress); + }, this._status && this._status.observed()); + }; - /** - * Register a rejection handler. Shortcut for .then(undefined, onRejected) - * @param {function?} onRejected - * @return {Promise} - */ - otherwise: function(onRejected) { - return this.then(undef, onRejected); - }, + /** + * Register a rejection handler. Shortcut for .then(undefined, onRejected) + * @param {function?} onRejected + * @return {Promise} + */ + promisePrototype['catch'] = promisePrototype.otherwise = function(onRejected) { + return this.then(undef, onRejected); + }; - /** - * Ensures that onFulfilledOrRejected will be called regardless of whether - * this promise is fulfilled or rejected. onFulfilledOrRejected WILL NOT - * receive the promises' value or reason. Any returned value will be disregarded. - * onFulfilledOrRejected may throw or return a rejected promise to signal - * an additional error. - * @param {function} onFulfilledOrRejected handler to be called regardless of - * fulfillment or rejection - * @returns {Promise} - */ - ensure: function(onFulfilledOrRejected) { - return this.then(injectHandler, injectHandler)['yield'](this); + /** + * Ensures that onFulfilledOrRejected will be called regardless of whether + * this promise is fulfilled or rejected. onFulfilledOrRejected WILL NOT + * receive the promises' value or reason. Any returned value will be disregarded. + * onFulfilledOrRejected may throw or return a rejected promise to signal + * an additional error. + * @param {function} onFulfilledOrRejected handler to be called regardless of + * fulfillment or rejection + * @returns {Promise} + */ + promisePrototype['finally'] = promisePrototype.ensure = function(onFulfilledOrRejected) { + return typeof onFulfilledOrRejected === 'function' + ? this.then(injectHandler, injectHandler)['yield'](this) + : this; - function injectHandler() { - return resolve(onFulfilledOrRejected()); - } - }, - - /** - * Shortcut for .then(function() { return value; }) - * @param {*} value - * @return {Promise} a promise that: - * - is fulfilled if value is not a promise, or - * - if value is a promise, will fulfill with its value, or reject - * with its reason. - */ - 'yield': function(value) { - return this.then(function() { - return value; - }); - }, - - /** - * Runs a side effect when this promise fulfills, without changing the - * fulfillment value. - * @param {function} onFulfilledSideEffect - * @returns {Promise} - */ - tap: function(onFulfilledSideEffect) { - return this.then(onFulfilledSideEffect)['yield'](this); - }, - - /** - * Assumes that this promise will fulfill with an array, and arranges - * for the onFulfilled to be called with the array as its argument list - * i.e. onFulfilled.apply(undefined, array). - * @param {function} onFulfilled function to receive spread arguments - * @return {Promise} - */ - spread: function(onFulfilled) { - return this.then(function(array) { - // array may contain promises, so resolve its contents. - return all(array, function(array) { - return onFulfilled.apply(undef, array); - }); - }); - }, - - /** - * Shortcut for .then(onFulfilledOrRejected, onFulfilledOrRejected) - * @deprecated - */ - always: function(onFulfilledOrRejected, onProgress) { - return this.then(onFulfilledOrRejected, onFulfilledOrRejected, onProgress); + function injectHandler() { + return resolve(onFulfilledOrRejected()); } }; + /** + * Terminate a promise chain by handling the ultimate fulfillment value or + * rejection reason, and assuming responsibility for all errors. if an + * error propagates out of handleResult or handleFatalError, it will be + * rethrown to the host, resulting in a loud stack track on most platforms + * and a crash on some. + * @param {function?} handleResult + * @param {function?} handleError + * @returns {undefined} + */ + promisePrototype.done = function(handleResult, handleError) { + this.then(handleResult, handleError)['catch'](crash); + }; + + /** + * Shortcut for .then(function() { return value; }) + * @param {*} value + * @return {Promise} a promise that: + * - is fulfilled if value is not a promise, or + * - if value is a promise, will fulfill with its value, or reject + * with its reason. + */ + promisePrototype['yield'] = function(value) { + return this.then(function() { + return value; + }); + }; + + /** + * Runs a side effect when this promise fulfills, without changing the + * fulfillment value. + * @param {function} onFulfilledSideEffect + * @returns {Promise} + */ + promisePrototype.tap = function(onFulfilledSideEffect) { + return this.then(onFulfilledSideEffect)['yield'](this); + }; + + /** + * Assumes that this promise will fulfill with an array, and arranges + * for the onFulfilled to be called with the array as its argument list + * i.e. onFulfilled.apply(undefined, array). + * @param {function} onFulfilled function to receive spread arguments + * @return {Promise} + */ + promisePrototype.spread = function(onFulfilled) { + return this.then(function(array) { + // array may contain promises, so resolve its contents. + return all(array, function(array) { + return onFulfilled.apply(undef, array); + }); + }); + }; + + /** + * Shortcut for .then(onFulfilledOrRejected, onFulfilledOrRejected) + * @deprecated + */ + promisePrototype.always = function(onFulfilledOrRejected, onProgress) { + return this.then(onFulfilledOrRejected, onFulfilledOrRejected, onProgress); + }; + + /** + * Casts x to a trusted promise. If x is already a trusted promise, it is + * returned, otherwise a new trusted Promise which follows x is returned. + * @param {*} x + * @returns {Promise} + */ + function cast(x) { + return x instanceof Promise ? x : resolve(x); + } + /** * Returns a resolved promise. The returned promise will be * - fulfilled with promiseOrValue if it is a value, or * - if promiseOrValue is a promise * - fulfilled with promiseOrValue's value after it is fulfilled * - rejected with promiseOrValue's reason after it is rejected + * In contract to cast(x), this always creates a new Promise * @param {*} value * @return {Promise} */ @@ -369,7 +537,9 @@ define(function (require) { * @return {Promise} rejected {@link Promise} */ function reject(promiseOrValue) { - return when(promiseOrValue, rejected); + return when(promiseOrValue, function(e) { + return new RejectedPromise(e); + }); } /** @@ -414,7 +584,7 @@ define(function (require) { deferred.reject = deferred.resolver.reject = function(reason) { if(resolved) { - return resolve(rejected(reason)); + return resolve(new RejectedPromise(reason)); } resolved = true; rejectPending(reason); @@ -429,169 +599,17 @@ define(function (require) { } /** - * Creates a new promise whose fate is determined by resolver. - * @param {function} resolver function(resolve, reject, notify) - * @returns {Promise} promise whose fate is determine by resolver + * Run a queue of functions as quickly as possible, passing + * value to each. */ - function promise(resolver) { - return _promise(resolver, monitorApi.PromiseStatus && monitorApi.PromiseStatus()); - } - - /** - * Creates a new promise, linked to parent, whose fate is determined - * by resolver. - * @param {function} resolver function(resolve, reject, notify) - * @param {Promise?} status promise from which the new promise is begotten - * @returns {Promise} promise whose fate is determine by resolver - * @private - */ - function _promise(resolver, status) { - var self, value, consumers = []; - - self = new Promise(_message, inspect); - self._status = status; - - // Call the provider resolver to seal the promise's fate - try { - resolver(promiseResolve, promiseReject, promiseNotify); - } catch(e) { - promiseReject(e); + function runHandlers(queue, value) { + for (var i = 0; i < queue.length; i++) { + queue[i](value); } - - // Return the promise - return self; - - /** - * Private message delivery. Queues and delivers messages to - * the promise's ultimate fulfillment value or rejection reason. - * @private - * @param {String} type - * @param {Array} args - * @param {Function} resolve - * @param {Function} notify - */ - function _message(type, args, resolve, notify) { - consumers ? consumers.push(deliver) : enqueue(function() { deliver(value); }); - - function deliver(p) { - p._message(type, args, resolve, notify); - } - } - - /** - * Returns a snapshot of the promise's state at the instant inspect() - * is called. The returned object is not live and will not update as - * the promise's state changes. - * @returns {{ state:String, value?:*, reason?:* }} status snapshot - * of the promise. - */ - function inspect() { - return value ? value.inspect() : toPendingState(); - } - - /** - * Transition from pre-resolution state to post-resolution state, notifying - * all listeners of the ultimate fulfillment or rejection - * @param {*|Promise} val resolution value - */ - function promiseResolve(val) { - if(!consumers) { - return; - } - - value = coerce(val); - scheduleConsumers(consumers, value); - consumers = undef; - - if(status) { - updateStatus(value, status); - } - } - - /** - * Reject this promise with the supplied reason, which will be used verbatim. - * @param {*} reason reason for the rejection - */ - function promiseReject(reason) { - promiseResolve(rejected(reason)); - } - - /** - * Issue a progress event, notifying all progress listeners - * @param {*} update progress event payload to pass to all listeners - */ - function promiseNotify(update) { - if(consumers) { - scheduleConsumers(consumers, progressed(update)); - } - } - } - - /** - * Creates a fulfilled, local promise as a proxy for a value - * NOTE: must never be exposed - * @param {*} value fulfillment value - * @returns {Promise} - */ - function fulfilled(value) { - return near( - new NearFulfilledProxy(value), - function() { return toFulfilledState(value); } - ); - } - - /** - * Creates a rejected, local promise with the supplied reason - * NOTE: must never be exposed - * @param {*} reason rejection reason - * @returns {Promise} - */ - function rejected(reason) { - return near( - new NearRejectedProxy(reason), - function() { return toRejectedState(reason); } - ); - } - - /** - * Creates a near promise using the provided proxy - * NOTE: must never be exposed - * @param {object} proxy proxy for the promise's ultimate value or reason - * @param {function} inspect function that returns a snapshot of the - * returned near promise's state - * @returns {Promise} - */ - function near(proxy, inspect) { - return new Promise(function (type, args, resolve) { - try { - resolve(proxy[type].apply(proxy, args)); - } catch(e) { - resolve(rejected(e)); - } - }, inspect); - } - - /** - * Create a progress promise with the supplied update. - * @private - * @param {*} update - * @return {Promise} progress promise - */ - function progressed(update) { - return new Promise(function (type, args, _, notify) { - var onProgress = args[2]; - try { - notify(typeof onProgress === 'function' ? onProgress(update) : update); - } catch(e) { - notify(e); - } - }); } /** * Coerces x to a trusted Promise - * - * @private * @param {*} x thing to coerce * @returns {*} Guaranteed to return a trusted Promise. If x * is trusted, returns x, otherwise, returns a new, trusted, already-resolved @@ -599,83 +617,121 @@ define(function (require) { * * the resolution value of x if it's a foreign promise, or * * x if it's a value */ - function coerce(x) { + function coerce(self, x) { + if (x === self) { + return new RejectedPromise(new TypeError()); + } + if (x instanceof Promise) { return x; } - if (!(x === Object(x) && 'then' in x)) { - return fulfilled(x); + try { + var untrustedThen = x === Object(x) && x.then; + + return typeof untrustedThen === 'function' + ? assimilate(untrustedThen, x) + : new FulfilledPromise(x); + } catch(e) { + return new RejectedPromise(e); } - - return promise(function(resolve, reject, notify) { - enqueue(function() { - try { - // We must check and assimilate in the same tick, but not the - // current tick, careful only to access promiseOrValue.then once. - var untrustedThen = x.then; - - if(typeof untrustedThen === 'function') { - fcall(untrustedThen, x, resolve, reject, notify); - } else { - // It's a value, create a fulfilled wrapper - resolve(fulfilled(x)); - } - - } catch(e) { - // Something went wrong, reject - reject(e); - } - }); - }); } /** - * Proxy for a near, fulfilled value - * @param {*} value - * @constructor + * Safely assimilates a foreign thenable by wrapping it in a trusted promise + * @param {function} untrustedThen x's then() method + * @param {object|function} x thenable + * @returns {Promise} */ - function NearFulfilledProxy(value) { + function assimilate(untrustedThen, x) { + return promise(function (resolve, reject) { + fcall(untrustedThen, x, resolve, reject); + }); + } + + makePromisePrototype = Object.create || + function(o) { + function PromisePrototype() {} + PromisePrototype.prototype = o; + return new PromisePrototype(); + }; + + /** + * Creates a fulfilled, local promise as a proxy for a value + * NOTE: must never be exposed + * @private + * @param {*} value fulfillment value + * @returns {Promise} + */ + function FulfilledPromise(value) { this.value = value; } - NearFulfilledProxy.prototype.when = function(onResult) { - return typeof onResult === 'function' ? onResult(this.value) : this.value; + FulfilledPromise.prototype = makePromisePrototype(promisePrototype); + + FulfilledPromise.prototype.inspect = function() { + return toFulfilledState(this.value); }; - /** - * Proxy for a near rejection - * @param {*} reason - * @constructor - */ - function NearRejectedProxy(reason) { - this.reason = reason; - } - - NearRejectedProxy.prototype.when = function(_, onError) { - if(typeof onError === 'function') { - return onError(this.reason); - } else { - throw this.reason; + FulfilledPromise.prototype._when = function(resolve, _, onFulfilled) { + try { + resolve(typeof onFulfilled === 'function' ? onFulfilled(this.value) : this.value); + } catch(e) { + resolve(new RejectedPromise(e)); } }; /** - * Schedule a task that will process a list of handlers - * in the next queue drain run. + * Creates a rejected, local promise as a proxy for a value + * NOTE: must never be exposed * @private - * @param {Array} handlers queue of handlers to execute - * @param {*} value passed as the only arg to each handler + * @param {*} reason rejection reason + * @returns {Promise} */ - function scheduleConsumers(handlers, value) { - enqueue(function() { - var handler, i = 0; - while (handler = handlers[i++]) { - handler(value); - } - }); + function RejectedPromise(reason) { + this.value = reason; } + RejectedPromise.prototype = makePromisePrototype(promisePrototype); + + RejectedPromise.prototype.inspect = function() { + return toRejectedState(this.value); + }; + + RejectedPromise.prototype._when = function(resolve, _, __, onRejected) { + try { + resolve(typeof onRejected === 'function' ? onRejected(this.value) : this); + } catch(e) { + resolve(new RejectedPromise(e)); + } + }; + + /** + * Create a progress promise with the supplied update. + * @private + * @param {*} value progress update value + * @return {Promise} progress promise + */ + function ProgressingPromise(value) { + this.value = value; + } + + ProgressingPromise.prototype = makePromisePrototype(promisePrototype); + + ProgressingPromise.prototype._when = function(_, notify, f, r, u) { + try { + notify(typeof u === 'function' ? u(this.value) : this.value); + } catch(e) { + notify(e); + } + }; + + /** + * Update a PromiseStatus monitor object with the outcome + * of the supplied value promise. + * @param {Promise} value + * @param {PromiseStatus} status + */ function updateStatus(value, status) { value.then(statusFulfilled, statusRejected); @@ -852,7 +908,7 @@ define(function (require) { function _map(array, mapFunc, fallback) { return when(array, function(array) { - return _promise(resolveMap); + return new Promise(resolveMap); function resolveMap(resolve, reject, notify) { var results, len, toResolve, i; @@ -879,12 +935,11 @@ define(function (require) { function resolveOne(item, i) { when(item, mapFunc, fallback).then(function(mapped) { results[i] = mapped; - notify(mapped); if(!--toResolve) { resolve(results); } - }, reject); + }, reject, notify); } } }); @@ -960,9 +1015,9 @@ define(function (require) { // Internals, utilities, etc. // - var reduceArray, slice, fcall, nextTick, handlerQueue, - setTimeout, funcProto, call, arrayProto, monitorApi, - cjsRequire, undef; + var promisePrototype, makePromisePrototype, reduceArray, slice, fcall, nextTick, handlerQueue, + funcProto, call, arrayProto, monitorApi, + capturedSetTimeout, cjsRequire, MutationObs, undef; cjsRequire = require; @@ -992,39 +1047,39 @@ define(function (require) { * processing until it is truly empty. */ function drainQueue() { - var task, i = 0; - - while(task = handlerQueue[i++]) { - task(); - } - + runHandlers(handlerQueue); handlerQueue = []; } - // capture setTimeout to avoid being caught by fake timers - // used in time based tests - setTimeout = global.setTimeout; - // Allow attaching the monitor to when() if env has no console - monitorApi = typeof console != 'undefined' ? console : when; + monitorApi = typeof console !== 'undefined' ? console : when; - // Prefer setImmediate or MessageChannel, cascade to node, - // vertx and finally setTimeout - /*global setImmediate,MessageChannel,process*/ - if (typeof setImmediate === 'function') { - nextTick = setImmediate.bind(global); - } else if(typeof MessageChannel !== 'undefined') { - var channel = new MessageChannel(); - channel.port1.onmessage = drainQueue; - nextTick = function() { channel.port2.postMessage(0); }; - } else if (typeof process === 'object' && process.nextTick) { + // Sniff "best" async scheduling option + // Prefer process.nextTick or MutationObserver, then check for + // vertx and finally fall back to setTimeout + /*global process,document,setTimeout,MutationObserver,WebKitMutationObserver*/ + if (typeof process === 'object' && process.nextTick) { nextTick = process.nextTick; + } else if(MutationObs = + (typeof MutationObserver === 'function' && MutationObserver) || + (typeof WebKitMutationObserver === 'function' && WebKitMutationObserver)) { + nextTick = (function(document, MutationObserver, drainQueue) { + var el = document.createElement('div'); + new MutationObserver(drainQueue).observe(el, { attributes: true }); + + return function() { + el.setAttribute('x', 'x'); + }; + }(document, MutationObs, drainQueue)); } else { try { // vert.x 1.x || 2.x nextTick = cjsRequire('vertx').runOnLoop || cjsRequire('vertx').runOnContext; } catch(ignore) { - nextTick = function(t) { setTimeout(t, 0); }; + // capture setTimeout to avoid being caught by fake timers + // used in time based tests + capturedSetTimeout = setTimeout; + nextTick = function(t) { capturedSetTimeout(t, 0); }; } } @@ -1095,15 +1150,28 @@ define(function (require) { return x; } + function crash(fatalError) { + if(typeof monitorApi.reportUnhandled === 'function') { + monitorApi.reportUnhandled(); + } else { + enqueue(function() { + throw fatalError; + }); + } + + throw fatalError; + } + return when; }); -})(typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(require); }, this); +})(typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(require); }); -if (typeof module === "object" && typeof require === "function") { - var bane = require("bane"); - var websocket = require("faye-websocket"); - var when = require("when"); -} +},{"__browserify_process":3}],5:[function(require,module,exports){ +/*global module:true, require:false*/ + +var bane = require("bane"); +var websocket = require("../lib/websocket/"); +var when = require("when"); function Mopidy(settings) { if (!(this instanceof Mopidy)) { @@ -1125,11 +1193,7 @@ function Mopidy(settings) { } } -if (typeof module === "object" && typeof require === "function") { - Mopidy.WebSocket = websocket.Client; -} else { - Mopidy.WebSocket = window.WebSocket; -} +Mopidy.WebSocket = websocket.Client; Mopidy.prototype._configure = function (settings) { var currentHost = (typeof document !== "undefined" && @@ -1394,6 +1458,8 @@ Mopidy.prototype._snakeToCamel = function (name) { }); }; -if (typeof exports === "object") { - exports.Mopidy = Mopidy; -} +module.exports = Mopidy; + +},{"../lib/websocket/":1,"bane":2,"when":4}]},{},[5]) +(5) +}); \ No newline at end of file diff --git a/mopidy/http/data/mopidy.min.js b/mopidy/http/data/mopidy.min.js new file mode 100644 index 00000000..450911bd --- /dev/null +++ b/mopidy/http/data/mopidy.min.js @@ -0,0 +1,5 @@ +/*! Mopidy.js - built 2014-01-04 + * http://www.mopidy.com/ + * Copyright (c) 2014 Stein Magnus Jodal and contributors + * Licensed under the Apache License, Version 2.0 */ +!function(a){if("object"==typeof exports)module.exports=a();else if("function"==typeof define&&define.amd)define(a);else{var b;"undefined"!=typeof window?b=window:"undefined"!=typeof global?b=global:"undefined"!=typeof self&&(b=self),b.Mopidy=a()}}(function(){var a;return function b(a,c,d){function e(g,h){if(!c[g]){if(!a[g]){var i="function"==typeof require&&require;if(!h&&i)return i(g,!0);if(f)return f(g,!0);throw new Error("Cannot find module '"+g+"'")}var j=c[g]={exports:{}};a[g][0].call(j.exports,function(b){var c=a[g][1][b];return e(c?c:b)},j,j.exports,b,a,c,d)}return c[g].exports}for(var f="function"==typeof require&&require,g=0;g0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):(d(this,a).push({listener:b(e),thisp:f}),void 0)},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),f.splice(0,f.length),void 0}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return f.splice(0,f.length),void 0;for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return f.splice(h,1),void 0},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f,aggregate:function(a){var b=f();return a.forEach(function(a){a.on(function(a,c){b.emit(a,c)})}),b}}})},{}],3:[function(a,b){var c=b.exports={};c.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){var b=a.source;if((b===window||null===b)&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var d=c.shift();d()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),c.title="browser",c.browser=!0,c.env={},c.argv=[],c.binding=function(){throw new Error("process.binding is not supported")},c.cwd=function(){return"/"},c.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){var d=b("__browserify_process");!function(a){"use strict";a(function(a){function b(a,b,c,d){return f(a).then(b,c,d)}function c(a){return new e(a,Q.PromiseStatus&&Q.PromiseStatus())}function e(a,b){function c(){return i?i.inspect():B()}function d(a,b,c,d,e){function f(f){f._when(a,b,c,d,e)}l?l.push(f):C(function(){f(i)})}function e(a){if(l){var c=l;l=U,C(function(){i=k(h,a),b&&p(i,b),j(c,i)})}}function f(a){e(new n(a))}function g(a){if(l){var b=l;C(function(){j(b,new o(a))})}}var h,i,l=[];h=this,this._status=b,this.inspect=c,this._when=d;try{a(e,f,g)}catch(m){f(m)}}function f(a){return a instanceof e?a:g(a)}function g(a){return c(function(b){b(a)})}function h(a){return b(a,function(a){return new n(a)})}function i(){function a(a,c,f){b.resolve=b.resolver.resolve=function(b){return e?g(b):(e=!0,a(b),d)},b.reject=b.resolver.reject=function(a){return e?g(new n(a)):(e=!0,c(a),d)},b.notify=b.resolver.notify=function(a){return f(a),a}}var b,d,e;return b={promise:U,resolve:U,reject:U,notify:U,resolver:{resolve:U,reject:U,notify:U}},b.promise=d=c(a),b}function j(a,b){for(var c=0;c>>0,i=Math.max(0,Math.min(d,o)),k=[],j=o-i+1,l=[],i)for(n=function(a){l.push(a),--j||(m=n=E,e(l))},m=function(a){k.push(a),--i||(m=n=E,c(k))},p=0;o>p;++p)p in a&&b(a[p],h,g,f);else c(k)}return c(h).then(e,f,g)})}function s(a,b,c,d){function e(a){return b?b(a[0]):a[0]}return r(a,1,e,c,d)}function t(a,b,c,d){return x(a,E).then(b,c,d)}function u(){return x(arguments,E)}function v(a){return x(a,z,A)}function w(a,b){return x(a,b)}function x(a,c,d){return b(a,function(a){function f(e,f,g){function h(a,h){b(a,c,d).then(function(a){i[h]=a,--k||e(i)},f,g)}var i,j,k,l;if(k=j=a.length>>>0,i=[],!k)return e(i),void 0;for(l=0;j>l;l++)l in a?h(a[l],l):--k}return new e(f)})}function y(a,c){var d=K(J,arguments,1);return b(a,function(a){var e;return e=a.length,d[0]=function(a,d,f){return b(a,function(a){return b(d,function(b){return c(a,b,f,e)})})},I.apply(a,d)})}function z(a){return{state:"fulfilled",value:a}}function A(a){return{state:"rejected",reason:a}}function B(){return{state:"pending"}}function C(a){1===M.push(a)&&L(D)}function D(){j(M),M=[]}function E(a){return a}function F(a){throw"function"==typeof Q.reportUnhandled?Q.reportUnhandled():C(function(){throw a}),a}b.promise=c,b.resolve=g,b.reject=h,b.defer=i,b.join=u,b.all=t,b.map=w,b.reduce=y,b.settle=v,b.any=s,b.some=r,b.isPromise=q,b.isPromiseLike=q,G=e.prototype,G.then=function(a,b,c){var d=this;return new e(function(e,f,g){d._when(e,g,a,b,c)},this._status&&this._status.observed())},G["catch"]=G.otherwise=function(a){return this.then(U,a)},G["finally"]=G.ensure=function(a){function b(){return g(a())}return"function"==typeof a?this.then(b,b).yield(this):this},G.done=function(a,b){this.then(a,b)["catch"](F)},G.yield=function(a){return this.then(function(){return a})},G.tap=function(a){return this.then(a).yield(this)},G.spread=function(a){return this.then(function(b){return t(b,function(b){return a.apply(U,b)})})},G.always=function(a,b){return this.then(a,a,b)},H=Object.create||function(a){function b(){}return b.prototype=a,new b},m.prototype=H(G),m.prototype.inspect=function(){return z(this.value)},m.prototype._when=function(a,b,c){try{a("function"==typeof c?c(this.value):this.value)}catch(d){a(new n(d))}},n.prototype=H(G),n.prototype.inspect=function(){return A(this.value)},n.prototype._when=function(a,b,c,d){try{a("function"==typeof d?d(this.value):this)}catch(e){a(new n(e))}},o.prototype=H(G),o.prototype._when=function(a,b,c,d,e){try{b("function"==typeof e?e(this.value):this.value)}catch(f){b(f)}};var G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U;if(S=a,M=[],Q="undefined"!=typeof console?console:b,"object"==typeof d&&d.nextTick)L=d.nextTick;else if(T="function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver)L=function(a,b,c){var d=a.createElement("div");return new b(c).observe(d,{attributes:!0}),function(){d.setAttribute("x","x")}}(document,T,D);else try{L=S("vertx").runOnLoop||S("vertx").runOnContext}catch(V){R=setTimeout,L=function(a){R(a,0)}}return N=Function.prototype,O=N.call,K=N.bind?O.bind(O):function(a,b){return a.apply(b,J.call(arguments,2))},P=[],J=P.slice,I=P.reduce||function(a){var b,c,d,e,f;if(f=0,b=Object(this),e=b.length>>>0,c=arguments,c.length<=1)for(;;){if(f in b){d=b[f++];break}if(++f>=e)throw new TypeError}else d=c[1];for(;e>f;++f)f in b&&(d=a(d,b[f],f,b));return d},b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{__browserify_process:3}],5:[function(a,b){function c(a){return this instanceof c?(this._settings=this._configure(a||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this.connect(),void 0):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.WebSocket=e.Client,c.prototype._configure=function(a){var b="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||"ws://"+b+"/mopidy/ws/",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,a},c.prototype._getConsole=function(){var a="undefined"!=typeof a&&a||{};return a.log=a.log||function(){},a.warn=a.warn||function(){},a.error=a.error||function(){},a},c.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var c=this._pendingRequests[b];delete this._pendingRequests[b],c.reject({message:"WebSocket closed",closeEvent:a})}.bind(this)),this.emit("state:offline")},c.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){var b=f.defer();switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:b.resolver.reject({message:"WebSocket is still connecting"});break;case c.WebSocket.CLOSING:b.resolver.reject({message:"WebSocket is closing"});break;case c.WebSocket.CLOSED:b.resolver.reject({message:"WebSocket is closed"});break;default:a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a)}return b.promise},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return this._console.warn("Unexpected response received. Message was:",a),void 0;var b=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?b.resolve(a.result):a.hasOwnProperty("error")?(b.reject(a.error),this._console.warn("Server returned error:",a.error)):(b.reject({message:"Response without 'result' or 'error' received",data:{response:a}}),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},c.prototype._createApi=function(a){var b=function(a){return function(){var b=Array.prototype.slice.call(arguments);return this._send({method:a,params:b})}.bind(this)}.bind(this),c=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},d=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),e=function(e){var f=c(e),g=this._snakeToCamel(f.slice(-1)[0]),h=d(f.slice(0,-1));h[g]=b(e),h[g].description=a[e].description,h[g].params=a[e].params}.bind(this);Object.keys(a).forEach(e),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:4}]},{},[5])(5)}); \ No newline at end of file diff --git a/mopidy/frontends/http/ext.conf b/mopidy/http/ext.conf similarity index 100% rename from mopidy/frontends/http/ext.conf rename to mopidy/http/ext.conf diff --git a/mopidy/frontends/http/ws.py b/mopidy/http/ws.py similarity index 93% rename from mopidy/frontends/http/ws.py rename to mopidy/http/ws.py index b46b450e..4d7aa9a2 100644 --- a/mopidy/frontends/http/ws.py +++ b/mopidy/http/ws.py @@ -9,7 +9,7 @@ from mopidy import core, models from mopidy.utils import jsonrpc -logger = logging.getLogger('mopidy.frontends.http') +logger = logging.getLogger(__name__) class WebSocketResource(object): @@ -18,6 +18,7 @@ class WebSocketResource(object): inspector = jsonrpc.JsonRpcInspector( objects={ 'core.get_uri_schemes': core.Core.get_uri_schemes, + 'core.get_version': core.Core.get_version, 'core.library': core.LibraryController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, @@ -27,6 +28,7 @@ class WebSocketResource(object): objects={ 'core.describe': inspector.describe, 'core.get_uri_schemes': self._core.get_uri_schemes, + 'core.get_version': self._core.get_version, 'core.library': self._core.library, 'core.playback': self._core.playback, 'core.playlists': self._core.playlists, diff --git a/mopidy/listener.py b/mopidy/listener.py new file mode 100644 index 00000000..cce5556d --- /dev/null +++ b/mopidy/listener.py @@ -0,0 +1,34 @@ +from __future__ import unicode_literals + +import logging + +import gobject +import pykka + +logger = logging.getLogger(__name__) + + +def send_async(cls, event, **kwargs): + gobject.idle_add(lambda: send(cls, event, **kwargs)) + + +def send(cls, event, **kwargs): + listeners = pykka.ActorRegistry.get_by_class(cls) + logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs) + for listener in listeners: + listener.proxy().on_event(event, **kwargs) + + +class Listener(object): + def on_event(self, event, **kwargs): + """ + Called on all events. + + *MAY* be implemented by actor. By default, this method forwards the + event to the specific event methods. + + :param event: the event name + :type event: string + :param kwargs: any other arguments to the specific event handlers + """ + getattr(self, event)(**kwargs) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py new file mode 100644 index 00000000..8b4a8b1f --- /dev/null +++ b/mopidy/local/__init__.py @@ -0,0 +1,166 @@ +from __future__ 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-Local' + ext_name = 'local' + 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['library'] = config.String() + schema['media_dir'] = config.Path() + schema['data_dir'] = config.Path() + schema['playlists_dir'] = config.Path() + schema['tag_cache_file'] = config.Deprecated() + schema['scan_timeout'] = config.Integer( + minimum=1000, maximum=1000*60*60) + schema['scan_flush_threshold'] = config.Integer(minimum=0) + schema['excluded_file_extensions'] = config.List(optional=True) + return schema + + def setup(self, registry): + from .actor import LocalBackend + from .json import JsonLibrary + + LocalBackend.libraries = registry['local:library'] + + registry.add('backend', LocalBackend) + registry.add('local:library', JsonLibrary) + + def get_command(self): + from .commands import LocalCommand + return LocalCommand() + + +class Library(object): + """ + Local library interface. + + Extensions that wish to provide an alternate local library storage backend + need to sub-class this class and install and configure it with an + extension. Both scanning and library calls will use the active local + library. + + :param config: Config dictionary + """ + + #: Name of the local library implementation, must be overriden. + name = None + + def __init__(self, config): + self._config = config + + def browse(self, path): + """ + Browse directories and tracks at the given path. + + :param string path: path to browse or None for root. + :rtype: List of :class:`~mopidy.models.Ref` tracks and directories. + """ + raise NotImplementedError + + def load(self): + """ + (Re)load any tracks stored in memory, if any, otherwise just return + number of available tracks currently available. Will be called at + startup for both library and update use cases, so if you plan to store + tracks in memory this is when the should be (re)loaded. + + :rtype: :class:`int` representing number of tracks in library. + """ + return 0 + + def lookup(self, uri): + """ + Lookup the given URI. + + Unlike the core APIs, local tracks uris can only be resolved to a + single track. + + :param string uri: track URI + :rtype: :class:`~mopidy.models.Track` + """ + raise NotImplementedError + + # TODO: remove uris, replacing it with support in query language. + # TODO: remove exact, replacing it with support in query language. + def search(self, query=None, limit=100, offset=0, exact=False, uris=None): + """ + Search the library for tracks where ``field`` contains ``values``. + + :param dict query: one or more queries to search for + :param int limit: maximum number of results to return + :param int offset: offset into result set to use. + :param bool exact: whether to look for exact matches + :param uris: zero or more URI roots to limit the search to + :type uris: list of strings or :class:`None` + :rtype: :class:`~mopidy.models.SearchResult` + """ + raise NotImplementedError + + # TODO: add file browsing support. + + # Remaining methods are use for the update process. + def begin(self): + """ + Prepare library for accepting updates. Exactly what this means is + highly implementation depended. This must however return an iterator + that generates all tracks in the library for efficient scanning. + + :rtype: :class:`~mopidy.models.Track` iterator + """ + raise NotImplementedError + + def add(self, track): + """ + Add the given track to library. + + :param track: Track to add to the library + :type track: :class:`~mopidy.models.Track` + """ + raise NotImplementedError + + def remove(self, uri): + """ + Remove the given track from the library. + + :param str uri: URI to remove from the library/ + """ + raise NotImplementedError + + def flush(self): + """ + Called for every n-th track indicating that work should be committed. + Sub-classes are free to ignore these hints. + + :rtype: Boolean indicating if state was flushed. + """ + return False + + def close(self): + """ + Close any resources used for updating, commit outstanding work etc. + """ + pass + + def clear(self): + """ + Clear out whatever data storage is used by this backend. + + :rtype: Boolean indicating if state was cleared. + """ + return False diff --git a/mopidy/backends/local/actor.py b/mopidy/local/actor.py similarity index 59% rename from mopidy/backends/local/actor.py rename to mopidy/local/actor.py index f3611891..61becc72 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/local/actor.py @@ -5,17 +5,20 @@ import os import pykka -from mopidy.backends import base +from mopidy import backend from mopidy.utils import encoding, path from .library import LocalLibraryProvider -from .playlists import LocalPlaylistsProvider from .playback import LocalPlaybackProvider +from .playlists import LocalPlaylistsProvider -logger = logging.getLogger('mopidy.backends.local') +logger = logging.getLogger(__name__) -class LocalBackend(pykka.ThreadingActor, base.Backend): +class LocalBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes = ['local'] + libraries = [] + def __init__(self, config, audio): super(LocalBackend, self).__init__() @@ -23,27 +26,36 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.check_dirs_and_files() - self.library = LocalLibraryProvider(backend=self) + libraries = dict((l.name, l) for l in self.libraries) + library_name = config['local']['library'] + + if library_name in libraries: + library = libraries[library_name](config) + logger.debug('Using %s as the local library', library_name) + else: + library = None + logger.warning('Local library %s not found', library_name) + self.playback = LocalPlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) - - self.uri_schemes = ['local'] + self.library = LocalLibraryProvider(backend=self, library=library) def check_dirs_and_files(self): if not os.path.isdir(self.config['local']['media_dir']): logger.warning('Local media dir %s does not exist.' % self.config['local']['media_dir']) + try: + path.get_or_create_dir(self.config['local']['data_dir']) + except EnvironmentError as error: + logger.warning( + 'Could not create local data dir: %s', + encoding.locale_decode(error)) + + # TODO: replace with data dir? try: path.get_or_create_dir(self.config['local']['playlists_dir']) except EnvironmentError as error: logger.warning( 'Could not create local playlists dir: %s', encoding.locale_decode(error)) - - try: - path.get_or_create_file(self.config['local']['tag_cache_file']) - except EnvironmentError as error: - logger.warning( - 'Could not create empty tag cache file: %s', - encoding.locale_decode(error)) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py new file mode 100644 index 00000000..85939b43 --- /dev/null +++ b/mopidy/local/commands.py @@ -0,0 +1,157 @@ +from __future__ import unicode_literals + +import logging +import os +import time + +from mopidy import commands, exceptions +from mopidy.audio import scan +from mopidy.utils import path + +from . import translator + +logger = logging.getLogger(__name__) + + +def _get_library(args, config): + libraries = dict((l.name, l) for l in args.registry['local:library']) + library_name = config['local']['library'] + + if library_name not in libraries: + logger.warning('Local library %s not found', library_name) + return 1 + + logger.debug('Using %s as the local library', library_name) + return libraries[library_name](config) + + +class LocalCommand(commands.Command): + def __init__(self): + super(LocalCommand, self).__init__() + self.add_child('scan', ScanCommand()) + self.add_child('clear', ClearCommand()) + + +class ClearCommand(commands.Command): + help = 'Clear local media files from the local library.' + + def run(self, args, config): + library = _get_library(args, config) + prompt = 'Are you sure you want to clear the library? [y/N] ' + + if raw_input(prompt).lower() != 'y': + logging.info('Clearing library aborted.') + return 0 + + if library.clear(): + logging.info('Library succesfully cleared.') + return 0 + + logging.warning('Unable to clear library.') + return 1 + + +class ScanCommand(commands.Command): + help = 'Scan local media files and populate the local library.' + + def __init__(self): + super(ScanCommand, self).__init__() + self.add_argument('--limit', + action='store', type=int, dest='limit', default=None, + help='Maxmimum number of tracks to scan') + + def run(self, args, config): + media_dir = config['local']['media_dir'] + scan_timeout = config['local']['scan_timeout'] + flush_threshold = config['local']['scan_flush_threshold'] + excluded_file_extensions = config['local']['excluded_file_extensions'] + excluded_file_extensions = set( + file_ext.lower() for file_ext in excluded_file_extensions) + + library = _get_library(args, config) + + uri_path_mapping = {} + uris_in_library = set() + uris_to_update = set() + uris_to_remove = set() + + num_tracks = library.load() + logger.info('Checking %d tracks from library.', num_tracks) + + for track in library.begin(): + uri_path_mapping[track.uri] = translator.local_track_uri_to_path( + track.uri, media_dir) + try: + stat = os.stat(uri_path_mapping[track.uri]) + if int(stat.st_mtime) > track.last_modified: + uris_to_update.add(track.uri) + uris_in_library.add(track.uri) + except OSError: + logger.debug('Missing file %s', track.uri) + uris_to_remove.add(track.uri) + + logger.info('Removing %d missing tracks.', len(uris_to_remove)) + for uri in uris_to_remove: + library.remove(uri) + + logger.info('Checking %s for unknown tracks.', media_dir) + for relpath in path.find_files(media_dir): + uri = translator.path_to_local_track_uri(relpath) + file_extension = os.path.splitext(relpath)[1] + + if file_extension.lower() in excluded_file_extensions: + logger.debug('Skipped %s: File extension excluded.', uri) + continue + + if uri not in uris_in_library: + uris_to_update.add(uri) + uri_path_mapping[uri] = os.path.join(media_dir, relpath) + + logger.info('Found %d unknown tracks.', len(uris_to_update)) + logger.info('Scanning...') + + uris_to_update = sorted(uris_to_update)[:args.limit] + + scanner = scan.Scanner(scan_timeout) + progress = _Progress(flush_threshold, len(uris_to_update)) + + for uri in uris_to_update: + try: + data = scanner.scan(path.path_to_uri(uri_path_mapping[uri])) + track = scan.audio_data_to_track(data).copy(uri=uri) + library.add(track) + logger.debug('Added %s', track.uri) + except exceptions.ScannerError as error: + logger.warning('Failed %s: %s', uri, error) + + if progress.increment(): + progress.log() + if library.flush(): + logger.debug('Progress flushed.') + + progress.log() + library.close() + logger.info('Done scanning.') + return 0 + + +class _Progress(object): + def __init__(self, batch_size, total): + self.count = 0 + self.batch_size = batch_size + self.total = total + self.start = time.time() + + def increment(self): + self.count += 1 + return self.batch_size and self.count % self.batch_size == 0 + + def log(self): + duration = time.time() - self.start + if self.count >= self.total or not self.count: + logger.info('Scanned %d of %d files in %ds.', + self.count, self.total, duration) + else: + remainder = duration / self.count * (self.total - self.count) + logger.info('Scanned %d of %d files in %ds, ~%ds left.', + self.count, self.total, duration, remainder) diff --git a/mopidy/backends/local/ext.conf b/mopidy/local/ext.conf similarity index 71% rename from mopidy/backends/local/ext.conf rename to mopidy/local/ext.conf index afc13c7d..8f1e860c 100644 --- a/mopidy/backends/local/ext.conf +++ b/mopidy/local/ext.conf @@ -1,9 +1,11 @@ [local] enabled = true +library = json media_dir = $XDG_MUSIC_DIR +data_dir = $XDG_DATA_DIR/mopidy/local playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists -tag_cache_file = $XDG_DATA_DIR/mopidy/local/tag_cache scan_timeout = 1000 +scan_flush_threshold = 1000 excluded_file_extensions = .html .jpeg diff --git a/mopidy/local/json.py b/mopidy/local/json.py new file mode 100644 index 00000000..10611f6f --- /dev/null +++ b/mopidy/local/json.py @@ -0,0 +1,169 @@ +from __future__ import absolute_import, unicode_literals + +import collections +import gzip +import json +import logging +import os +import re +import sys +import tempfile +import time + +import mopidy +from mopidy import local, models +from mopidy.local import search, translator + +logger = logging.getLogger(__name__) + + +# TODO: move to load and dump in models? +def load_library(json_file): + try: + with gzip.open(json_file, 'rb') as fp: + return json.load(fp, object_hook=models.model_json_decoder) + except (IOError, ValueError) as e: + logger.warning('Loading JSON local library failed: %s', e) + return {} + + +def write_library(json_file, data): + data['version'] = mopidy.__version__ + directory, basename = os.path.split(json_file) + + # TODO: cleanup directory/basename.* files. + tmp = tempfile.NamedTemporaryFile( + prefix=basename + '.', dir=directory, delete=False) + + try: + with gzip.GzipFile(fileobj=tmp, mode='wb') as fp: + json.dump(data, fp, cls=models.ModelJSONEncoder, + indent=2, separators=(',', ': ')) + os.rename(tmp.name, json_file) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) + + +class _BrowseCache(object): + encoding = sys.getfilesystemencoding() + splitpath_re = re.compile(r'([^/]+)') + + def __init__(self, uris): + # TODO: local.ROOT_DIRECTORY_URI + self._cache = {'local:directory': collections.OrderedDict()} + + for track_uri in uris: + path = translator.local_track_uri_to_path(track_uri, b'/') + parts = self.splitpath_re.findall( + path.decode(self.encoding, 'replace')) + track_ref = models.Ref.track(uri=track_uri, name=parts.pop()) + + # Look for our parents backwards as this is faster than having to + # do a complete search for each add. + parent_uri = None + child = None + for i in reversed(range(len(parts))): + directory = '/'.join(parts[:i+1]) + uri = translator.path_to_local_directory_uri(directory) + + # First dir we process is our parent + if not parent_uri: + parent_uri = uri + + # We found ourselves and we exist, done. + if uri in self._cache: + if child: + self._cache[uri][child.uri] = child + break + + # Initialize ourselves, store child if present, and add + # ourselves as child for next loop. + self._cache[uri] = collections.OrderedDict() + if child: + self._cache[uri][child.uri] = child + child = models.Ref.directory(uri=uri, name=parts[i]) + else: + # Loop completed, so final child needs to be added to root. + if child: + self._cache['local:directory'][child.uri] = child + # If no parent was set we belong in the root. + if not parent_uri: + parent_uri = 'local:directory' + + self._cache[parent_uri][track_uri] = track_ref + + def lookup(self, uri): + return self._cache.get(uri, {}).values() + + +# TODO: make this available to other code? +class DebugTimer(object): + def __init__(self, msg): + self.msg = msg + self.start = None + + def __enter__(self): + self.start = time.time() + + def __exit__(self, exc_type, exc_value, traceback): + duration = (time.time() - self.start) * 1000 + logger.debug('%s: %dms', self.msg, duration) + + +class JsonLibrary(local.Library): + name = 'json' + + def __init__(self, config): + self._tracks = {} + self._browse_cache = None + self._media_dir = config['local']['media_dir'] + self._json_file = os.path.join( + config['local']['data_dir'], b'library.json.gz') + + def browse(self, uri): + if not self._browse_cache: + return [] + return self._browse_cache.lookup(uri) + + def load(self): + logger.debug('Loading library: %s', self._json_file) + with DebugTimer('Loading tracks'): + library = load_library(self._json_file) + self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) + with DebugTimer('Building browse cache'): + self._browse_cache = _BrowseCache(sorted(self._tracks.keys())) + return len(self._tracks) + + def lookup(self, uri): + try: + return self._tracks[uri] + except KeyError: + return None + + def search(self, query=None, limit=100, offset=0, uris=None, exact=False): + tracks = self._tracks.values() + # TODO: pass limit and offset into search helpers + if exact: + return search.find_exact(tracks, query=query, uris=uris) + else: + return search.search(tracks, query=query, uris=uris) + + def begin(self): + return self._tracks.itervalues() + + def add(self, track): + self._tracks[track.uri] = track + + def remove(self, uri): + self._tracks.pop(uri, None) + + def close(self): + write_library(self._json_file, {'tracks': self._tracks.values()}) + + def clear(self): + try: + os.remove(self._json_file) + return True + except OSError: + return False diff --git a/mopidy/local/library.py b/mopidy/local/library.py new file mode 100644 index 00000000..a626f566 --- /dev/null +++ b/mopidy/local/library.py @@ -0,0 +1,50 @@ +from __future__ import unicode_literals + +import logging + +from mopidy import backend, models + +logger = logging.getLogger(__name__) + + +class LocalLibraryProvider(backend.LibraryProvider): + """Proxy library that delegates work to our active local library.""" + + root_directory = models.Ref.directory(uri=b'local:directory', + name='Local media') + + def __init__(self, backend, library): + super(LocalLibraryProvider, self).__init__(backend) + self._library = library + self.refresh() + + def browse(self, path): + if not self._library: + return [] + return self._library.browse(path) + + def refresh(self, uri=None): + if not self._library: + return 0 + num_tracks = self._library.load() + logger.info('Loaded %d local tracks using %s', + num_tracks, self._library.name) + + def lookup(self, uri): + if not self._library: + return [] + track = self._library.lookup(uri) + if track is None: + logger.debug('Failed to lookup %r', uri) + return [] + return [track] + + def find_exact(self, query=None, uris=None): + if not self._library: + return None + return self._library.search(query=query, uris=uris, exact=True) + + def search(self, query=None, uris=None): + if not self._library: + return None + return self._library.search(query=query, uris=uris, exact=False) diff --git a/mopidy/local/playback.py b/mopidy/local/playback.py new file mode 100644 index 00000000..bd798589 --- /dev/null +++ b/mopidy/local/playback.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals + +import logging + +from mopidy import backend + +from . import translator + +logger = logging.getLogger(__name__) + + +class LocalPlaybackProvider(backend.PlaybackProvider): + def change_track(self, track): + track = track.copy(uri=translator.local_track_uri_to_file_uri( + track.uri, self.backend.config['local']['media_dir'])) + return super(LocalPlaybackProvider, self).change_track(track) diff --git a/mopidy/backends/local/playlists.py b/mopidy/local/playlists.py similarity index 80% rename from mopidy/backends/local/playlists.py rename to mopidy/local/playlists.py index 081bc335..f22c6fde 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/local/playlists.py @@ -5,17 +5,17 @@ import logging import os import shutil -from mopidy.backends import base, listener -from mopidy.models import Playlist, Track +from mopidy import backend +from mopidy.models import Playlist from mopidy.utils import formatting, path from .translator import parse_m3u -logger = logging.getLogger('mopidy.backends.local') +logger = logging.getLogger(__name__) -class LocalPlaylistsProvider(base.BasePlaylistsProvider): +class LocalPlaylistsProvider(backend.PlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalPlaylistsProvider, self).__init__(*args, **kwargs) self._media_dir = self.backend.config['local']['media_dir'] @@ -50,19 +50,15 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): uri = 'local:playlist:%s' % name tracks = [] - for track_uri in parse_m3u(m3u, self._media_dir): - result = self.backend.library.lookup(track_uri) - if result: - tracks += self.backend.library.lookup(track_uri) - else: - tracks.append(Track(uri=track_uri)) + for track in parse_m3u(m3u, self._media_dir): + tracks.append(track) playlist = Playlist(uri=uri, name=name, tracks=tracks) playlists.append(playlist) self.playlists = playlists # TODO: send what scheme we loaded them for? - listener.BackendListener.send('playlists_loaded') + backend.BackendListener.send('playlists_loaded') logger.info( 'Loaded %d local playlists from %s', @@ -94,10 +90,20 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) return file_path + def _write_m3u_extinf(self, file_handle, track): + title = track.name.encode('latin-1', 'replace') + runtime = track.length / 1000 if track.length else -1 + file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n') + def _save_m3u(self, playlist): file_path = self._m3u_uri_to_path(playlist.uri) + extended = any(track.name for track in playlist.tracks) with open(file_path, 'w') as file_handle: + if extended: + file_handle.write('#EXTM3U\n') for track in playlist.tracks: + if extended and track.name: + self._write_m3u_extinf(file_handle, track) file_handle.write(track.uri + '\n') def _delete_m3u(self, uri): diff --git a/mopidy/local/search.py b/mopidy/local/search.py new file mode 100644 index 00000000..68d0a1f5 --- /dev/null +++ b/mopidy/local/search.py @@ -0,0 +1,180 @@ +from __future__ import unicode_literals + +from mopidy.models import Album, SearchResult + + +def find_exact(tracks, query=None, uris=None): + # TODO Only return results within URI roots given by ``uris`` + + if query is None: + query = {} + + _validate_query(query) + + for (field, values) in query.iteritems(): + if not hasattr(values, '__iter__'): + values = [values] + # FIXME this is bound to be slow for large libraries + for value in values: + if field == 'track_no': + q = _convert_to_int(value) + else: + q = value.strip() + + uri_filter = lambda t: q == t.uri + track_name_filter = lambda t: q == t.name + album_filter = lambda t: q == getattr(t, 'album', Album()).name + artist_filter = lambda t: filter( + lambda a: q == a.name, t.artists) + albumartist_filter = lambda t: any([ + q == a.name + for a in getattr(t.album, 'artists', [])]) + composer_filter = lambda t: any([ + q == a.name + for a in getattr(t, 'composers', [])]) + performer_filter = lambda t: any([ + q == a.name + for a in getattr(t, 'performers', [])]) + track_no_filter = lambda t: q == t.track_no + genre_filter = lambda t: t.genre and q == t.genre + date_filter = lambda t: q == t.date + comment_filter = lambda t: q == t.comment + any_filter = lambda t: ( + uri_filter(t) or + track_name_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + composer_filter(t) or + performer_filter(t) or + track_no_filter(t) or + genre_filter(t) or + date_filter(t) or + comment_filter(t)) + + if field == 'uri': + tracks = filter(uri_filter, tracks) + elif field == 'track_name': + tracks = filter(track_name_filter, tracks) + elif field == 'album': + tracks = filter(album_filter, tracks) + elif field == 'artist': + tracks = filter(artist_filter, tracks) + elif field == 'albumartist': + tracks = filter(albumartist_filter, tracks) + elif field == 'composer': + tracks = filter(composer_filter, tracks) + elif field == 'performer': + tracks = filter(performer_filter, tracks) + elif field == 'track_no': + tracks = filter(track_no_filter, tracks) + elif field == 'genre': + tracks = filter(genre_filter, tracks) + elif field == 'date': + tracks = filter(date_filter, tracks) + elif field == 'comment': + tracks = filter(comment_filter, tracks) + elif field == 'any': + tracks = filter(any_filter, tracks) + else: + raise LookupError('Invalid lookup field: %s' % field) + + # TODO: add local:search: + return SearchResult(uri='local:search', tracks=tracks) + + +def search(tracks, query=None, uris=None): + # TODO Only return results within URI roots given by ``uris`` + + if query is None: + query = {} + + _validate_query(query) + + for (field, values) in query.iteritems(): + if not hasattr(values, '__iter__'): + values = [values] + # FIXME this is bound to be slow for large libraries + for value in values: + if field == 'track_no': + q = _convert_to_int(value) + else: + q = value.strip().lower() + + uri_filter = lambda t: bool(t.uri and q in t.uri.lower()) + track_name_filter = lambda t: bool(t.name and q in t.name.lower()) + album_filter = lambda t: bool( + t.album and t.album.name and q in t.album.name.lower()) + artist_filter = lambda t: bool(filter( + lambda a: bool(a.name and q in a.name.lower()), t.artists)) + albumartist_filter = lambda t: any([ + a.name and q in a.name.lower() + for a in getattr(t.album, 'artists', [])]) + composer_filter = lambda t: any([ + a.name and q in a.name.lower() + for a in getattr(t, 'composers', [])]) + performer_filter = lambda t: any([ + a.name and q in a.name.lower() + for a in getattr(t, 'performers', [])]) + track_no_filter = lambda t: q == t.track_no + genre_filter = lambda t: bool(t.genre and q in t.genre.lower()) + date_filter = lambda t: bool(t.date and t.date.startswith(q)) + comment_filter = lambda t: bool( + t.comment and q in t.comment.lower()) + any_filter = lambda t: ( + uri_filter(t) or + track_name_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + composer_filter(t) or + performer_filter(t) or + track_no_filter(t) or + genre_filter(t) or + date_filter(t) or + comment_filter(t)) + + if field == 'uri': + tracks = filter(uri_filter, tracks) + elif field == 'track_name': + tracks = filter(track_name_filter, tracks) + elif field == 'album': + tracks = filter(album_filter, tracks) + elif field == 'artist': + tracks = filter(artist_filter, tracks) + elif field == 'albumartist': + tracks = filter(albumartist_filter, tracks) + elif field == 'composer': + tracks = filter(composer_filter, tracks) + elif field == 'performer': + tracks = filter(performer_filter, tracks) + elif field == 'track_no': + tracks = filter(track_no_filter, tracks) + elif field == 'genre': + tracks = filter(genre_filter, tracks) + elif field == 'date': + tracks = filter(date_filter, tracks) + elif field == 'comment': + tracks = filter(comment_filter, tracks) + elif field == 'any': + tracks = filter(any_filter, tracks) + else: + raise LookupError('Invalid lookup field: %s' % field) + # TODO: add local:search: + return SearchResult(uri='local:search', tracks=tracks) + + +def _validate_query(query): + for (_, values) in query.iteritems(): + if not values: + raise LookupError('Missing query') + for value in values: + if not value: + raise LookupError('Missing query') + + +def _convert_to_int(string): + try: + return int(string) + except ValueError: + return object() diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py new file mode 100644 index 00000000..c3f9874b --- /dev/null +++ b/mopidy/local/translator.py @@ -0,0 +1,119 @@ +from __future__ import unicode_literals + +import logging +import os +import re +import urlparse +import urllib + +from mopidy.models import Track +from mopidy.utils.encoding import locale_decode +from mopidy.utils.path import path_to_uri, uri_to_path + +M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') + +logger = logging.getLogger(__name__) + + +def local_track_uri_to_file_uri(uri, media_dir): + return path_to_uri(local_track_uri_to_path(uri, media_dir)) + + +def local_track_uri_to_path(uri, media_dir): + if not uri.startswith('local:track:'): + raise ValueError('Invalid URI.') + file_path = uri_to_path(uri).split(b':', 1)[1] + return os.path.join(media_dir, file_path) + + +def path_to_local_track_uri(relpath): + """Convert path releative to media_dir to local track URI.""" + if isinstance(relpath, unicode): + relpath = relpath.encode('utf-8') + return b'local:track:%s' % urllib.quote(relpath) + + +def path_to_local_directory_uri(relpath): + """Convert path relative to :confval:`local/media_dir` directory URI.""" + if isinstance(relpath, unicode): + relpath = relpath.encode('utf-8') + return b'local:directory:%s' % urllib.quote(relpath) + + +def m3u_extinf_to_track(line): + """Convert extended M3U directive to track template.""" + m = M3U_EXTINF_RE.match(line) + if not m: + logger.warning('Invalid extended M3U directive: %s', line) + return Track() + (runtime, title) = m.groups() + if int(runtime) > 0: + return Track(name=title, length=1000*int(runtime)) + else: + return Track(name=title) + + +def parse_m3u(file_path, media_dir): + r""" + Convert M3U file list to list of tracks + + Example M3U data:: + + # This is a comment + Alternative\Band - Song.mp3 + Classical\Other Band - New Song.mp3 + Stuff.mp3 + D:\More Music\Foo.mp3 + http://www.example.com:8000/Listen.pls + http://www.example.com/~user/Mine.mp3 + + Example extended M3U data:: + + #EXTM3U + #EXTINF:123, Sample artist - Sample title + Sample.mp3 + #EXTINF:321,Example Artist - Example title + Greatest Hits\Example.ogg + #EXTINF:-1,Radio XMP + http://mp3stream.example.com:8000/ + + - Relative paths of songs should be with respect to location of M3U. + - Paths are normally platform specific. + - Lines starting with # are ignored, except for extended M3U directives. + - Track.name and Track.length are set from extended M3U directives. + - m3u files are latin-1. + """ + # TODO: uris as bytes + tracks = [] + try: + with open(file_path) as m3u: + contents = m3u.readlines() + except IOError as error: + logger.warning('Couldn\'t open m3u: %s', locale_decode(error)) + return tracks + + if not contents: + return tracks + + extended = contents[0].decode('latin1').startswith('#EXTM3U') + + track = Track() + for line in contents: + line = line.strip().decode('latin1') + + if line.startswith('#'): + if extended and line.startswith('#EXTINF'): + track = m3u_extinf_to_track(line) + continue + + if urlparse.urlsplit(line).scheme: + tracks.append(track.copy(uri=line)) + elif os.path.normpath(line) == os.path.abspath(line): + path = path_to_uri(line) + tracks.append(track.copy(uri=path)) + else: + path = path_to_uri(os.path.join(media_dir, line)) + tracks.append(track.copy(uri=path)) + + track = Track() + return tracks diff --git a/mopidy/models.py b/mopidy/models.py index 04d71591..e1a1270f 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -69,10 +69,14 @@ class ImmutableObject(object): data = {} for key in self.__dict__.keys(): public_key = key.lstrip('_') - data[public_key] = values.pop(public_key, self.__dict__[key]) + value = values.pop(public_key, self.__dict__[key]) + if value is not None: + data[public_key] = value for key in values.keys(): if hasattr(self, key): - data[key] = values.pop(key) + value = values.pop(key) + if value is not None: + data[key] = value if values: raise TypeError( 'copy() got an unexpected keyword argument "%s"' % key) @@ -136,6 +140,76 @@ def model_json_decoder(dct): return dct +class Ref(ImmutableObject): + """ + Model to represent URI references with a human friendly name and type + attached. This is intended for use a lightweight object "free" of metadata + that can be passed around instead of using full blown models. + + :param uri: object URI + :type uri: string + :param name: object name + :type name: string + :param type: object type + :type name: string + """ + + #: The object URI. Read-only. + uri = None + + #: The object name. Read-only. + name = None + + #: The object type, e.g. "artist", "album", "track", "playlist", + #: "directory". Read-only. + type = None + + #: Constant used for comparison with the :attr:`type` field. + ALBUM = 'album' + + #: Constant used for comparison with the :attr:`type` field. + ARTIST = 'artist' + + #: Constant used for comparison with the :attr:`type` field. + DIRECTORY = 'directory' + + #: Constant used for comparison with the :attr:`type` field. + PLAYLIST = 'playlist' + + #: Constant used for comparison with the :attr:`type` field. + TRACK = 'track' + + @classmethod + def album(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`ALBUM`.""" + kwargs['type'] = Ref.ALBUM + return cls(**kwargs) + + @classmethod + def artist(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`ARTIST`.""" + kwargs['type'] = Ref.ARTIST + return cls(**kwargs) + + @classmethod + def directory(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`DIRECTORY`.""" + kwargs['type'] = Ref.DIRECTORY + return cls(**kwargs) + + @classmethod + def playlist(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`PLAYLIST`.""" + kwargs['type'] = Ref.PLAYLIST + return cls(**kwargs) + + @classmethod + def track(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`TRACK`.""" + kwargs['type'] = Ref.TRACK + return cls(**kwargs) + + class Artist(ImmutableObject): """ :param uri: artist URI @@ -204,8 +278,8 @@ class Album(ImmutableObject): # actual usage of this field with more than one image. def __init__(self, *args, **kwargs): - self.__dict__['artists'] = frozenset(kwargs.pop('artists', [])) - self.__dict__['images'] = frozenset(kwargs.pop('images', [])) + self.__dict__['artists'] = frozenset(kwargs.pop('artists', None) or []) + self.__dict__['images'] = frozenset(kwargs.pop('images', None) or []) super(Album, self).__init__(*args, **kwargs) @@ -291,9 +365,10 @@ class Track(ImmutableObject): last_modified = 0 def __init__(self, *args, **kwargs): - self.__dict__['artists'] = frozenset(kwargs.pop('artists', [])) - self.__dict__['composers'] = frozenset(kwargs.pop('composers', [])) - self.__dict__['performers'] = frozenset(kwargs.pop('performers', [])) + get = lambda key: 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) @@ -362,7 +437,7 @@ class Playlist(ImmutableObject): last_modified = None def __init__(self, *args, **kwargs): - self.__dict__['tracks'] = tuple(kwargs.pop('tracks', [])) + self.__dict__['tracks'] = tuple(kwargs.pop('tracks', None) or []) super(Playlist, self).__init__(*args, **kwargs) # TODO: def insert(self, pos, track): ... ? @@ -398,7 +473,7 @@ class SearchResult(ImmutableObject): albums = tuple() def __init__(self, *args, **kwargs): - self.__dict__['tracks'] = tuple(kwargs.pop('tracks', [])) - self.__dict__['artists'] = tuple(kwargs.pop('artists', [])) - self.__dict__['albums'] = tuple(kwargs.pop('albums', [])) + 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) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/mpd/__init__.py similarity index 92% rename from mopidy/frontends/mpd/__init__.py rename to mopidy/mpd/__init__.py index 571d6455..77aaf83f 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/mpd/__init__.py @@ -29,6 +29,6 @@ class Extension(ext.Extension): def validate_environment(self): pass - def get_frontend_classes(self): + def setup(self, registry): from .actor import MpdFrontend - return [MpdFrontend] + registry.add('frontend', MpdFrontend) diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/mpd/actor.py similarity index 91% rename from mopidy/frontends/mpd/actor.py rename to mopidy/mpd/actor.py index 9df7ba07..20417a4d 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -5,11 +5,12 @@ import sys import pykka +from mopidy import zeroconf from mopidy.core import CoreListener -from mopidy.frontends.mpd import session -from mopidy.utils import encoding, network, process, zeroconf +from mopidy.mpd import session +from mopidy.utils import encoding, network, process -logger = logging.getLogger('mopidy.frontends.mpd') +logger = logging.getLogger(__name__) class MpdFrontend(pykka.ThreadingActor, CoreListener): @@ -50,7 +51,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): logger.info('Registered MPD with Zeroconf as "%s"', self.zeroconf_service.name) else: - logger.warning('Registering MPD with Zeroconf failed.') + logger.info('Registering MPD with Zeroconf failed.') def on_stop(self): if self.zeroconf_service: diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py similarity index 90% rename from mopidy/frontends/mpd/dispatcher.py rename to mopidy/mpd/dispatcher.py index ec3b71f8..6aeace9d 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -5,9 +5,9 @@ import re import pykka -from mopidy.frontends.mpd import exceptions, protocol +from mopidy.mpd import exceptions, protocol -logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') +logger = logging.getLogger(__name__) protocol.load_protocol_modules() @@ -165,7 +165,12 @@ class MpdDispatcher(object): def _call_handler(self, request): (handler, kwargs) = self._find_handler(request) - return handler(self.context, **kwargs) + try: + return handler(self.context, **kwargs) + except exceptions.MpdAckError as exc: + if exc.command is None: + exc.command = handler.__name__.split('__', 1)[0] + raise def _find_handler(self, request): for pattern in protocol.request_handlers: @@ -221,7 +226,7 @@ class MpdContext(object): #: The current :class:`MpdDispatcher`. dispatcher = None - #: The current :class:`mopidy.frontends.mpd.MpdSession`. + #: The current :class:`mopidy.mpd.MpdSession`. session = None #: The Mopidy configuration. @@ -292,3 +297,19 @@ class MpdContext(object): if uri not in self._playlist_name_from_uri: self.refresh_playlists_mapping() return self._playlist_name_from_uri[uri] + + # TODO: consider making context.browse(path) which uses this internally. + # advantage would be that all browse requests then go through the same code + # and we could prebuild/cache path->uri relationships instead of having to + # look them up all the time. + def directory_path_to_uri(self, path): + parts = re.findall(r'[^/]+', path) + uri = None + for part in parts: + for ref in self.core.library.browse(uri).get(): + if ref.type == ref.DIRECTORY and ref.name == part: + uri = ref.uri + break + else: + raise exceptions.MpdNoExistError() + return uri diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/mpd/exceptions.py similarity index 82% rename from mopidy/frontends/mpd/exceptions.py rename to mopidy/mpd/exceptions.py index db3212d8..ec874553 100644 --- a/mopidy/frontends/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -21,7 +21,7 @@ class MpdAckError(MopidyException): error_code = 0 - def __init__(self, message='', index=0, command=''): + def __init__(self, message='', index=0, command=None): super(MpdAckError, self).__init__(message, index, command) self.message = message self.index = index @@ -50,6 +50,7 @@ class MpdPermissionError(MpdAckError): def __init__(self, *args, **kwargs): super(MpdPermissionError, self).__init__(*args, **kwargs) + assert self.command is not None, 'command must be given explicitly' self.message = 'you don\'t have permission for "%s"' % self.command @@ -58,10 +59,18 @@ class MpdUnknownCommand(MpdAckError): def __init__(self, *args, **kwargs): super(MpdUnknownCommand, self).__init__(*args, **kwargs) + assert self.command is not None, 'command must be given explicitly' self.message = 'unknown command "%s"' % self.command self.command = '' +class MpdNoCommand(MpdUnknownCommand): + def __init__(self, *args, **kwargs): + kwargs['command'] = '' + super(MpdNoCommand, self).__init__(*args, **kwargs) + self.message = 'No command given' + + class MpdNoExistError(MpdAckError): error_code = MpdAckError.ACK_ERROR_NO_EXIST diff --git a/mopidy/frontends/mpd/ext.conf b/mopidy/mpd/ext.conf similarity index 100% rename from mopidy/frontends/mpd/ext.conf rename to mopidy/mpd/ext.conf diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py similarity index 100% rename from mopidy/frontends/mpd/protocol/__init__.py rename to mopidy/mpd/protocol/__init__.py diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py similarity index 78% rename from mopidy/frontends/mpd/protocol/audio_output.py rename to mopidy/mpd/protocol/audio_output.py index ee1782bd..802be6c0 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.exceptions import MpdNoExistError -from mopidy.frontends.mpd.protocol import handle_request +from mopidy.mpd.exceptions import MpdNoExistError +from mopidy.mpd.protocol import handle_request @handle_request(r'disableoutput\ "(?P\d+)"$') @@ -16,7 +16,7 @@ def disableoutput(context, outputid): if int(outputid) == 0: context.core.playback.set_mute(False) else: - raise MpdNoExistError('No such audio output', command='disableoutput') + raise MpdNoExistError('No such audio output') @handle_request(r'enableoutput\ "(?P\d+)"$') @@ -31,7 +31,7 @@ def enableoutput(context, outputid): if int(outputid) == 0: context.core.playback.set_mute(True) else: - raise MpdNoExistError('No such audio output', command='enableoutput') + raise MpdNoExistError('No such audio output') @handle_request(r'outputs$') diff --git a/mopidy/frontends/mpd/protocol/channels.py b/mopidy/mpd/protocol/channels.py similarity index 93% rename from mopidy/frontends/mpd/protocol/channels.py rename to mopidy/mpd/protocol/channels.py index 1f54a41b..e8efd2a0 100644 --- a/mopidy/frontends/mpd/protocol/channels.py +++ b/mopidy/mpd/protocol/channels.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import MpdNotImplemented +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.exceptions import MpdNotImplemented @handle_request(r'subscribe\ "(?P[A-Za-z0-9:._-]+)"$') diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/mpd/protocol/command_list.py similarity index 95% rename from mopidy/frontends/mpd/protocol/command_list.py rename to mopidy/mpd/protocol/command_list.py index c85a594b..8268c55d 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/mpd/protocol/command_list.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import MpdUnknownCommand +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.exceptions import MpdUnknownCommand @handle_request(r'command_list_begin$') diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/mpd/protocol/connection.py similarity index 83% rename from mopidy/frontends/mpd/protocol/connection.py rename to mopidy/mpd/protocol/connection.py index 734ed37a..a6f9ffcb 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/mpd/protocol/connection.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import ( +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.exceptions import ( MpdPasswordError, MpdPermissionError) @@ -30,7 +30,7 @@ def kill(context): @handle_request(r'password\ "(?P[^"]+)"$', auth_required=False) -def password_(context, password): +def password(context, password): """ *musicpd.org, connection section:* @@ -42,7 +42,7 @@ def password_(context, password): if password == context.config['mpd']['password']: context.dispatcher.authenticated = True else: - raise MpdPasswordError('incorrect password', command='password') + raise MpdPasswordError('incorrect password') @handle_request(r'ping$', auth_required=False) diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py similarity index 89% rename from mopidy/frontends/mpd/protocol/current_playlist.py rename to mopidy/mpd/protocol/current_playlist.py index d5bf267a..de8721d3 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd import translator -from mopidy.frontends.mpd.exceptions import ( +from mopidy.mpd import translator +from mopidy.mpd.exceptions import ( MpdArgError, MpdNoExistError, MpdNotImplemented) -from mopidy.frontends.mpd.protocol import handle_request +from mopidy.mpd.protocol import handle_request @handle_request(r'add\ "(?P[^"]*)"$') @@ -20,11 +20,37 @@ def add(context, uri): - ``add ""`` should add all tracks in the library to the current playlist. """ - if not uri: + if not uri.strip('/'): return + tl_tracks = context.core.tracklist.add(uri=uri).get() - if not tl_tracks: - raise MpdNoExistError('directory or file not found', command='add') + if tl_tracks: + return + + try: + uri = context.directory_path_to_uri(translator.normalize_path(uri)) + except MpdNoExistError as e: + e.command = 'add' + e.message = 'directory or file not found' + raise + + browse_futures = [context.core.library.browse(uri)] + lookup_futures = [] + while browse_futures: + for ref in browse_futures.pop().get(): + if ref.type == ref.DIRECTORY: + browse_futures.append(context.core.library.browse(ref.uri)) + else: + lookup_futures.append(context.core.library.lookup(ref.uri)) + + tracks = [] + for future in lookup_futures: + tracks.extend(future.get()) + + if not tracks: + raise MpdNoExistError('directory or file not found') + + context.core.tracklist.add(tracks=tracks) @handle_request(r'addid\ "(?P[^"]*)"(\ "(?P\d+)")*$') @@ -47,14 +73,14 @@ def addid(context, uri, songpos=None): - ``addid ""`` should return an error. """ if not uri: - raise MpdNoExistError('No such song', command='addid') + raise MpdNoExistError('No such song') if songpos is not None: songpos = int(songpos) if songpos and songpos > context.core.tracklist.length.get(): - raise MpdArgError('Bad song index', command='addid') + raise MpdArgError('Bad song index') tl_tracks = context.core.tracklist.add(uri=uri, at_position=songpos).get() if not tl_tracks: - raise MpdNoExistError('No such song', command='addid') + raise MpdNoExistError('No such song') return ('Id', tl_tracks[0].tlid) @@ -103,7 +129,7 @@ def deleteid(context, tlid): tlid = int(tlid) tl_tracks = context.core.tracklist.remove(tlid=[tlid]).get() if not tl_tracks: - raise MpdNoExistError('No such song', command='deleteid') + raise MpdNoExistError('No such song') @handle_request(r'clear$') @@ -159,7 +185,7 @@ def moveid(context, tlid, to): to = int(to) tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() if not tl_tracks: - raise MpdNoExistError('No such song', command='moveid') + raise MpdNoExistError('No such song') position = context.core.tracklist.index(tl_tracks[0]).get() context.core.tracklist.move(position, position + 1, to) @@ -217,7 +243,7 @@ def playlistid(context, tlid=None): tlid = int(tlid) tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() if not tl_tracks: - raise MpdNoExistError('No such song', command='playlistid') + raise 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: @@ -254,7 +280,7 @@ def playlistinfo(context, songpos=None, start=None, end=None): start = 0 start = int(start) if not (0 <= start <= context.core.tracklist.length.get()): - raise MpdArgError('Bad song index', command='playlistinfo') + raise MpdArgError('Bad song index') if end is not None: end = int(end) if end > context.core.tracklist.length.get(): @@ -381,7 +407,7 @@ def swapid(context, tlid1, tlid2): 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 MpdNoExistError('No such song', command='swapid') + raise MpdNoExistError('No such song') position1 = context.core.tracklist.index(tl_tracks1[0]).get() position2 = context.core.tracklist.index(tl_tracks2[0]).get() swap(context, position1, position2) diff --git a/mopidy/mpd/protocol/empty.py b/mopidy/mpd/protocol/empty.py new file mode 100644 index 00000000..64cfc1fb --- /dev/null +++ b/mopidy/mpd/protocol/empty.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals + +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.exceptions import MpdNoCommand + + +@handle_request(r'[\ ]*$') +def empty(context): + """The original MPD server returns an error on an empty request.""" + raise MpdNoCommand() diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py similarity index 83% rename from mopidy/frontends/mpd/protocol/music_db.py rename to mopidy/mpd/protocol/music_db.py index e1d718c0..58681557 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -4,10 +4,10 @@ import functools import itertools import re -from mopidy.models import Track -from mopidy.frontends.mpd import translator -from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented -from mopidy.frontends.mpd.protocol import handle_request, stored_playlists +from mopidy.models import Ref, Track +from mopidy.mpd import translator +from mopidy.mpd.exceptions import MpdArgError, MpdNoExistError +from mopidy.mpd.protocol import handle_request, stored_playlists LIST_QUERY = r""" @@ -163,7 +163,7 @@ def count(context, mpd_query): try: query = _query_from_mpd_search_format(mpd_query) except ValueError: - raise MpdArgError('incorrect arguments', command='count') + raise MpdArgError('incorrect arguments') results = context.core.library.find_exact(**query).get() result_tracks = _get_tracks(results) return [ @@ -417,7 +417,33 @@ def listall(context, uri=None): Lists all songs and directories in ``URI``. """ - raise MpdNotImplemented # TODO + result = [] + root_path = translator.normalize_path(uri) + # TODO: doesn't the dispatcher._call_handler have enough info to catch + # the error this can produce, set the command and then 'raise'? + try: + uri = context.directory_path_to_uri(root_path) + except MpdNoExistError as e: + e.command = 'listall' + e.message = 'Not found' + raise + browse_futures = [(root_path, context.core.library.browse(uri))] + + while browse_futures: + base_path, future = browse_futures.pop() + for ref in future.get(): + if ref.type == Ref.DIRECTORY: + path = '/'.join([base_path, ref.name.replace('/', '')]) + result.append(('directory', path)) + browse_futures.append( + (path, context.core.library.browse(ref.uri))) + elif ref.type == Ref.TRACK: + result.append(('file', ref.uri)) + + if not result: + raise MpdNoExistError('Not found') + + return [('directory', root_path)] + result @handle_request(r'listallinfo$') @@ -431,7 +457,41 @@ def listallinfo(context, uri=None): Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. """ - raise MpdNotImplemented # TODO + dirs_and_futures = [] + result = [] + root_path = translator.normalize_path(uri) + try: + uri = context.directory_path_to_uri(root_path) + except MpdNoExistError as e: + e.command = 'listallinfo' + e.message = 'Not found' + raise + browse_futures = [(root_path, context.core.library.browse(uri))] + + while browse_futures: + base_path, future = browse_futures.pop() + for ref in future.get(): + if ref.type == Ref.DIRECTORY: + path = '/'.join([base_path, ref.name.replace('/', '')]) + future = context.core.library.browse(ref.uri) + browse_futures.append((path, future)) + dirs_and_futures.append(('directory', path)) + elif ref.type == Ref.TRACK: + # TODO Lookup tracks in batch for better performance + dirs_and_futures.append(context.core.library.lookup(ref.uri)) + + result = [] + for obj in dirs_and_futures: + if hasattr(obj, 'get'): + for track in obj.get(): + result.extend(translator.track_to_mpd_format(track)) + else: + result.append(obj) + + if not result: + raise MpdNoExistError('Not found') + + return [('directory', root_path)] + result @handle_request(r'lsinfo$') @@ -452,9 +512,28 @@ def lsinfo(context, uri=None): directories located at the root level, for both ``lsinfo``, ``lsinfo ""``, and ``lsinfo "/"``. """ - if uri is None or uri == '/' or uri == '': - return stored_playlists.listplaylists(context) - raise MpdNotImplemented # TODO + result = [] + root_path = translator.normalize_path(uri, relative=True) + try: + uri = context.directory_path_to_uri(root_path) + except MpdNoExistError as e: + e.command = 'lsinfo' + e.message = 'Not found' + raise + + if uri is None: + result.extend(stored_playlists.listplaylists(context)) + + for ref in context.core.library.browse(uri).get(): + if ref.type == Ref.DIRECTORY: + path = '/'.join([root_path, ref.name.replace('/', '')]) + result.append(('directory', path.lstrip('/'))) + elif ref.type == Ref.TRACK: + # TODO Lookup tracks in batch for better performance + tracks = context.core.library.lookup(ref.uri).get() + if tracks: + result.extend(translator.track_to_mpd_format(tracks[0])) + return result @handle_request(r'rescan$') diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py similarity index 97% rename from mopidy/frontends/mpd/protocol/playback.py rename to mopidy/mpd/protocol/playback.py index 27ee6d4b..4f8ae73a 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals from mopidy.core import PlaybackState -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import ( +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.exceptions import ( MpdArgError, MpdNoExistError, MpdNotImplemented) @@ -151,12 +151,12 @@ def playid(context, tlid): return _play_minus_one(context) tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() if not tl_tracks: - raise MpdNoExistError('No such song', command='playid') + raise MpdNoExistError('No such song') return context.core.playback.play(tl_tracks[0]).get() @handle_request(r'play\ ("?)(?P-?\d+)\1$') -def playpos(context, songpos): +def play__pos(context, songpos): """ *musicpd.org, playback section:* @@ -184,7 +184,7 @@ def playpos(context, songpos): tl_track = context.core.tracklist.slice(songpos, songpos + 1).get()[0] return context.core.playback.play(tl_track).get() except IndexError: - raise MpdArgError('Bad song index', command='play') + raise MpdArgError('Bad song index') def _play_minus_one(context): @@ -325,7 +325,7 @@ def seek(context, songpos, seconds): """ tl_track = context.core.playback.current_tl_track.get() if context.core.tracklist.index(tl_track).get() != int(songpos): - playpos(context, songpos) + play__pos(context, songpos) context.core.playback.seek(int(seconds) * 1000).get() diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/mpd/protocol/reflection.py similarity index 95% rename from mopidy/frontends/mpd/protocol/reflection.py rename to mopidy/mpd/protocol/reflection.py index d5206120..79aa1247 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/mpd/protocol/reflection.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.exceptions import MpdPermissionError -from mopidy.frontends.mpd.protocol import handle_request, mpd_commands +from mopidy.mpd.exceptions import MpdPermissionError +from mopidy.mpd.protocol import handle_request, mpd_commands @handle_request(r'config$', auth_required=False) diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py similarity index 98% rename from mopidy/frontends/mpd/protocol/status.py rename to mopidy/mpd/protocol/status.py index 2fe3a402..96bca6d6 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -3,9 +3,9 @@ from __future__ import unicode_literals import pykka from mopidy.core import PlaybackState -from mopidy.frontends.mpd.exceptions import MpdNotImplemented -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.translator import track_to_mpd_format +from mopidy.mpd.exceptions import MpdNotImplemented +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.translator import track_to_mpd_format #: Subsystems that can be registered with idle command. SUBSYSTEMS = [ diff --git a/mopidy/frontends/mpd/protocol/stickers.py b/mopidy/mpd/protocol/stickers.py similarity index 83% rename from mopidy/frontends/mpd/protocol/stickers.py rename to mopidy/mpd/protocol/stickers.py index 84417e51..17798523 100644 --- a/mopidy/frontends/mpd/protocol/stickers.py +++ b/mopidy/mpd/protocol/stickers.py @@ -1,13 +1,13 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import MpdNotImplemented +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.exceptions import MpdNotImplemented @handle_request( r'sticker\ delete\ "(?P[^"]+)"\ ' r'"(?P[^"]+)"(\ "(?P[^"]+)")*$') -def sticker_delete(context, field, uri, name=None): +def sticker__delete(context, field, uri, name=None): """ *musicpd.org, sticker section:* @@ -22,7 +22,7 @@ def sticker_delete(context, field, uri, name=None): @handle_request( r'sticker\ find\ "(?P[^"]+)"\ "(?P[^"]+)"\ ' r'"(?P[^"]+)"$') -def sticker_find(context, field, uri, name): +def sticker__find(context, field, uri, name): """ *musicpd.org, sticker section:* @@ -38,7 +38,7 @@ def sticker_find(context, field, uri, name): @handle_request( r'sticker\ get\ "(?P[^"]+)"\ "(?P[^"]+)"\ ' r'"(?P[^"]+)"$') -def sticker_get(context, field, uri, name): +def sticker__get(context, field, uri, name): """ *musicpd.org, sticker section:* @@ -50,7 +50,7 @@ def sticker_get(context, field, uri, name): @handle_request(r'sticker\ list\ "(?P[^"]+)"\ "(?P[^"]+)"$') -def sticker_list(context, field, uri): +def sticker__list(context, field, uri): """ *musicpd.org, sticker section:* @@ -64,7 +64,7 @@ def sticker_list(context, field, uri): @handle_request( r'sticker\ set\ "(?P[^"]+)"\ "(?P[^"]+)"\ ' r'"(?P[^"]+)"\ "(?P[^"]+)"$') -def sticker_set(context, field, uri, name, value): +def sticker__set(context, field, uri, name, value): """ *musicpd.org, sticker section:* diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py similarity index 93% rename from mopidy/frontends/mpd/protocol/stored_playlists.py rename to mopidy/mpd/protocol/stored_playlists.py index 974dbc7f..a852d795 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -2,9 +2,9 @@ from __future__ import unicode_literals import datetime as dt -from mopidy.frontends.mpd.exceptions import MpdNoExistError, MpdNotImplemented -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.translator import playlist_to_mpd_format +from mopidy.mpd.exceptions import MpdNoExistError, MpdNotImplemented +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.translator import playlist_to_mpd_format @handle_request(r'listplaylist\ ("?)(?P[^"]+)\1$') @@ -24,7 +24,7 @@ def listplaylist(context, name): """ playlist = context.lookup_playlist_from_name(name) if not playlist: - raise MpdNoExistError('No such playlist', command='listplaylist') + raise MpdNoExistError('No such playlist') return ['file: %s' % t.uri for t in playlist.tracks] @@ -44,7 +44,7 @@ def listplaylistinfo(context, name): """ playlist = context.lookup_playlist_from_name(name) if not playlist: - raise MpdNoExistError('No such playlist', command='listplaylistinfo') + raise MpdNoExistError('No such playlist') return playlist_to_mpd_format(playlist) @@ -115,7 +115,7 @@ def load(context, name, start=None, end=None): """ playlist = context.lookup_playlist_from_name(name) if not playlist: - raise MpdNoExistError('No such playlist', command='load') + raise MpdNoExistError('No such playlist') if start is not None: start = int(start) if end is not None: diff --git a/mopidy/frontends/mpd/session.py b/mopidy/mpd/session.py similarity index 93% rename from mopidy/frontends/mpd/session.py rename to mopidy/mpd/session.py index 14173308..2c0bd840 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/mpd/session.py @@ -2,10 +2,10 @@ from __future__ import unicode_literals import logging -from mopidy.frontends.mpd import dispatcher, protocol +from mopidy.mpd import dispatcher, protocol from mopidy.utils import formatting, network -logger = logging.getLogger('mopidy.frontends.mpd') +logger = logging.getLogger(__name__) class MpdSession(network.LineProtocol): diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/mpd/translator.py similarity index 60% rename from mopidy/frontends/mpd/translator.py rename to mopidy/mpd/translator.py index 4f38effa..520e9ac8 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -1,16 +1,20 @@ from __future__ import unicode_literals -import os import re import shlex -import urllib -from mopidy.frontends.mpd import protocol -from mopidy.frontends.mpd.exceptions import MpdArgError +from mopidy.mpd.exceptions import MpdArgError from mopidy.models import TlTrack -from mopidy.utils.path import mtime as get_mtime, uri_to_path, split_path # TODO: special handling of local:// uri scheme +normalize_path_re = re.compile(r'[^/]+') + + +def normalize_path(path, relative=False): + parts = normalize_path_re.findall(path or '') + if not relative: + parts.insert(0, '') + return '/'.join(parts) def track_to_mpd_format(track, position=None): @@ -87,27 +91,6 @@ def track_to_mpd_format(track, position=None): return result -MPD_KEY_ORDER = ''' - key file Time Artist Album AlbumArtist Title Track Genre Date Composer - Performer Comment Disc MUSICBRAINZ_ALBUMID MUSICBRAINZ_ALBUMARTISTID - MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime -'''.split() - - -def order_mpd_track_info(result): - """ - Order results from - :func:`mopidy.frontends.mpd.translator.track_to_mpd_format` so that it - matches MPD's ordering. Simply a cosmetic fix for easier diffing of - tag_caches. - - :param result: the track info - :type result: list of tuples - :rtype: list of tuples - """ - return sorted(result, key=lambda i: MPD_KEY_ORDER.index(i[0])) - - def artists_to_mpd_format(artists): """ Format track artists for output to MPD client. @@ -197,92 +180,3 @@ def query_from_mpd_list_format(field, mpd_query): return query else: raise MpdArgError('not able to parse args', command='list') - - -# TODO: move to tagcache backend. -def tracks_to_tag_cache_format(tracks, media_dir): - """ - Format list of tracks for output to MPD tag cache - - :param tracks: the tracks - :type tracks: list of :class:`mopidy.models.Track` - :param media_dir: the path to the music dir - :type media_dir: string - :rtype: list of lists of two-tuples - """ - result = [ - ('info_begin',), - ('mpd_version', protocol.VERSION), - ('fs_charset', protocol.ENCODING), - ('info_end',) - ] - tracks.sort(key=lambda t: t.uri) - dirs, files = tracks_to_directory_tree(tracks, media_dir) - _add_to_tag_cache(result, dirs, files, media_dir) - return result - - -# TODO: bytes only -def _add_to_tag_cache(result, dirs, files, media_dir): - base_path = media_dir.encode('utf-8') - - for path, (entry_dirs, entry_files) in dirs.items(): - try: - text_path = path.decode('utf-8') - except UnicodeDecodeError: - text_path = urllib.quote(path).decode('utf-8') - name = os.path.split(text_path)[1] - result.append(('directory', text_path)) - result.append(('mtime', get_mtime(os.path.join(base_path, path)))) - result.append(('begin', name)) - _add_to_tag_cache(result, entry_dirs, entry_files, media_dir) - result.append(('end', name)) - - result.append(('songList begin',)) - - for track in files: - track_result = dict(track_to_mpd_format(track)) - - # XXX Don't save comments to the tag cache as they may span multiple - # lines. We'll start saving track comments when we move from tag_cache - # to a JSON file. See #579 for details. - if 'Comment' in track_result: - del track_result['Comment'] - - path = uri_to_path(track_result['file']) - try: - text_path = path.decode('utf-8') - except UnicodeDecodeError: - text_path = urllib.quote(path).decode('utf-8') - relative_path = os.path.relpath(path, base_path) - relative_uri = urllib.quote(relative_path) - - # TODO: use track.last_modified - track_result['file'] = relative_uri - track_result['mtime'] = get_mtime(path) - track_result['key'] = os.path.basename(text_path) - track_result = order_mpd_track_info(track_result.items()) - - result.extend(track_result) - - result.append(('songList end',)) - - -def tracks_to_directory_tree(tracks, media_dir): - directories = ({}, []) - - for track in tracks: - path = b'' - current = directories - - absolute_track_dir_path = os.path.dirname(uri_to_path(track.uri)) - relative_track_dir_path = re.sub( - '^' + re.escape(media_dir), b'', absolute_track_dir_path) - - for part in split_path(relative_track_dir_path): - path = os.path.join(path, part) - if path not in current[0]: - current[0][path] = ({}, []) - current = current[0][path] - current[1].append(track) - return directories diff --git a/mopidy/backends/stream/__init__.py b/mopidy/stream/__init__.py similarity index 78% rename from mopidy/backends/stream/__init__.py rename to mopidy/stream/__init__.py index 061ac5d0..e4c2bad7 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/stream/__init__.py @@ -19,11 +19,13 @@ class Extension(ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['protocols'] = config.List() + schema['timeout'] = config.Integer( + minimum=1000, maximum=1000 * 60 * 60) return schema def validate_environment(self): pass - def get_backend_classes(self): + def setup(self, registry): from .actor import StreamBackend - return [StreamBackend] + registry.add('backend', StreamBackend) diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py new file mode 100644 index 00000000..aecc4e42 --- /dev/null +++ b/mopidy/stream/actor.py @@ -0,0 +1,44 @@ +from __future__ import unicode_literals + +import logging +import urlparse + +import pykka + +from mopidy import audio as audio_lib, backend, exceptions +from mopidy.audio import scan +from mopidy.models import Track + +logger = logging.getLogger(__name__) + + +class StreamBackend(pykka.ThreadingActor, backend.Backend): + def __init__(self, config, audio): + super(StreamBackend, self).__init__() + + self.library = StreamLibraryProvider( + backend=self, timeout=config['stream']['timeout']) + self.playback = backend.PlaybackProvider(audio=audio, backend=self) + self.playlists = None + + self.uri_schemes = audio_lib.supported_uri_schemes( + config['stream']['protocols']) + + +class StreamLibraryProvider(backend.LibraryProvider): + def __init__(self, backend, timeout): + super(StreamLibraryProvider, self).__init__(backend) + self._scanner = scan.Scanner(min_duration=None, timeout=timeout) + + def lookup(self, uri): + if urlparse.urlsplit(uri).scheme not in self.backend.uri_schemes: + return [] + + try: + data = self._scanner.scan(uri) + track = scan.audio_data_to_track(data) + except exceptions.ScannerError as e: + logger.warning('Problem looking up %s: %s', uri, e) + track = Track(uri=uri, name=uri) + + return [track] diff --git a/mopidy/backends/stream/ext.conf b/mopidy/stream/ext.conf similarity index 86% rename from mopidy/backends/stream/ext.conf rename to mopidy/stream/ext.conf index dc0287da..811dec88 100644 --- a/mopidy/backends/stream/ext.conf +++ b/mopidy/stream/ext.conf @@ -8,3 +8,4 @@ protocols = rtmp rtmps rtsp +timeout = 5000 diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 896fd707..9c88b368 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -31,12 +31,13 @@ def bootstrap_delayed_logging(): root.addHandler(_delayed_handler) -def setup_logging(config, verbosity_level, save_debug_log): - setup_console_logging(config, verbosity_level) +def setup_logging(config, extensions, verbosity_level, save_debug_log): setup_log_levels(config) + setup_console_logging(config, extensions, verbosity_level) + if save_debug_log: - setup_debug_logging_to_file(config) + setup_debug_logging_to_file(config, extensions) logging.captureWarnings(True) @@ -51,29 +52,55 @@ def setup_log_levels(config): logging.getLogger(name).setLevel(level) -def setup_console_logging(config, verbosity_level): - if verbosity_level < 0: - log_level = logging.WARNING +LOG_LEVELS = { + -1: dict(root=logging.ERROR, mopidy=logging.WARNING), + 0: dict(root=logging.ERROR, mopidy=logging.INFO), + 1: dict(root=logging.WARNING, mopidy=logging.DEBUG), + 2: dict(root=logging.INFO, mopidy=logging.DEBUG), + 3: dict(root=logging.DEBUG, mopidy=logging.DEBUG), +} + + +def setup_console_logging(config, extensions, verbosity_level): + if verbosity_level < min(LOG_LEVELS.keys()): + verbosity_level = min(LOG_LEVELS.keys()) + if verbosity_level > max(LOG_LEVELS.keys()): + verbosity_level = max(LOG_LEVELS.keys()) + + if verbosity_level < 1: log_format = config['logging']['console_format'] - elif verbosity_level >= 1: - log_level = logging.DEBUG - log_format = config['logging']['debug_format'] else: - log_level = logging.INFO - log_format = config['logging']['console_format'] + log_format = config['logging']['debug_format'] formatter = logging.Formatter(log_format) - handler = logging.StreamHandler() - handler.setFormatter(formatter) - handler.setLevel(log_level) - root = logging.getLogger('') - root.addHandler(handler) + + root_handler = logging.StreamHandler() + root_handler.setFormatter(formatter) + root_handler.setLevel(LOG_LEVELS[verbosity_level]['root']) + logging.getLogger('').addHandler(root_handler) + + mopidy_handler = logging.StreamHandler() + mopidy_handler.setFormatter(formatter) + mopidy_handler.setLevel(LOG_LEVELS[verbosity_level]['mopidy']) + add_mopidy_handler(extensions, mopidy_handler) -def setup_debug_logging_to_file(config): +def setup_debug_logging_to_file(config, extensions): formatter = logging.Formatter(config['logging']['debug_format']) handler = logging.handlers.RotatingFileHandler( config['logging']['debug_file'], maxBytes=10485760, backupCount=3) handler.setFormatter(formatter) - handler.setLevel(logging.DEBUG) - root = logging.getLogger('') - root.addHandler(handler) + + logging.getLogger('').addHandler(handler) + + # We must add our handler explicitly, since the mopidy* handlers don't + # propagate to the root handler. + add_mopidy_handler(extensions, handler) + + +def add_mopidy_handler(extensions, handler): + names = ['mopidy_%s' % ext.ext_name for ext in extensions] + names.append('mopidy') + for name in names: + logger = logging.getLogger(name) + logger.propagate = False + logger.addHandler(handler) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 1ffb12d6..bb1edbc4 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -12,7 +12,7 @@ import pykka from mopidy.utils import encoding -logger = logging.getLogger('mopidy.utils.server') +logger = logging.getLogger(__name__) class ShouldRetrySocketCall(Exception): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 32dcb721..29e8077e 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -9,7 +9,7 @@ import urlparse import glib -logger = logging.getLogger('mopidy.utils.path') +logger = logging.getLogger(__name__) XDG_DIRS = { @@ -119,26 +119,20 @@ def find_files(path): path = path.encode('utf-8') if os.path.isfile(path): - if not os.path.basename(path).startswith(b'.'): - yield path - else: - for dirpath, dirnames, filenames in os.walk(path, followlinks=True): - for dirname in dirnames: - if dirname.startswith(b'.'): - # Skip hidden dirs by modifying dirnames inplace - dirnames.remove(dirname) + return - for filename in filenames: - if filename.startswith(b'.'): - # Skip hidden files - continue + for dirpath, dirnames, filenames in os.walk(path, followlinks=True): + for dirname in dirnames: + if dirname.startswith(b'.'): + # Skip hidden dirs by modifying dirnames inplace + dirnames.remove(dirname) - yield os.path.join(dirpath, filename) + for filename in filenames: + if filename.startswith(b'.'): + # Skip hidden files + continue - -def find_uris(path): - for p in find_files(path): - yield path_to_uri(p) + yield os.path.relpath(os.path.join(dirpath, filename), path) def check_file_path_is_inside_base_dir(file_path, base_path): diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index c8e3e558..0660efe0 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -8,7 +8,7 @@ import threading from pykka import ActorDeadError from pykka.registry import ActorRegistry -logger = logging.getLogger('mopidy.utils.process') +logger = logging.getLogger(__name__) SIGNALS = dict( (k, v) for v, k in signal.__dict__.iteritems() diff --git a/mopidy/utils/zeroconf.py b/mopidy/zeroconf.py similarity index 76% rename from mopidy/utils/zeroconf.py rename to mopidy/zeroconf.py index acd25ef1..e95b1792 100644 --- a/mopidy/utils/zeroconf.py +++ b/mopidy/zeroconf.py @@ -4,7 +4,7 @@ import logging import socket import string -logger = logging.getLogger('mopidy.utils.zeroconf') +logger = logging.getLogger(__name__) try: import dbus @@ -25,7 +25,20 @@ def _convert_text_to_dbus_bytes(text): class Zeroconf(object): - """Publish a network service with Zeroconf using Avahi.""" + """Publish a network service with Zeroconf. + + Currently, this only works on Linux using Avahi via D-Bus. + + :param str name: human readable name of the service, e.g. 'MPD on neptune' + :param int port: TCP port of the service, e.g. 6600 + :param str stype: service type, e.g. '_mpd._tcp' + :param str domain: local network domain name, defaults to '' + :param str host: interface to advertise the service on, defaults to all + interfaces + :param text: extra information depending on ``stype``, defaults to empty + list + :type text: list of str + """ def __init__(self, name, port, stype=None, domain=None, host=None, text=None): @@ -44,6 +57,11 @@ class Zeroconf(object): hostname=self.host or socket.getfqdn(), port=self.port) def publish(self): + """Publish the service. + + Call when your service starts. + """ + if _is_loopback_address(self.host): logger.info( 'Zeroconf publish on loopback interface is not supported.') @@ -83,6 +101,11 @@ class Zeroconf(object): return False def unpublish(self): + """Unpublish the service. + + Call when your service shuts down. + """ + if self.group: try: self.group.Reset() diff --git a/requirements/README.rst b/requirements/README.rst deleted file mode 100644 index e1a6d757..00000000 --- a/requirements/README.rst +++ /dev/null @@ -1,11 +0,0 @@ -********************* -pip requirement files -********************* - -The files found here are `requirement files -`_ that may be used -with `pip `_. - -To install the dependencies found in one of these files, simply run e.g.:: - - pip install -r requirements/tests.txt diff --git a/requirements/core.txt b/requirements/core.txt deleted file mode 100644 index d8e81e61..00000000 --- a/requirements/core.txt +++ /dev/null @@ -1,5 +0,0 @@ -setuptools -# Available as python-setuptools in Debian/Ubuntu - -Pykka >= 1.1 -# Available as python-pykka from apt.mopidy.com diff --git a/requirements/http.txt b/requirements/http.txt deleted file mode 100644 index f38bfa3c..00000000 --- a/requirements/http.txt +++ /dev/null @@ -1,6 +0,0 @@ -cherrypy >= 3.2.2 -# Available as python-cherrypy3 in Debian/Ubuntu - -ws4py >= 0.2.3 -# Available as python-ws4py in newer Debian/Ubuntu and from apt.mopidy.com for -# older releases of Debian/Ubuntu diff --git a/requirements/tests.txt b/requirements/tests.txt deleted file mode 100644 index 8aacebbc..00000000 --- a/requirements/tests.txt +++ /dev/null @@ -1,4 +0,0 @@ -coverage -flake8 -mock >= 1.0 -nose diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..5e409001 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[wheel] +universal = 1 diff --git a/setup.py b/setup.py index 511a16e8..f3e20f4a 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ setup( 'Pykka >= 1.1', ], extras_require={ - 'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'], + 'http': ['CherryPy >= 3.2.2', 'ws4py >= 0.2.3'], }, test_suite='nose.collector', tests_require=[ @@ -41,10 +41,10 @@ setup( 'mopidy-convert-config = mopidy.config.convert:main', ], 'mopidy.ext': [ - 'http = mopidy.frontends.http:Extension [http]', - 'local = mopidy.backends.local:Extension', - 'mpd = mopidy.frontends.mpd:Extension', - 'stream = mopidy.backends.stream:Extension', + 'http = mopidy.http:Extension [http]', + 'local = mopidy.local:Extension', + 'mpd = mopidy.mpd:Extension', + 'stream = mopidy.stream:Extension', ], }, classifiers=[ diff --git a/tests/audio/scan_test.py b/tests/audio/scan_test.py deleted file mode 100644 index 4acbecb6..00000000 --- a/tests/audio/scan_test.py +++ /dev/null @@ -1,313 +0,0 @@ -from __future__ import unicode_literals - -import unittest - -from mopidy import exceptions -from mopidy.audio import scan -from mopidy.models import Track, Artist, Album -from mopidy.utils import path as path_lib - -from tests import path_to_data_dir - - -class FakeGstDate(object): - def __init__(self, year, month, day): - self.year = year - self.month = month - self.day = day - - -class TranslatorTest(unittest.TestCase): - def setUp(self): - self.data = { - 'uri': 'uri', - 'album': 'albumname', - 'track-number': 1, - 'artist': 'name', - 'composer': 'composer', - 'performer': 'performer', - 'album-artist': 'albumartistname', - 'title': 'trackname', - 'track-count': 2, - 'album-disc-number': 2, - 'album-disc-count': 3, - 'date': FakeGstDate(2006, 1, 1,), - 'container-format': 'ID3 tag', - 'genre': 'genre', - 'duration': 4531000000, - 'comment': 'comment', - 'musicbrainz-trackid': 'mbtrackid', - 'musicbrainz-albumid': 'mbalbumid', - 'musicbrainz-artistid': 'mbartistid', - 'musicbrainz-albumartistid': 'mbalbumartistid', - 'mtime': 1234, - } - - self.album = { - 'name': 'albumname', - 'num_tracks': 2, - 'num_discs': 3, - 'musicbrainz_id': 'mbalbumid', - } - - self.artist_single = { - 'name': 'name', - 'musicbrainz_id': 'mbartistid', - } - - self.artist_multiple = { - 'name': ['name1', 'name2'], - 'musicbrainz_id': 'mbartistid', - } - - self.artist = self.artist_single - - self.composer_single = { - 'name': 'composer', - } - - self.composer_multiple = { - 'name': ['composer1', 'composer2'], - } - - self.composer = self.composer_single - - self.performer_single = { - 'name': 'performer', - } - - self.performer_multiple = { - 'name': ['performer1', 'performer2'], - } - - self.performer = self.performer_single - - self.albumartist = { - 'name': 'albumartistname', - 'musicbrainz_id': 'mbalbumartistid', - } - - self.track = { - 'uri': 'uri', - 'name': 'trackname', - 'date': '2006-01-01', - 'genre': 'genre', - 'track_no': 1, - 'disc_no': 2, - 'comment': 'comment', - 'length': 4531, - 'musicbrainz_id': 'mbtrackid', - 'last_modified': 1234, - } - - def build_track(self): - if self.albumartist: - self.album['artists'] = [Artist(**self.albumartist)] - self.track['album'] = Album(**self.album) - - if ('name' in self.artist - and not isinstance(self.artist['name'], basestring)): - self.track['artists'] = [Artist(name=artist) - for artist in self.artist['name']] - else: - self.track['artists'] = [Artist(**self.artist)] - - if ('name' in self.composer - and not isinstance(self.composer['name'], basestring)): - self.track['composers'] = [Artist(name=artist) - for artist in self.composer['name']] - else: - self.track['composers'] = [Artist(**self.composer)] \ - if self.composer else '' - - if ('name' in self.performer - and not isinstance(self.performer['name'], basestring)): - self.track['performers'] = [Artist(name=artist) - for artist in self.performer['name']] - else: - self.track['performers'] = [Artist(**self.performer)] \ - if self.performer else '' - - return Track(**self.track) - - def check(self): - expected = self.build_track() - actual = scan.audio_data_to_track(self.data) - self.assertEqual(expected, actual) - - def test_basic_data(self): - self.check() - - def test_missing_track_number(self): - del self.data['track-number'] - del self.track['track_no'] - self.check() - - def test_missing_track_count(self): - del self.data['track-count'] - del self.album['num_tracks'] - self.check() - - def test_missing_track_name(self): - del self.data['title'] - del self.track['name'] - self.check() - - def test_missing_track_musicbrainz_id(self): - del self.data['musicbrainz-trackid'] - del self.track['musicbrainz_id'] - self.check() - - def test_missing_album_name(self): - del self.data['album'] - del self.album['name'] - self.check() - - def test_missing_album_musicbrainz_id(self): - del self.data['musicbrainz-albumid'] - del self.album['musicbrainz_id'] - self.check() - - def test_missing_artist_name(self): - del self.data['artist'] - del self.artist['name'] - self.check() - - def test_missing_composer_name(self): - del self.data['composer'] - del self.composer['name'] - self.check() - - def test_multiple_track_composers(self): - self.data['composer'] = ['composer1', 'composer2'] - self.composer = self.composer_multiple - self.check() - - def test_multiple_track_performers(self): - self.data['performer'] = ['performer1', 'performer2'] - self.performer = self.performer_multiple - self.check() - - def test_missing_performer_name(self): - del self.data['performer'] - del self.performer['name'] - self.check() - - def test_missing_artist_musicbrainz_id(self): - del self.data['musicbrainz-artistid'] - del self.artist['musicbrainz_id'] - self.check() - - def test_multiple_track_artists(self): - self.data['artist'] = ['name1', 'name2'] - self.data['musicbrainz-artistid'] = 'mbartistid' - self.artist = self.artist_multiple - self.check() - - def test_missing_album_artist(self): - del self.data['album-artist'] - del self.albumartist['name'] - self.check() - - def test_missing_album_artist_musicbrainz_id(self): - del self.data['musicbrainz-albumartistid'] - del self.albumartist['musicbrainz_id'] - self.check() - - def test_missing_genre(self): - del self.data['genre'] - del self.track['genre'] - self.check() - - def test_missing_date(self): - del self.data['date'] - del self.track['date'] - self.check() - - def test_invalid_date(self): - self.data['date'] = FakeGstDate(65535, 1, 1) - del self.track['date'] - self.check() - - def test_missing_comment(self): - del self.data['comment'] - del self.track['comment'] - self.check() - - -class ScannerTest(unittest.TestCase): - def setUp(self): - self.errors = {} - self.data = {} - - def scan(self, path): - paths = path_lib.find_files(path_to_data_dir(path)) - uris = (path_lib.path_to_uri(p) for p in paths) - scanner = scan.Scanner() - for uri in uris: - key = uri[len('file://'):] - try: - self.data[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.data[name][key], value) - - def test_data_is_set(self): - self.scan('scanner/simple') - self.assert_(self.data) - - def test_errors_is_not_set(self): - self.scan('scanner/simple') - self.assert_(not self.errors) - - def test_uri_is_set(self): - self.scan('scanner/simple') - self.check( - 'scanner/simple/song1.mp3', 'uri', - 'file://%s' % path_to_data_dir('scanner/simple/song1.mp3')) - self.check( - 'scanner/simple/song1.ogg', 'uri', - 'file://%s' % path_to_data_dir('scanner/simple/song1.ogg')) - - def test_duration_is_set(self): - self.scan('scanner/simple') - self.check('scanner/simple/song1.mp3', 'duration', 4680000000) - self.check('scanner/simple/song1.ogg', 'duration', 4680000000) - - def test_artist_is_set(self): - self.scan('scanner/simple') - self.check('scanner/simple/song1.mp3', 'artist', 'name') - self.check('scanner/simple/song1.ogg', 'artist', 'name') - - def test_album_is_set(self): - self.scan('scanner/simple') - self.check('scanner/simple/song1.mp3', 'album', 'albumname') - self.check('scanner/simple/song1.ogg', 'album', 'albumname') - - def test_track_is_set(self): - self.scan('scanner/simple') - self.check('scanner/simple/song1.mp3', 'title', 'trackname') - self.check('scanner/simple/song1.ogg', 'title', 'trackname') - - def test_nonexistant_dir_does_not_fail(self): - self.scan('scanner/does-not-exist') - self.assert_(not self.errors) - - def test_other_media_is_ignored(self): - self.scan('scanner/image') - self.assert_(self.errors) - - def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self): - self.scan('scanner/example.log') - self.assert_(self.errors) - - def test_empty_wav_file_is_ignored(self): - self.scan('scanner/empty.wav') - self.assert_(self.errors) - - @unittest.SkipTest - def test_song_without_time_is_handeled(self): - pass diff --git a/tests/audio/actor_test.py b/tests/audio/test_actor.py similarity index 97% rename from tests/audio/actor_test.py rename to tests/audio/test_actor.py index eac299cf..3f7e56ce 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/test_actor.py @@ -23,6 +23,7 @@ class AudioTest(unittest.TestCase): 'audio': { 'mixer': 'fakemixer track_max_volume=65536', 'mixer_track': None, + 'mixer_volume': None, 'output': 'fakesink', 'visualizer': None, } @@ -73,6 +74,7 @@ class AudioTest(unittest.TestCase): 'audio': { 'mixer': 'fakemixer track_max_volume=40', 'mixer_track': None, + 'mixer_volume': None, 'output': 'fakesink', 'visualizer': None, } @@ -88,6 +90,7 @@ class AudioTest(unittest.TestCase): 'audio': { 'mixer': 'fakemixer track_max_volume=0', 'mixer_track': None, + 'mixer_volume': None, 'output': 'fakesink', 'visualizer': None, } diff --git a/tests/audio/listener_test.py b/tests/audio/test_listener.py similarity index 100% rename from tests/audio/listener_test.py rename to tests/audio/test_listener.py diff --git a/tests/audio/playlists_test.py b/tests/audio/test_playlists.py similarity index 100% rename from tests/audio/playlists_test.py rename to tests/audio/test_playlists.py diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py new file mode 100644 index 00000000..cc44ecd6 --- /dev/null +++ b/tests/audio/test_scan.py @@ -0,0 +1,362 @@ +from __future__ import unicode_literals + +import os +import unittest + +import gobject +gobject.threads_init() + +from mopidy import exceptions +from mopidy.audio import scan +from mopidy.models import Track, Artist, Album +from mopidy.utils import path as path_lib + +from tests import path_to_data_dir + + +class FakeGstDate(object): + def __init__(self, year, month, day): + self.year = year + self.month = month + self.day = day + + +# TODO: keep ids without name? +class TranslatorTest(unittest.TestCase): + def setUp(self): + self.data = { + 'uri': 'uri', + 'duration': 4531000000, + 'mtime': 1234, + 'tags': { + 'album': ['album'], + 'track-number': [1], + 'artist': ['artist'], + 'composer': ['composer'], + 'performer': ['performer'], + 'album-artist': ['albumartist'], + 'title': ['track'], + 'track-count': [2], + 'album-disc-number': [2], + 'album-disc-count': [3], + 'date': [FakeGstDate(2006, 1, 1,)], + 'container-format': ['ID3 tag'], + 'genre': ['genre'], + 'comment': ['comment'], + 'musicbrainz-trackid': ['trackid'], + 'musicbrainz-albumid': ['albumid'], + 'musicbrainz-artistid': ['artistid'], + 'musicbrainz-albumartistid': ['albumartistid'], + 'bitrate': [1000], + }, + } + + artist = Artist(name='artist', musicbrainz_id='artistid') + composer = Artist(name='composer') + performer = Artist(name='performer') + albumartist = Artist(name='albumartist', + musicbrainz_id='albumartistid') + + album = Album(name='album', num_tracks=2, num_discs=3, + musicbrainz_id='albumid', artists=[albumartist]) + + self.track = Track(uri='uri', name='track', date='2006-01-01', + genre='genre', track_no=1, disc_no=2, length=4531, + comment='comment', musicbrainz_id='trackid', + last_modified=1234, album=album, bitrate=1000, + artists=[artist], composers=[composer], + performers=[performer]) + + def check(self, expected): + actual = scan.audio_data_to_track(self.data) + self.assertEqual(expected, actual) + + def test_track(self): + self.check(self.track) + + def test_none_track_length(self): + self.data['duration'] = None + self.check(self.track.copy(length=None)) + + def test_none_track_last_modified(self): + self.data['mtime'] = None + self.check(self.track.copy(last_modified=None)) + + def test_missing_track_no(self): + del self.data['tags']['track-number'] + self.check(self.track.copy(track_no=None)) + + def test_multiple_track_no(self): + self.data['tags']['track-number'].append(9) + self.check(self.track) + + def test_missing_track_disc_no(self): + del self.data['tags']['album-disc-number'] + self.check(self.track.copy(disc_no=None)) + + def test_multiple_track_disc_no(self): + self.data['tags']['album-disc-number'].append(9) + self.check(self.track) + + def test_missing_track_name(self): + del self.data['tags']['title'] + self.check(self.track.copy(name=None)) + + def test_multiple_track_name(self): + self.data['tags']['title'] = ['name1', 'name2'] + self.check(self.track.copy(name='name1; name2')) + + def test_missing_track_musicbrainz_id(self): + del self.data['tags']['musicbrainz-trackid'] + self.check(self.track.copy(musicbrainz_id=None)) + + def test_multiple_track_musicbrainz_id(self): + self.data['tags']['musicbrainz-trackid'].append('id') + self.check(self.track) + + def test_missing_track_bitrate(self): + del self.data['tags']['bitrate'] + self.check(self.track.copy(bitrate=None)) + + def test_multiple_track_bitrate(self): + self.data['tags']['bitrate'].append(1234) + self.check(self.track) + + def test_missing_track_genre(self): + del self.data['tags']['genre'] + self.check(self.track.copy(genre=None)) + + def test_multiple_track_genre(self): + self.data['tags']['genre'] = ['genre1', 'genre2'] + self.check(self.track.copy(genre='genre1; genre2')) + + def test_missing_track_date(self): + del self.data['tags']['date'] + self.check(self.track.copy(date=None)) + + def test_multiple_track_date(self): + self.data['tags']['date'].append(FakeGstDate(2030, 1, 1)) + self.check(self.track) + + def test_invalid_track_date(self): + self.data['tags']['date'] = [FakeGstDate(65535, 1, 1)] + self.check(self.track.copy(date=None)) + + def test_missing_track_comment(self): + del self.data['tags']['comment'] + self.check(self.track.copy(comment=None)) + + def test_multiple_track_comment(self): + self.data['tags']['comment'] = ['comment1', 'comment2'] + self.check(self.track.copy(comment='comment1; comment2')) + + def test_missing_track_artist_name(self): + del self.data['tags']['artist'] + self.check(self.track.copy(artists=[])) + + def test_multiple_track_artist_name(self): + self.data['tags']['artist'] = ['name1', 'name2'] + artists = [Artist(name='name1'), Artist(name='name2')] + self.check(self.track.copy(artists=artists)) + + def test_missing_track_artist_musicbrainz_id(self): + del self.data['tags']['musicbrainz-artistid'] + artist = list(self.track.artists)[0].copy(musicbrainz_id=None) + self.check(self.track.copy(artists=[artist])) + + def test_multiple_track_artist_musicbrainz_id(self): + self.data['tags']['musicbrainz-artistid'].append('id') + self.check(self.track) + + def test_missing_track_composer_name(self): + del self.data['tags']['composer'] + self.check(self.track.copy(composers=[])) + + def test_multiple_track_composer_name(self): + self.data['tags']['composer'] = ['composer1', 'composer2'] + composers = [Artist(name='composer1'), Artist(name='composer2')] + self.check(self.track.copy(composers=composers)) + + def test_missing_track_performer_name(self): + del self.data['tags']['performer'] + self.check(self.track.copy(performers=[])) + + def test_multiple_track_performe_name(self): + self.data['tags']['performer'] = ['performer1', 'performer2'] + performers = [Artist(name='performer1'), Artist(name='performer2')] + self.check(self.track.copy(performers=performers)) + + def test_missing_album_name(self): + del self.data['tags']['album'] + album = self.track.album.copy(name=None) + self.check(self.track.copy(album=album)) + + def test_multiple_album_name(self): + self.data['tags']['album'].append('album2') + self.check(self.track) + + def test_missing_album_musicbrainz_id(self): + del self.data['tags']['musicbrainz-albumid'] + album = self.track.album.copy(musicbrainz_id=None) + self.check(self.track.copy(album=album)) + + def test_multiple_album_musicbrainz_id(self): + self.data['tags']['musicbrainz-albumid'].append('id') + self.check(self.track) + + def test_missing_album_num_tracks(self): + del self.data['tags']['track-count'] + album = self.track.album.copy(num_tracks=None) + self.check(self.track.copy(album=album)) + + def test_multiple_album_num_tracks(self): + self.data['tags']['track-count'].append(9) + self.check(self.track) + + def test_missing_album_num_discs(self): + del self.data['tags']['album-disc-count'] + album = self.track.album.copy(num_discs=None) + self.check(self.track.copy(album=album)) + + def test_multiple_album_num_discs(self): + self.data['tags']['album-disc-count'].append(9) + self.check(self.track) + + def test_missing_album_artist_name(self): + del self.data['tags']['album-artist'] + album = self.track.album.copy(artists=[]) + self.check(self.track.copy(album=album)) + + def test_multiple_album_artist_name(self): + self.data['tags']['album-artist'] = ['name1', 'name2'] + artists = [Artist(name='name1'), Artist(name='name2')] + album = self.track.album.copy(artists=artists) + self.check(self.track.copy(album=album)) + + def test_missing_album_artist_musicbrainz_id(self): + del self.data['tags']['musicbrainz-albumartistid'] + albumartist = list(self.track.album.artists)[0] + albumartist = albumartist.copy(musicbrainz_id=None) + album = self.track.album.copy(artists=[albumartist]) + self.check(self.track.copy(album=album)) + + def test_multiple_album_artist_musicbrainz_id(self): + self.data['tags']['musicbrainz-albumartistid'].append('id') + self.check(self.track) + + def test_stream_organization_track_name(self): + del self.data['tags']['title'] + self.data['tags']['organization'] = ['organization'] + self.check(self.track.copy(name='organization')) + + def test_multiple_organization_track_name(self): + del self.data['tags']['title'] + self.data['tags']['organization'] = ['organization1', 'organization2'] + self.check(self.track.copy(name='organization1; organization2')) + + # TODO: combine all comment types? + def test_stream_location_track_comment(self): + del self.data['tags']['comment'] + self.data['tags']['location'] = ['location'] + self.check(self.track.copy(comment='location')) + + def test_multiple_location_track_comment(self): + del self.data['tags']['comment'] + self.data['tags']['location'] = ['location1', 'location2'] + self.check(self.track.copy(comment='location1; location2')) + + def test_stream_copyright_track_comment(self): + del self.data['tags']['comment'] + self.data['tags']['copyright'] = ['copyright'] + self.check(self.track.copy(comment='copyright')) + + def test_multiple_copyright_track_comment(self): + del self.data['tags']['comment'] + self.data['tags']['copyright'] = ['copyright1', 'copyright2'] + self.check(self.track.copy(comment='copyright1; copyright2')) + + +class ScannerTest(unittest.TestCase): + def setUp(self): + self.errors = {} + self.data = {} + + def find(self, path): + media_dir = path_to_data_dir(path) + for path in path_lib.find_files(media_dir): + yield os.path.join(media_dir, path) + + def scan(self, paths): + scanner = scan.Scanner() + for path in paths: + uri = path_lib.path_to_uri(path) + key = uri[len('file://'):] + try: + self.data[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.data[name][key], value) + + def check_tag(self, name, key, value): + name = path_to_data_dir(name) + self.assertEqual(self.data[name]['tags'][key], value) + + def test_data_is_set(self): + self.scan(self.find('scanner/simple')) + self.assert_(self.data) + + def test_errors_is_not_set(self): + self.scan(self.find('scanner/simple')) + self.assert_(not self.errors) + + def test_uri_is_set(self): + self.scan(self.find('scanner/simple')) + self.check( + 'scanner/simple/song1.mp3', 'uri', + 'file://%s' % path_to_data_dir('scanner/simple/song1.mp3')) + self.check( + 'scanner/simple/song1.ogg', 'uri', + 'file://%s' % path_to_data_dir('scanner/simple/song1.ogg')) + + def test_duration_is_set(self): + self.scan(self.find('scanner/simple')) + self.check('scanner/simple/song1.mp3', 'duration', 4680000000) + self.check('scanner/simple/song1.ogg', 'duration', 4680000000) + + def test_artist_is_set(self): + self.scan(self.find('scanner/simple')) + self.check_tag('scanner/simple/song1.mp3', 'artist', ['name']) + self.check_tag('scanner/simple/song1.ogg', 'artist', ['name']) + + def test_album_is_set(self): + self.scan(self.find('scanner/simple')) + self.check_tag('scanner/simple/song1.mp3', 'album', ['albumname']) + self.check_tag('scanner/simple/song1.ogg', 'album', ['albumname']) + + def test_track_is_set(self): + self.scan(self.find('scanner/simple')) + self.check_tag('scanner/simple/song1.mp3', 'title', ['trackname']) + self.check_tag('scanner/simple/song1.ogg', 'title', ['trackname']) + + def test_nonexistant_dir_does_not_fail(self): + self.scan(self.find('scanner/does-not-exist')) + self.assert_(not self.errors) + + def test_other_media_is_ignored(self): + self.scan(self.find('scanner/image')) + self.assert_(self.errors) + + def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self): + self.scan([path_to_data_dir('scanner/example.log')]) + self.assert_(self.errors) + + def test_empty_wav_file_is_ignored(self): + self.scan([path_to_data_dir('scanner/empty.wav')]) + self.assert_(self.errors) + + @unittest.SkipTest + def test_song_without_time_is_handeled(self): + pass diff --git a/mopidy/frontends/__init__.py b/tests/backend/__init__.py similarity index 100% rename from mopidy/frontends/__init__.py rename to tests/backend/__init__.py diff --git a/tests/backends/listener_test.py b/tests/backend/test_listener.py similarity index 83% rename from tests/backends/listener_test.py rename to tests/backend/test_listener.py index ae2eb997..fd861e4f 100644 --- a/tests/backends/listener_test.py +++ b/tests/backend/test_listener.py @@ -3,12 +3,12 @@ from __future__ import unicode_literals import mock import unittest -from mopidy.backends.listener import BackendListener +from mopidy import backend class BackendListenerTest(unittest.TestCase): def setUp(self): - self.listener = BackendListener() + self.listener = backend.BackendListener() def test_on_event_forwards_to_specific_handler(self): self.listener.playlists_loaded = mock.Mock() diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py deleted file mode 100644 index 5623c787..00000000 --- a/tests/backends/local/translator_test.py +++ /dev/null @@ -1,194 +0,0 @@ -# encoding: utf-8 - -from __future__ import unicode_literals - -import os -import tempfile -import unittest - -from mopidy.backends.local.translator import parse_m3u, parse_mpd_tag_cache -from mopidy.models import Track, Artist, Album -from mopidy.utils.path import path_to_uri - -from tests import path_to_data_dir - -data_dir = path_to_data_dir('') -song1_path = path_to_data_dir('song1.mp3') -song2_path = path_to_data_dir('song2.mp3') -encoded_path = path_to_data_dir('æøå.mp3') -song1_uri = path_to_uri(song1_path) -song2_uri = path_to_uri(song2_path) -encoded_uri = path_to_uri(encoded_path) - -# FIXME use mock instead of tempfile.NamedTemporaryFile - - -class M3UToUriTest(unittest.TestCase): - def test_empty_file(self): - uris = parse_m3u(path_to_data_dir('empty.m3u'), data_dir) - self.assertEqual([], uris) - - def test_basic_file(self): - uris = parse_m3u(path_to_data_dir('one.m3u'), data_dir) - self.assertEqual([song1_uri], uris) - - def test_file_with_comment(self): - uris = parse_m3u(path_to_data_dir('comment.m3u'), data_dir) - self.assertEqual([song1_uri], uris) - - def test_file_is_relative_to_correct_dir(self): - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write('song1.mp3') - try: - uris = parse_m3u(tmp.name, data_dir) - self.assertEqual([song1_uri], uris) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) - - def test_file_with_absolute_files(self): - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write(song1_path) - try: - uris = parse_m3u(tmp.name, data_dir) - self.assertEqual([song1_uri], uris) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) - - def test_file_with_multiple_absolute_files(self): - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write(song1_path + '\n') - tmp.write('# comment \n') - tmp.write(song2_path) - try: - uris = parse_m3u(tmp.name, data_dir) - self.assertEqual([song1_uri, song2_uri], uris) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) - - def test_file_with_uri(self): - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write(song1_uri) - try: - uris = parse_m3u(tmp.name, data_dir) - self.assertEqual([song1_uri], uris) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) - - def test_encoding_is_latin1(self): - uris = parse_m3u(path_to_data_dir('encoding.m3u'), data_dir) - self.assertEqual([encoded_uri], uris) - - def test_open_missing_file(self): - uris = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir) - self.assertEqual([], uris) - - -class URItoM3UTest(unittest.TestCase): - pass - - -expected_artists = [Artist(name='name')] -expected_albums = [ - Album(name='albumname', artists=expected_artists, num_tracks=2), - Album(name='albumname', num_tracks=2), -] -expected_tracks = [] - - -def generate_track(path, ident, album_id): - uri = 'local:track:%s' % path - track = Track( - uri=uri, name='trackname', artists=expected_artists, - album=expected_albums[album_id], track_no=1, date='2006', length=4000, - last_modified=1272319626) - expected_tracks.append(track) - - -generate_track('song1.mp3', 6, 0) -generate_track('song2.mp3', 7, 0) -generate_track('song3.mp3', 8, 1) -generate_track('subdir1/song4.mp3', 2, 0) -generate_track('subdir1/song5.mp3', 3, 0) -generate_track('subdir2/song6.mp3', 4, 1) -generate_track('subdir2/song7.mp3', 5, 1) -generate_track('subdir1/subsubdir/song8.mp3', 0, 0) -generate_track('subdir1/subsubdir/song9.mp3', 1, 1) - - -class MPDTagCacheToTracksTest(unittest.TestCase): - def test_emtpy_cache(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('empty_tag_cache'), path_to_data_dir('')) - self.assertEqual(set(), tracks) - - def test_simple_cache(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('simple_tag_cache'), path_to_data_dir('')) - track = Track( - uri='local:track:song1.mp3', name='trackname', - artists=expected_artists, track_no=1, album=expected_albums[0], - date='2006', length=4000, last_modified=1272319626) - self.assertEqual(set([track]), tracks) - - def test_advanced_cache(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('advanced_tag_cache'), path_to_data_dir('')) - self.assertEqual(set(expected_tracks), tracks) - - def test_unicode_cache(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('utf8_tag_cache'), path_to_data_dir('')) - - artists = [Artist(name='æøå')] - album = Album(name='æøå', artists=artists) - track = Track( - uri='local:track:song1.mp3', name='æøå', artists=artists, - composers=artists, performers=artists, genre='æøå', - album=album, length=4000, last_modified=1272319626, - comment='æøå&^`ൂ㔶') - - self.assertEqual(track, list(tracks)[0]) - - @unittest.SkipTest - def test_misencoded_cache(self): - # FIXME not sure if this can happen - pass - - def test_cache_with_blank_track_info(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('blank_tag_cache'), path_to_data_dir('')) - expected = Track( - uri='local:track:song1.mp3', length=4000, last_modified=1272319626) - self.assertEqual(set([expected]), tracks) - - def test_musicbrainz_tagcache(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('musicbrainz_tag_cache'), path_to_data_dir('')) - artist = list(expected_tracks[0].artists)[0].copy( - musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') - albumartist = list(expected_tracks[0].artists)[0].copy( - name='albumartistname', - musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') - album = expected_tracks[0].album.copy( - artists=[albumartist], - musicbrainz_id='cb5f1603-d314-4c9c-91e5-e295cfb125d2') - track = expected_tracks[0].copy( - artists=[artist], album=album, - musicbrainz_id='90488461-8c1f-4a4e-826b-4c6dc70801f0') - - self.assertEqual(track, list(tracks)[0]) - - def test_albumartist_tag_cache(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('albumartist_tag_cache'), path_to_data_dir('')) - artist = Artist(name='albumartistname') - album = expected_albums[0].copy(artists=[artist]) - track = Track( - uri='local:track:song1.mp3', name='trackname', - artists=expected_artists, track_no=1, album=album, date='2006', - length=4000, last_modified=1272319626) - self.assertEqual(track, list(tracks)[0]) diff --git a/tests/config/config_test.py b/tests/config/test_config.py similarity index 100% rename from tests/config/config_test.py rename to tests/config/test_config.py diff --git a/tests/config/schemas_test.py b/tests/config/test_schemas.py similarity index 92% rename from tests/config/schemas_test.py rename to tests/config/test_schemas.py index 9da8f667..6eb35ed3 100644 --- a/tests/config/schemas_test.py +++ b/tests/config/test_schemas.py @@ -4,7 +4,7 @@ import logging import mock import unittest -from mopidy.config import schemas +from mopidy.config import schemas, types from tests import any_unicode @@ -77,6 +77,13 @@ class ConfigSchemaTest(unittest.TestCase): self.assertIsNone(result['bar']) self.assertIsNone(result['baz']) + def test_deserialize_deprecated_value(self): + self.schema['foo'] = types.Deprecated() + + result, errors = self.schema.deserialize(self.values) + self.assertItemsEqual(['bar', 'baz'], result.keys()) + self.assertNotIn('foo', errors) + class LogLevelConfigSchemaTest(unittest.TestCase): def test_conversion(self): diff --git a/tests/config/types_test.py b/tests/config/test_types.py similarity index 97% rename from tests/config/types_test.py rename to tests/config/test_types.py index 0df3dfb4..c4b9ec88 100644 --- a/tests/config/types_test.py +++ b/tests/config/test_types.py @@ -33,6 +33,16 @@ class ConfigValueTest(unittest.TestCase): self.assertIsInstance(value.serialize(object(), display=True), bytes) +class DeprecatedTest(unittest.TestCase): + def test_deserialize_returns_deprecated_value(self): + self.assertIsInstance(types.Deprecated().deserialize(b'foobar'), + types.DeprecatedValue) + + def test_serialize_returns_deprecated_value(self): + self.assertIsInstance(types.Deprecated().serialize('foobar'), + types.DeprecatedValue) + + class StringTest(unittest.TestCase): def test_deserialize_conversion_success(self): value = types.String() diff --git a/tests/config/validator_tests.py b/tests/config/test_validator.py similarity index 100% rename from tests/config/validator_tests.py rename to tests/config/test_validator.py diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py deleted file mode 100644 index f3374547..00000000 --- a/tests/core/playback_test.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import unicode_literals - -import mock -import unittest - -from mopidy.backends import base -from mopidy.core import Core, PlaybackState -from mopidy.models import Track - - -class CorePlaybackTest(unittest.TestCase): - def setUp(self): - self.backend1 = mock.Mock() - self.backend1.uri_schemes.get.return_value = ['dummy1'] - self.playback1 = mock.Mock(spec=base.BasePlaybackProvider) - self.backend1.playback = self.playback1 - - self.backend2 = mock.Mock() - self.backend2.uri_schemes.get.return_value = ['dummy2'] - self.playback2 = mock.Mock(spec=base.BasePlaybackProvider) - self.backend2.playback = self.playback2 - - # A backend without the optional playback provider - self.backend3 = mock.Mock() - self.backend3.uri_schemes.get.return_value = ['dummy3'] - self.backend3.has_playback().get.return_value = False - - self.tracks = [ - Track(uri='dummy1:a', length=40000), - Track(uri='dummy2:a', length=40000), - Track(uri='dummy3:a', length=40000), # Unplayable - Track(uri='dummy1:b', length=40000), - ] - - self.core = Core(audio=None, backends=[ - self.backend1, self.backend2, self.backend3]) - self.core.tracklist.add(self.tracks) - - self.tl_tracks = self.core.tracklist.tl_tracks - self.unplayable_tl_track = self.tl_tracks[2] - - def test_play_selects_dummy1_backend(self): - self.core.playback.play(self.tl_tracks[0]) - - self.playback1.play.assert_called_once_with(self.tracks[0]) - self.assertFalse(self.playback2.play.called) - - def test_play_selects_dummy2_backend(self): - self.core.playback.play(self.tl_tracks[1]) - - self.assertFalse(self.playback1.play.called) - self.playback2.play.assert_called_once_with(self.tracks[1]) - - def test_play_skips_to_next_on_unplayable_track(self): - self.core.playback.play(self.unplayable_tl_track) - - self.playback1.play.assert_called_once_with(self.tracks[3]) - self.assertFalse(self.playback2.play.called) - - self.assertEqual( - self.core.playback.current_tl_track, self.tl_tracks[3]) - - def test_pause_selects_dummy1_backend(self): - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.pause() - - self.playback1.pause.assert_called_once_with() - self.assertFalse(self.playback2.pause.called) - - def test_pause_selects_dummy2_backend(self): - self.core.playback.play(self.tl_tracks[1]) - self.core.playback.pause() - - self.assertFalse(self.playback1.pause.called) - self.playback2.pause.assert_called_once_with() - - def test_pause_changes_state_even_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track - self.core.playback.pause() - - self.assertEqual(self.core.playback.state, PlaybackState.PAUSED) - self.assertFalse(self.playback1.pause.called) - self.assertFalse(self.playback2.pause.called) - - def test_resume_selects_dummy1_backend(self): - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.pause() - self.core.playback.resume() - - self.playback1.resume.assert_called_once_with() - self.assertFalse(self.playback2.resume.called) - - def test_resume_selects_dummy2_backend(self): - self.core.playback.play(self.tl_tracks[1]) - self.core.playback.pause() - self.core.playback.resume() - - self.assertFalse(self.playback1.resume.called) - self.playback2.resume.assert_called_once_with() - - def test_resume_does_nothing_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track - self.core.playback.state = PlaybackState.PAUSED - self.core.playback.resume() - - self.assertEqual(self.core.playback.state, PlaybackState.PAUSED) - self.assertFalse(self.playback1.resume.called) - self.assertFalse(self.playback2.resume.called) - - def test_stop_selects_dummy1_backend(self): - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.stop() - - self.playback1.stop.assert_called_once_with() - self.assertFalse(self.playback2.stop.called) - - def test_stop_selects_dummy2_backend(self): - self.core.playback.play(self.tl_tracks[1]) - self.core.playback.stop() - - self.assertFalse(self.playback1.stop.called) - self.playback2.stop.assert_called_once_with() - - def test_stop_changes_state_even_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track - self.core.playback.state = PlaybackState.PAUSED - self.core.playback.stop() - - self.assertEqual(self.core.playback.state, PlaybackState.STOPPED) - self.assertFalse(self.playback1.stop.called) - self.assertFalse(self.playback2.stop.called) - - def test_seek_selects_dummy1_backend(self): - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.seek(10000) - - self.playback1.seek.assert_called_once_with(10000) - self.assertFalse(self.playback2.seek.called) - - def test_seek_selects_dummy2_backend(self): - self.core.playback.play(self.tl_tracks[1]) - self.core.playback.seek(10000) - - self.assertFalse(self.playback1.seek.called) - self.playback2.seek.assert_called_once_with(10000) - - def test_seek_fails_for_unplayable_track(self): - self.core.playback.current_tl_track = self.unplayable_tl_track - self.core.playback.state = PlaybackState.PLAYING - success = self.core.playback.seek(1000) - - self.assertFalse(success) - self.assertFalse(self.playback1.seek.called) - self.assertFalse(self.playback2.seek.called) - - def test_time_position_selects_dummy1_backend(self): - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.seek(10000) - self.core.playback.time_position - - self.playback1.get_time_position.assert_called_once_with() - self.assertFalse(self.playback2.get_time_position.called) - - def test_time_position_selects_dummy2_backend(self): - self.core.playback.play(self.tl_tracks[1]) - self.core.playback.seek(10000) - self.core.playback.time_position - - self.assertFalse(self.playback1.get_time_position.called) - self.playback2.get_time_position.assert_called_once_with() - - def test_time_position_returns_0_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track - - result = self.core.playback.time_position - - self.assertEqual(result, 0) - self.assertFalse(self.playback1.get_time_position.called) - self.assertFalse(self.playback2.get_time_position.called) - - def test_mute(self): - self.assertEqual(self.core.playback.mute, False) - - self.core.playback.mute = True - - self.assertEqual(self.core.playback.mute, True) diff --git a/tests/core/actor_test.py b/tests/core/test_actor.py similarity index 80% rename from tests/core/actor_test.py rename to tests/core/test_actor.py index c4952af3..4a808cad 100644 --- a/tests/core/actor_test.py +++ b/tests/core/test_actor.py @@ -6,15 +6,18 @@ import unittest import pykka from mopidy.core import Core +from mopidy.utils import versioning class CoreActorTest(unittest.TestCase): def setUp(self): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.backend1.actor_ref.actor_class.__name__ = b'B1' self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.backend2.actor_ref.actor_class.__name__ = b'B2' self.core = Core(audio=None, backends=[self.backend1, self.backend2]) @@ -28,10 +31,12 @@ class CoreActorTest(unittest.TestCase): self.assertIn('dummy2', result) def test_backends_with_colliding_uri_schemes_fails(self): - self.backend1.__class__.__name__ = b'B1' - self.backend2.__class__.__name__ = b'B2' self.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2'] + self.assertRaisesRegexp( AssertionError, 'Cannot add URI scheme dummy1 for B2, it is already handled by B1', Core, audio=None, backends=[self.backend1, self.backend2]) + + def test_version(self): + self.assertEqual(self.core.version, versioning.get_version()) diff --git a/tests/core/events_test.py b/tests/core/test_events.py similarity index 56% rename from tests/core/events_test.py rename to tests/core/test_events.py index 5d646840..ffa84e6e 100644 --- a/tests/core/events_test.py +++ b/tests/core/test_events.py @@ -6,7 +6,7 @@ import unittest import pykka from mopidy import core -from mopidy.backends import dummy +from mopidy.backend import dummy from mopidy.models import Track @@ -24,59 +24,6 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'playlists_loaded') - def test_pause_sends_track_playback_paused_event(self, send): - tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() - self.core.playback.play().get() - send.reset_mock() - - self.core.playback.pause().get() - - self.assertEqual(send.call_args[0][0], 'track_playback_paused') - self.assertEqual(send.call_args[1]['tl_track'], tl_tracks[0]) - self.assertEqual(send.call_args[1]['time_position'], 0) - - def test_resume_sends_track_playback_resumed(self, send): - tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() - self.core.playback.play() - self.core.playback.pause().get() - send.reset_mock() - - self.core.playback.resume().get() - - self.assertEqual(send.call_args[0][0], 'track_playback_resumed') - self.assertEqual(send.call_args[1]['tl_track'], tl_tracks[0]) - self.assertEqual(send.call_args[1]['time_position'], 0) - - def test_play_sends_track_playback_started_event(self, send): - tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() - send.reset_mock() - - self.core.playback.play().get() - - self.assertEqual(send.call_args[0][0], 'track_playback_started') - self.assertEqual(send.call_args[1]['tl_track'], tl_tracks[0]) - - def test_stop_sends_track_playback_ended_event(self, send): - tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() - self.core.playback.play().get() - send.reset_mock() - - self.core.playback.stop().get() - - self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended') - self.assertEqual(send.call_args_list[0][1]['tl_track'], tl_tracks[0]) - self.assertEqual(send.call_args_list[0][1]['time_position'], 0) - - def test_seek_sends_seeked_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play().get() - send.reset_mock() - - self.core.playback.seek(1000).get() - - self.assertEqual(send.call_args[0][0], 'seeked') - self.assertEqual(send.call_args[1]['time_position'], 1000) - def test_tracklist_add_sends_tracklist_changed_event(self, send): send.reset_mock() @@ -152,12 +99,3 @@ class BackendEventsTest(unittest.TestCase): self.core.playlists.save(playlist).get() self.assertEqual(send.call_args[0][0], 'playlist_changed') - - def test_set_volume_sends_volume_changed_event(self, send): - self.core.playback.set_volume(10).get() - send.reset_mock() - - self.core.playback.set_volume(20).get() - - self.assertEqual(send.call_args[0][0], 'volume_changed') - self.assertEqual(send.call_args[1]['volume'], 20) diff --git a/tests/core/library_test.py b/tests/core/test_library.py similarity index 71% rename from tests/core/library_test.py rename to tests/core/test_library.py index f4028d2f..7a40194d 100644 --- a/tests/core/library_test.py +++ b/tests/core/test_library.py @@ -3,31 +3,99 @@ from __future__ import unicode_literals import mock import unittest -from mopidy.backends import base -from mopidy.core import Core -from mopidy.models import SearchResult, Track +from mopidy import backend, core +from mopidy.models import Ref, SearchResult, Track class CoreLibraryTest(unittest.TestCase): def setUp(self): + dummy1_root = Ref.directory(uri='dummy1:directory', name='dummy1') self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] - self.library1 = mock.Mock(spec=base.BaseLibraryProvider) + self.library1 = mock.Mock(spec=backend.LibraryProvider) + 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'] - self.library2 = mock.Mock(spec=base.BaseLibraryProvider) + self.library2 = mock.Mock(spec=backend.LibraryProvider) + 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.has_library().get.return_value = False + self.backend3.has_library_browse().get.return_value = False - self.core = Core(audio=None, backends=[ + self.core = core.Core(audio=None, backends=[ self.backend1, self.backend2, self.backend3]) + def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): + result = self.core.library.browse(None) + + self.assertEqual(result, [ + Ref.directory(uri='dummy1:directory', name='dummy1'), + Ref.directory(uri='dummy2:directory', name='dummy2'), + ]) + self.assertFalse(self.library1.browse.called) + self.assertFalse(self.library2.browse.called) + self.assertFalse(self.backend3.library.browse.called) + + def test_browse_empty_string_returns_nothing(self): + result = self.core.library.browse('') + + self.assertEqual(result, []) + self.assertFalse(self.library1.browse.called) + self.assertFalse(self.library2.browse.called) + + def test_browse_dummy1_selects_dummy1_backend(self): + self.library1.browse().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') + + self.assertEqual(self.library1.browse.call_count, 1) + self.assertEqual(self.library2.browse.call_count, 0) + self.library1.browse.assert_called_with('dummy1:directory:/foo') + + def test_browse_dummy2_selects_dummy2_backend(self): + self.library2.browse().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') + + self.assertEqual(self.library1.browse.call_count, 0) + self.assertEqual(self.library2.browse.call_count, 1) + self.library2.browse.assert_called_with('dummy2:directory:/bar') + + def test_browse_dummy3_returns_nothing(self): + result = self.core.library.browse('dummy3:test') + + self.assertEqual(result, []) + self.assertEqual(self.library1.browse.call_count, 0) + self.assertEqual(self.library2.browse.call_count, 0) + + def test_browse_dir_returns_subdirs_and_tracks(self): + self.library1.browse().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, [ + Ref.directory(uri='dummy1:directory:/foo/bar', name='Bar'), + Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'), + ]) + def test_lookup_selects_dummy1_backend(self): self.core.library.lookup('dummy1:a') diff --git a/tests/core/listener_test.py b/tests/core/test_listener.py similarity index 100% rename from tests/core/listener_test.py rename to tests/core/test_listener.py diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py new file mode 100644 index 00000000..6796cfe7 --- /dev/null +++ b/tests/core/test_playback.py @@ -0,0 +1,395 @@ +from __future__ import unicode_literals + +import mock +import unittest + +from mopidy import backend, core +from mopidy.models import Track + + +class CorePlaybackTest(unittest.TestCase): + def setUp(self): + 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.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.backend2.playback = self.playback2 + + # A backend without the optional playback provider + self.backend3 = mock.Mock() + self.backend3.uri_schemes.get.return_value = ['dummy3'] + self.backend3.has_playback().get.return_value = False + + self.tracks = [ + Track(uri='dummy1:a', length=40000), + Track(uri='dummy2:a', length=40000), + Track(uri='dummy3:a', length=40000), # Unplayable + Track(uri='dummy1:b', length=40000), + ] + + self.core = core.Core(audio=None, backends=[ + self.backend1, self.backend2, self.backend3]) + self.core.tracklist.add(self.tracks) + + self.tl_tracks = self.core.tracklist.tl_tracks + self.unplayable_tl_track = self.tl_tracks[2] + + # TODO Test get_current_tl_track + + # TODO Test get_current_track + + # TODO Test state + + def test_play_selects_dummy1_backend(self): + self.core.playback.play(self.tl_tracks[0]) + + self.playback1.play.assert_called_once_with(self.tracks[0]) + self.assertFalse(self.playback2.play.called) + + def test_play_selects_dummy2_backend(self): + self.core.playback.play(self.tl_tracks[1]) + + self.assertFalse(self.playback1.play.called) + self.playback2.play.assert_called_once_with(self.tracks[1]) + + def test_play_skips_to_next_on_unplayable_track(self): + self.core.playback.play(self.unplayable_tl_track) + + self.playback1.play.assert_called_once_with(self.tracks[3]) + self.assertFalse(self.playback2.play.called) + + self.assertEqual( + self.core.playback.current_tl_track, self.tl_tracks[3]) + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_play_when_stopped_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[0]), + ]) + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_play_when_playing_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.play(self.tl_tracks[3]) + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[0], time_position=1000), + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[3]), + ]) + + def test_pause_selects_dummy1_backend(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.pause() + + self.playback1.pause.assert_called_once_with() + self.assertFalse(self.playback2.pause.called) + + def test_pause_selects_dummy2_backend(self): + self.core.playback.play(self.tl_tracks[1]) + self.core.playback.pause() + + self.assertFalse(self.playback1.pause.called) + self.playback2.pause.assert_called_once_with() + + def test_pause_changes_state_even_if_track_is_unplayable(self): + self.core.playback.current_tl_track = self.unplayable_tl_track + self.core.playback.pause() + + self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) + self.assertFalse(self.playback1.pause.called) + self.assertFalse(self.playback2.pause.called) + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_pause_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.pause() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='paused'), + mock.call( + 'track_playback_paused', + tl_track=self.tl_tracks[0], time_position=1000), + ]) + + def test_resume_selects_dummy1_backend(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.pause() + self.core.playback.resume() + + self.playback1.resume.assert_called_once_with() + self.assertFalse(self.playback2.resume.called) + + def test_resume_selects_dummy2_backend(self): + self.core.playback.play(self.tl_tracks[1]) + self.core.playback.pause() + self.core.playback.resume() + + self.assertFalse(self.playback1.resume.called) + self.playback2.resume.assert_called_once_with() + + def test_resume_does_nothing_if_track_is_unplayable(self): + self.core.playback.current_tl_track = self.unplayable_tl_track + self.core.playback.state = core.PlaybackState.PAUSED + self.core.playback.resume() + + self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) + self.assertFalse(self.playback1.resume.called) + self.assertFalse(self.playback2.resume.called) + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_resume_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.pause() + listener_mock.reset_mock() + + self.core.playback.resume() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='paused', new_state='playing'), + mock.call( + 'track_playback_resumed', + tl_track=self.tl_tracks[0], time_position=1000), + ]) + + def test_stop_selects_dummy1_backend(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.stop() + + self.playback1.stop.assert_called_once_with() + self.assertFalse(self.playback2.stop.called) + + def test_stop_selects_dummy2_backend(self): + self.core.playback.play(self.tl_tracks[1]) + self.core.playback.stop() + + self.assertFalse(self.playback1.stop.called) + self.playback2.stop.assert_called_once_with() + + def test_stop_changes_state_even_if_track_is_unplayable(self): + self.core.playback.current_tl_track = self.unplayable_tl_track + self.core.playback.state = core.PlaybackState.PAUSED + self.core.playback.stop() + + self.assertEqual(self.core.playback.state, core.PlaybackState.STOPPED) + self.assertFalse(self.playback1.stop.called) + self.assertFalse(self.playback2.stop.called) + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_stop_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.stop() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[0], time_position=1000), + ]) + + # TODO Test next() more + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_next_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.next() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[1]), + ]) + + # TODO Test previous() more + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_previous_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[1]) + listener_mock.reset_mock() + + self.core.playback.previous() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[1], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[0]), + ]) + + # TODO Test on_end_of_track() more + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_on_end_of_track_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.on_end_of_track() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[1]), + ]) + + def test_seek_selects_dummy1_backend(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.seek(10000) + + self.playback1.seek.assert_called_once_with(10000) + self.assertFalse(self.playback2.seek.called) + + def test_seek_selects_dummy2_backend(self): + self.core.playback.play(self.tl_tracks[1]) + self.core.playback.seek(10000) + + self.assertFalse(self.playback1.seek.called) + self.playback2.seek.assert_called_once_with(10000) + + def test_seek_fails_for_unplayable_track(self): + self.core.playback.current_tl_track = self.unplayable_tl_track + self.core.playback.state = core.PlaybackState.PLAYING + success = self.core.playback.seek(1000) + + self.assertFalse(success) + self.assertFalse(self.playback1.seek.called) + self.assertFalse(self.playback2.seek.called) + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_seek_emits_seeked_event(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.seek(1000) + + listener_mock.send.assert_called_once_with( + 'seeked', time_position=1000) + + def test_time_position_selects_dummy1_backend(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.seek(10000) + self.core.playback.time_position + + self.playback1.get_time_position.assert_called_once_with() + self.assertFalse(self.playback2.get_time_position.called) + + def test_time_position_selects_dummy2_backend(self): + self.core.playback.play(self.tl_tracks[1]) + self.core.playback.seek(10000) + self.core.playback.time_position + + self.assertFalse(self.playback1.get_time_position.called) + self.playback2.get_time_position.assert_called_once_with() + + def test_time_position_returns_0_if_track_is_unplayable(self): + self.core.playback.current_tl_track = self.unplayable_tl_track + + result = self.core.playback.time_position + + self.assertEqual(result, 0) + self.assertFalse(self.playback1.get_time_position.called) + self.assertFalse(self.playback2.get_time_position.called) + + # TODO Test on_tracklist_change + + # TODO Test volume + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_set_volume_emits_volume_changed_event(self, listener_mock): + self.core.playback.set_volume(10) + listener_mock.reset_mock() + + self.core.playback.set_volume(20) + + listener_mock.send.assert_called_once_with('volume_changed', volume=20) + + def test_mute(self): + self.assertEqual(self.core.playback.mute, False) + + self.core.playback.mute = True + + self.assertEqual(self.core.playback.mute, True) diff --git a/tests/core/playlists_test.py b/tests/core/test_playlists.py similarity index 97% rename from tests/core/playlists_test.py rename to tests/core/test_playlists.py index 01c2b881..ac1787fa 100644 --- a/tests/core/playlists_test.py +++ b/tests/core/test_playlists.py @@ -3,8 +3,7 @@ from __future__ import unicode_literals import mock import unittest -from mopidy.backends import base -from mopidy.core import Core +from mopidy import backend, core from mopidy.models import Playlist, Track @@ -12,12 +11,12 @@ class PlaylistsTest(unittest.TestCase): def setUp(self): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] - self.sp1 = mock.Mock(spec=base.BasePlaylistsProvider) + self.sp1 = mock.Mock(spec=backend.PlaylistsProvider) self.backend1.playlists = self.sp1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] - self.sp2 = mock.Mock(spec=base.BasePlaylistsProvider) + self.sp2 = mock.Mock(spec=backend.PlaylistsProvider) self.backend2.playlists = self.sp2 # A backend without the optional playlists provider @@ -34,7 +33,7 @@ class PlaylistsTest(unittest.TestCase): self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:b')]) self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] - self.core = Core(audio=None, backends=[ + self.core = core.Core(audio=None, backends=[ self.backend3, self.backend1, self.backend2]) def test_get_playlists_combines_result_from_backends(self): diff --git a/tests/core/tracklist_test.py b/tests/core/test_tracklist.py similarity index 90% rename from tests/core/tracklist_test.py rename to tests/core/test_tracklist.py index 596a20a6..80b4dd23 100644 --- a/tests/core/tracklist_test.py +++ b/tests/core/test_tracklist.py @@ -3,8 +3,7 @@ from __future__ import unicode_literals import mock import unittest -from mopidy.backends import base -from mopidy.core import Core +from mopidy import backend, core from mopidy.models import Track @@ -18,10 +17,10 @@ class TracklistTest(unittest.TestCase): self.backend = mock.Mock() self.backend.uri_schemes.get.return_value = ['dummy1'] - self.library = mock.Mock(spec=base.BaseLibraryProvider) + self.library = mock.Mock(spec=backend.LibraryProvider) self.backend.library = self.library - self.core = Core(audio=None, backends=[self.backend]) + self.core = core.Core(audio=None, backends=[self.backend]) self.tl_tracks = self.core.tracklist.add(self.tracks) def test_add_by_uri_looks_up_uri_in_library(self): @@ -72,4 +71,4 @@ class TracklistTest(unittest.TestCase): def test_filter_fails_if_values_is_a_string(self): self.assertRaises(ValueError, self.core.tracklist.filter, uri='a') - # TODO Extract tracklist tests from the base backend tests + # TODO Extract tracklist tests from the local backend tests diff --git a/tests/data/advanced_tag_cache b/tests/data/advanced_tag_cache deleted file mode 100644 index be299fb6..00000000 --- a/tests/data/advanced_tag_cache +++ /dev/null @@ -1,107 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -directory: subdir1 -begin: subdir1 -directory: subsubdir -begin: subdir1/subsubdir -songList begin -key: song8.mp3 -file: subdir1/subsubdir/song8.mp3 -Time: 4 -Artist: name -AlbumArtist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -key: song9.mp3 -file: subdir1/subsubdir/song9.mp3 -Time: 4 -Artist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -songList end -end: subdir1/subsubdir -songList begin -key: song4.mp3 -file: subdir1/song4.mp3 -Time: 4 -Artist: name -AlbumArtist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -key: song5.mp3 -file: subdir1/song5.mp3 -Time: 4 -Artist: name -AlbumArtist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -songList end -end: subdir1 -directory: subdir2 -begin: subdir2 -songList begin -key: song6.mp3 -file: subdir2/song6.mp3 -Time: 4 -Artist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -key: song7.mp3 -file: subdir2/song7.mp3 -Time: 4 -Artist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -songList end -end: subdir2 -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 4 -Artist: name -AlbumArtist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -key: song2.mp3 -file: /song2.mp3 -Time: 4 -Artist: name -AlbumArtist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -key: song3.mp3 -file: /song3.mp3 -Time: 4 -Artist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -songList end diff --git a/tests/data/albumartist_tag_cache b/tests/data/albumartist_tag_cache deleted file mode 100644 index 29942a75..00000000 --- a/tests/data/albumartist_tag_cache +++ /dev/null @@ -1,16 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 4 -Artist: name -Title: trackname -Album: albumname -AlbumArtist: albumartistname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -songList end diff --git a/tests/data/blank_tag_cache b/tests/data/blank_tag_cache deleted file mode 100644 index a6d33386..00000000 --- a/tests/data/blank_tag_cache +++ /dev/null @@ -1,10 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 4 -mtime: 1272319626 -songList end diff --git a/tests/data/comment-ext.m3u b/tests/data/comment-ext.m3u new file mode 100644 index 00000000..95983d06 --- /dev/null +++ b/tests/data/comment-ext.m3u @@ -0,0 +1,5 @@ +#EXTM3U +# test +#EXTINF:-1,song1 +# test +song1.mp3 diff --git a/tests/data/empty-ext.m3u b/tests/data/empty-ext.m3u new file mode 100644 index 00000000..fcd71879 --- /dev/null +++ b/tests/data/empty-ext.m3u @@ -0,0 +1 @@ +#EXTM3U diff --git a/tests/data/empty_tag_cache b/tests/data/empty_tag_cache deleted file mode 100644 index 84053d90..00000000 --- a/tests/data/empty_tag_cache +++ /dev/null @@ -1,6 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -songList begin -songList end diff --git a/tests/data/encoding-ext.m3u b/tests/data/encoding-ext.m3u new file mode 100644 index 00000000..1c59a322 --- /dev/null +++ b/tests/data/encoding-ext.m3u @@ -0,0 +1,3 @@ +#EXTM3U +#EXTINF:-1,æøå +æøå.mp3 diff --git a/tests/data/.blank.mp3 b/tests/data/find/.blank.mp3 similarity index 100% rename from tests/data/.blank.mp3 rename to tests/data/find/.blank.mp3 diff --git a/tests/data/.hidden/.gitignore b/tests/data/find/.hidden/.gitignore similarity index 100% rename from tests/data/.hidden/.gitignore rename to tests/data/find/.hidden/.gitignore diff --git a/tests/frontends/http/__init__.py b/tests/data/find/baz/file similarity index 100% rename from tests/frontends/http/__init__.py rename to tests/data/find/baz/file diff --git a/tests/data/find/foo/bar/file b/tests/data/find/foo/bar/file new file mode 100644 index 00000000..e69de29b diff --git a/tests/data/find/foo/file b/tests/data/find/foo/file new file mode 100644 index 00000000..e69de29b diff --git a/tests/data/library.json.gz b/tests/data/library.json.gz new file mode 100644 index 00000000..768b8282 Binary files /dev/null and b/tests/data/library.json.gz differ diff --git a/tests/data/library_tag_cache b/tests/data/library_tag_cache deleted file mode 100644 index 6d00cf97..00000000 --- a/tests/data/library_tag_cache +++ /dev/null @@ -1,56 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -songList begin -key: key1 -file: /path1 -Artist: artist1 -AlbumArtist: artist1 -Title: track1 -Album: album1 -Date: 2001-02-03 -Track: 1 -Time: 4 -key: key2 -file: /path2 -Artist: artist2 -AlbumArtist: artist2 -Title: track2 -Album: album2 -Date: 2002 -Track: 2 -Time: 4 -key: key3 -file: /path3 -Artist: artist4 -AlbumArtist: artist3 -Title: track3 -Album: album3 -Date: 2003 -Track: 3 -Time: 4 -key: key4 -file: /path4 -Artist: artist3 -Title: track4 -Album: album4 -Date: 2004 -Track: 4 -Comment: This is a fantastic track -Time: 60 -key: key5 -file: /path5 -Composer: artist5 -Title: track5 -Album: album4 -Genre: genre1 -Time: 4 -key: key6 -file: /path6 -Performer: artist6 -Title: track6 -Album: album4 -Genre: genre2 -Time: 4 -songList end diff --git a/tests/data/musicbrainz_tag_cache b/tests/data/musicbrainz_tag_cache deleted file mode 100644 index 0e9dca46..00000000 --- a/tests/data/musicbrainz_tag_cache +++ /dev/null @@ -1,20 +0,0 @@ -info_begin -mpd_version: 0.16.0 -fs_charset: UTF-8 -info_end -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 4 -Artist: name -Title: trackname -Album: albumname -AlbumArtist: albumartistname -Track: 1/2 -Date: 2006 -MUSICBRAINZ_ALBUMID: cb5f1603-d314-4c9c-91e5-e295cfb125d2 -MUSICBRAINZ_ALBUMARTISTID: 7364dea6-ca9a-48e3-be01-b44ad0d19897 -MUSICBRAINZ_ARTISTID: 7364dea6-ca9a-48e3-be01-b44ad0d19897 -MUSICBRAINZ_TRACKID: 90488461-8c1f-4a4e-826b-4c6dc70801f0 -mtime: 1272319626 -songList end diff --git a/tests/data/one-ext.m3u b/tests/data/one-ext.m3u new file mode 100644 index 00000000..7e94d5e9 --- /dev/null +++ b/tests/data/one-ext.m3u @@ -0,0 +1,3 @@ +#EXTM3U +#EXTINF:-1,song1 +song1.mp3 diff --git a/tests/data/scanner/advanced_cache b/tests/data/scanner/advanced_cache deleted file mode 100644 index 60f7fca6..00000000 --- a/tests/data/scanner/advanced_cache +++ /dev/null @@ -1,81 +0,0 @@ -info_begin -mpd_version: 0.15.4 -fs_charset: UTF-8 -info_end -directory: subdir1 -mtime: 1288121499 -begin: subdir1 -songList begin -key: song4.mp3 -file: subdir1/song4.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -key: song5.mp3 -file: subdir1/song5.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -songList end -end: subdir1 -directory: subdir2 -mtime: 1288121499 -begin: subdir2 -songList begin -key: song6.mp3 -file: subdir2/song6.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -key: song7.mp3 -file: subdir2/song7.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -songList end -end: subdir2 -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -key: song2.mp3 -file: /song2.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -key: song3.mp3 -file: /song3.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -songList end diff --git a/tests/data/scanner/empty_cache b/tests/data/scanner/empty_cache deleted file mode 100644 index 3c466a32..00000000 --- a/tests/data/scanner/empty_cache +++ /dev/null @@ -1,6 +0,0 @@ -info_begin -mpd_version: 0.15.4 -fs_charset: UTF-8 -info_end -songList begin -songList end diff --git a/tests/data/scanner/simple_cache b/tests/data/scanner/simple_cache deleted file mode 100644 index db11c324..00000000 --- a/tests/data/scanner/simple_cache +++ /dev/null @@ -1,15 +0,0 @@ -info_begin -mpd_version: 0.15.4 -fs_charset: UTF-8 -info_end -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -songList end diff --git a/tests/data/simple_tag_cache b/tests/data/simple_tag_cache deleted file mode 100644 index 07a474b3..00000000 --- a/tests/data/simple_tag_cache +++ /dev/null @@ -1,16 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 4 -Artist: name -AlbumArtist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -songList end diff --git a/tests/data/two-ext.m3u b/tests/data/two-ext.m3u new file mode 100644 index 00000000..c2bf3e75 --- /dev/null +++ b/tests/data/two-ext.m3u @@ -0,0 +1,5 @@ +#EXTM3U +#EXTINF:-1,song1 +song1.mp3 +#EXTINF:60,song2 +song2.mp3 diff --git a/tests/data/utf8_tag_cache b/tests/data/utf8_tag_cache deleted file mode 100644 index 83fbcad4..00000000 --- a/tests/data/utf8_tag_cache +++ /dev/null @@ -1,18 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 4 -Artist: æøå -AlbumArtist: æøå -Composer: æøå -Performer: æøå -Title: æøå -Album: æøå -Genre: æøå -Comment: æøå&^`ൂ㔶 -mtime: 1272319626 -songList end diff --git a/tests/frontends/__init__.py b/tests/frontends/__init__.py deleted file mode 100644 index baffc488..00000000 --- a/tests/frontends/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import unicode_literals diff --git a/tests/frontends/mpd/__init__.py b/tests/frontends/mpd/__init__.py deleted file mode 100644 index baffc488..00000000 --- a/tests/frontends/mpd/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import unicode_literals diff --git a/tests/frontends/mpd/translator_test.py b/tests/frontends/mpd/translator_test.py deleted file mode 100644 index a6a2eaa9..00000000 --- a/tests/frontends/mpd/translator_test.py +++ /dev/null @@ -1,358 +0,0 @@ -from __future__ import unicode_literals - -import datetime -import os -import unittest - -from mopidy.utils.path import mtime, uri_to_path -from mopidy.frontends.mpd import translator, protocol -from mopidy.models import Album, Artist, TlTrack, Playlist, Track - - -class TrackMpdFormatTest(unittest.TestCase): - track = Track( - uri='a uri', - artists=[Artist(name='an artist')], - name='a name', - album=Album(name='an album', num_tracks=13, - artists=[Artist(name='an other artist')]), - track_no=7, - composers=[Artist(name='a composer')], - performers=[Artist(name='a performer')], - genre='a genre', - date=datetime.date(1977, 1, 1), - disc_no='1', - comment='a comment', - length=137000, - ) - - def setUp(self): - self.media_dir = '/dir/subdir' - mtime.set_fake_time(1234567) - - def tearDown(self): - mtime.undo_fake() - - def test_track_to_mpd_format_for_empty_track(self): - result = translator.track_to_mpd_format(Track()) - self.assertIn(('file', ''), result) - self.assertIn(('Time', 0), result) - self.assertIn(('Artist', ''), result) - self.assertIn(('Title', ''), result) - self.assertIn(('Album', ''), result) - self.assertIn(('Track', 0), result) - self.assertNotIn(('Date', ''), result) - self.assertEqual(len(result), 6) - - def test_track_to_mpd_format_with_position(self): - result = translator.track_to_mpd_format(Track(), position=1) - self.assertNotIn(('Pos', 1), result) - - def test_track_to_mpd_format_with_tlid(self): - result = translator.track_to_mpd_format(TlTrack(1, Track())) - self.assertNotIn(('Id', 1), result) - - def test_track_to_mpd_format_with_position_and_tlid(self): - result = translator.track_to_mpd_format( - TlTrack(2, Track()), position=1) - self.assertIn(('Pos', 1), result) - self.assertIn(('Id', 2), result) - - def test_track_to_mpd_format_for_nonempty_track(self): - result = translator.track_to_mpd_format( - TlTrack(122, self.track), position=9) - self.assertIn(('file', 'a uri'), result) - self.assertIn(('Time', 137), result) - self.assertIn(('Artist', 'an artist'), result) - self.assertIn(('Title', 'a name'), result) - self.assertIn(('Album', 'an album'), result) - self.assertIn(('AlbumArtist', 'an other artist'), result) - self.assertIn(('Composer', 'a composer'), result) - 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(('Comment', 'a comment'), result) - self.assertIn(('Pos', 9), result) - self.assertIn(('Id', 122), result) - self.assertEqual(len(result), 15) - - def test_track_to_mpd_format_musicbrainz_trackid(self): - track = self.track.copy(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) - 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) - 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]) - result = translator.track_to_mpd_format(track) - self.assertIn(('MUSICBRAINZ_ARTISTID', 'foo'), result) - - def test_artists_to_mpd_format(self): - artists = [Artist(name='ABBA'), Artist(name='Beatles')] - translated = translator.artists_to_mpd_format(artists) - self.assertEqual(translated, 'ABBA, Beatles') - - def test_artists_to_mpd_format_artist_with_no_name(self): - artists = [Artist(name=None)] - translated = translator.artists_to_mpd_format(artists) - self.assertEqual(translated, '') - - -class PlaylistMpdFormatTest(unittest.TestCase): - def test_mpd_format(self): - playlist = Playlist(tracks=[ - Track(track_no=1), Track(track_no=2), Track(track_no=3)]) - result = translator.playlist_to_mpd_format(playlist) - self.assertEqual(len(result), 3) - - def test_mpd_format_with_range(self): - playlist = Playlist(tracks=[ - Track(track_no=1), Track(track_no=2), Track(track_no=3)]) - result = translator.playlist_to_mpd_format(playlist, 1, 2) - self.assertEqual(len(result), 1) - self.assertEqual(dict(result[0])['Track'], 2) - - -class TracksToTagCacheFormatTest(unittest.TestCase): - def setUp(self): - self.media_dir = '/dir/subdir' - mtime.set_fake_time(1234567) - - def tearDown(self): - mtime.undo_fake() - - def translate(self, track): - base_path = self.media_dir.encode('utf-8') - result = dict(translator.track_to_mpd_format(track)) - result['file'] = uri_to_path(result['file'])[len(base_path) + 1:] - result['key'] = os.path.basename(result['file']) - result['mtime'] = mtime('') - return translator.order_mpd_track_info(result.items()) - - def consume_headers(self, result): - self.assertEqual(('info_begin',), result[0]) - self.assertEqual(('mpd_version', protocol.VERSION), result[1]) - self.assertEqual(('fs_charset', protocol.ENCODING), result[2]) - self.assertEqual(('info_end',), result[3]) - return result[4:] - - def consume_song_list(self, result): - self.assertEqual(('songList begin',), result[0]) - for i, row in enumerate(result): - if row == ('songList end',): - return result[1:i], result[i + 1:] - self.fail("Couldn't find songList end in result") - - def consume_directory(self, result): - self.assertEqual('directory', result[0][0]) - self.assertEqual(('mtime', mtime('.')), result[1]) - self.assertEqual(('begin', os.path.split(result[0][1])[1]), result[2]) - directory = result[2][1] - for i, row in enumerate(result): - if row == ('end', directory): - return result[3:i], result[i + 1:] - self.fail("Couldn't find end %s in result" % directory) - - def test_empty_tag_cache_has_header(self): - result = translator.tracks_to_tag_cache_format([], self.media_dir) - result = self.consume_headers(result) - - def test_empty_tag_cache_has_song_list(self): - result = translator.tracks_to_tag_cache_format([], self.media_dir) - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assertEqual(len(song_list), 0) - self.assertEqual(len(result), 0) - - def test_tag_cache_has_header(self): - track = Track(uri='file:///dir/subdir/song.mp3') - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - result = self.consume_headers(result) - - def test_tag_cache_has_song_list(self): - track = Track(uri='file:///dir/subdir/song.mp3') - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assert_(song_list) - self.assertEqual(len(result), 0) - - def test_tag_cache_has_formated_track(self): - track = Track(uri='file:///dir/subdir/song.mp3') - formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assertEqual(formated, song_list) - self.assertEqual(len(result), 0) - - def test_tag_cache_has_formated_track_with_key_and_mtime(self): - track = Track(uri='file:///dir/subdir/song.mp3') - formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assertEqual(formated, song_list) - self.assertEqual(len(result), 0) - - def test_tag_cache_supports_directories(self): - track = Track(uri='file:///dir/subdir/folder/song.mp3') - formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - dir_data, result = self.consume_directory(result) - song_list, result = self.consume_song_list(result) - self.assertEqual(len(song_list), 0) - self.assertEqual(len(result), 0) - - song_list, result = self.consume_song_list(dir_data) - self.assertEqual(len(result), 0) - self.assertEqual(formated, song_list) - - def test_tag_cache_diretory_header_is_right(self): - track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - dir_data, result = self.consume_directory(result) - - self.assertEqual(('directory', 'folder/sub'), dir_data[0]) - self.assertEqual(('mtime', mtime('.')), dir_data[1]) - self.assertEqual(('begin', 'sub'), dir_data[2]) - - def test_tag_cache_suports_sub_directories(self): - track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') - formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - - dir_data, result = self.consume_directory(result) - song_list, result = self.consume_song_list(result) - self.assertEqual(len(song_list), 0) - self.assertEqual(len(result), 0) - - dir_data, result = self.consume_directory(dir_data) - song_list, result = self.consume_song_list(result) - self.assertEqual(len(result), 0) - self.assertEqual(len(song_list), 0) - - song_list, result = self.consume_song_list(dir_data) - self.assertEqual(len(result), 0) - self.assertEqual(formated, song_list) - - def test_tag_cache_supports_multiple_tracks(self): - tracks = [ - Track(uri='file:///dir/subdir/song1.mp3'), - Track(uri='file:///dir/subdir/song2.mp3'), - ] - - formated = [] - formated.extend(self.translate(tracks[0])) - formated.extend(self.translate(tracks[1])) - - result = translator.tracks_to_tag_cache_format(tracks, self.media_dir) - - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assertEqual(formated, song_list) - self.assertEqual(len(result), 0) - - def test_tag_cache_supports_multiple_tracks_in_dirs(self): - tracks = [ - Track(uri='file:///dir/subdir/song1.mp3'), - Track(uri='file:///dir/subdir/folder/song2.mp3'), - ] - - formated = [] - formated.append(self.translate(tracks[0])) - formated.append(self.translate(tracks[1])) - - result = translator.tracks_to_tag_cache_format(tracks, self.media_dir) - - result = self.consume_headers(result) - dir_data, result = self.consume_directory(result) - song_list, song_result = self.consume_song_list(dir_data) - - self.assertEqual(formated[1], song_list) - self.assertEqual(len(song_result), 0) - - song_list, result = self.consume_song_list(result) - self.assertEqual(len(result), 0) - self.assertEqual(formated[0], song_list) - - -class TracksToDirectoryTreeTest(unittest.TestCase): - def setUp(self): - self.media_dir = '/root' - - def test_no_tracks_gives_emtpy_tree(self): - tree = translator.tracks_to_directory_tree([], self.media_dir) - self.assertEqual(tree, ({}, [])) - - def test_top_level_files(self): - tracks = [ - Track(uri='file:///root/file1.mp3'), - Track(uri='file:///root/file2.mp3'), - Track(uri='file:///root/file3.mp3'), - ] - tree = translator.tracks_to_directory_tree(tracks, self.media_dir) - self.assertEqual(tree, ({}, tracks)) - - def test_single_file_in_subdir(self): - tracks = [Track(uri='file:///root/dir/file1.mp3')] - tree = translator.tracks_to_directory_tree(tracks, self.media_dir) - expected = ({'dir': ({}, tracks)}, []) - self.assertEqual(tree, expected) - - def test_single_file_in_sub_subdir(self): - tracks = [Track(uri='file:///root/dir1/dir2/file1.mp3')] - tree = translator.tracks_to_directory_tree(tracks, self.media_dir) - expected = ({'dir1': ({'dir1/dir2': ({}, tracks)}, [])}, []) - self.assertEqual(tree, expected) - - def test_complex_file_structure(self): - tracks = [ - Track(uri='file:///root/file1.mp3'), - Track(uri='file:///root/dir1/file2.mp3'), - Track(uri='file:///root/dir1/file3.mp3'), - Track(uri='file:///root/dir2/file4.mp3'), - Track(uri='file:///root/dir2/sub/file5.mp3'), - ] - tree = translator.tracks_to_directory_tree(tracks, self.media_dir) - expected = ( - { - 'dir1': ({}, [tracks[1], tracks[2]]), - 'dir2': ( - { - 'dir2/sub': ({}, [tracks[4]]) - }, - [tracks[3]] - ), - }, - [tracks[0]] - ) - self.assertEqual(tree, expected) diff --git a/tests/http/__init__.py b/tests/http/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/frontends/http/events_test.py b/tests/http/test_events.py similarity index 97% rename from tests/frontends/http/events_test.py rename to tests/http/test_events.py index 5150db9b..dbfa8413 100644 --- a/tests/frontends/http/events_test.py +++ b/tests/http/test_events.py @@ -15,7 +15,7 @@ except ImportError: ws4py = False if cherrypy and ws4py: - from mopidy.frontends.http import actor + from mopidy.http import actor @unittest.skipUnless(cherrypy, 'cherrypy not found') diff --git a/tests/backends/local/__init__.py b/tests/local/__init__.py similarity index 100% rename from tests/backends/local/__init__.py rename to tests/local/__init__.py diff --git a/tests/backends/local/events_test.py b/tests/local/test_events.py similarity index 78% rename from tests/backends/local/events_test.py rename to tests/local/test_events.py index 725c580f..f0fd0959 100644 --- a/tests/backends/local/events_test.py +++ b/tests/local/test_events.py @@ -5,20 +5,20 @@ import unittest import mock import pykka -from mopidy import core, audio -from mopidy.backends import listener -from mopidy.backends.local import actor +from mopidy import audio, backend, core +from mopidy.local import actor from tests import path_to_data_dir -@mock.patch.object(listener.BackendListener, 'send') +@mock.patch.object(backend.BackendListener, 'send') class LocalBackendEventsTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', - 'tag_cache_file': path_to_data_dir('empty_tag_cache'), + 'library': 'json', } } diff --git a/tests/local/test_json.py b/tests/local/test_json.py new file mode 100644 index 00000000..3ccb6e6d --- /dev/null +++ b/tests/local/test_json.py @@ -0,0 +1,40 @@ +from __future__ import unicode_literals + +import unittest + +from mopidy.local import json +from mopidy.models import Ref + + +class BrowseCacheTest(unittest.TestCase): + maxDiff = None + + def setUp(self): + self.uris = ['local:track:foo/bar/song1', + 'local:track:foo/bar/song2', + 'local:track:foo/baz/song3', + 'local:track:foo/song4', + 'local:track:song5'] + self.cache = json._BrowseCache(self.uris) + + def test_lookup_root(self): + expected = [Ref.directory(uri='local:directory:foo', name='foo'), + Ref.track(uri='local:track:song5', name='song5')] + self.assertItemsEqual(expected, self.cache.lookup('local:directory')) + + def test_lookup_foo(self): + expected = [Ref.directory(uri='local:directory:foo/bar', name='bar'), + Ref.directory(uri='local:directory:foo/baz', name='baz'), + Ref.track(uri=self.uris[3], name='song4')] + result = self.cache.lookup('local:directory:foo') + self.assertItemsEqual(expected, result) + + def test_lookup_foo_bar(self): + expected = [Ref.track(uri=self.uris[0], name='song1'), + Ref.track(uri=self.uris[1], name='song2')] + self.assertItemsEqual( + expected, self.cache.lookup('local:directory:foo/bar')) + + def test_lookup_foo_baz(self): + result = self.cache.lookup('local:directory:foo/unknown') + self.assertItemsEqual([], result) diff --git a/tests/backends/local/library_test.py b/tests/local/test_library.py similarity index 93% rename from tests/backends/local/library_test.py rename to tests/local/test_library.py index c38fd74f..575f1fb8 100644 --- a/tests/backends/local/library_test.py +++ b/tests/local/test_library.py @@ -1,12 +1,14 @@ from __future__ import unicode_literals +import os +import shutil import tempfile import unittest import pykka from mopidy import core -from mopidy.backends.local import actor +from mopidy.local import actor, json from mopidy.models import Track, Album, Artist from tests import path_to_data_dir @@ -22,6 +24,7 @@ class LocalLibraryProviderTest(unittest.TestCase): Artist(name='artist4'), Artist(name='artist5'), Artist(name='artist6'), + Artist(), ] albums = [ @@ -29,6 +32,7 @@ class LocalLibraryProviderTest(unittest.TestCase): Album(name='album2', artists=[artists[1]]), Album(name='album3', artists=[artists[2]]), Album(name='album4'), + Album(artists=[artists[-1]]), ] tracks = [ @@ -55,17 +59,20 @@ class LocalLibraryProviderTest(unittest.TestCase): Track( uri='local:track:path6', name='track6', genre='genre2', album=albums[3], length=4000, performers=[artists[5]]), + Track(uri='local:track:nameless', album=albums[-1]), ] config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', - 'tag_cache_file': path_to_data_dir('library_tag_cache'), - } + 'library': 'json', + }, } def setUp(self): + actor.LocalBackend.libraries = [json.JsonLibrary] self.backend = actor.LocalBackend.start( config=self.config, audio=None).proxy() self.core = core.Core(backends=[self.backend]) @@ -73,6 +80,7 @@ class LocalLibraryProviderTest(unittest.TestCase): def tearDown(self): pykka.ActorRegistry.stop_all() + actor.LocalBackend.libraries = [] def test_refresh(self): self.library.refresh() @@ -85,27 +93,33 @@ class LocalLibraryProviderTest(unittest.TestCase): # Verifies that https://github.com/mopidy/mopidy/issues/500 # has been fixed. - tag_cache = tempfile.NamedTemporaryFile() - with open(self.config['local']['tag_cache_file']) as fh: - tag_cache.write(fh.read()) - tag_cache.flush() + tmpdir = tempfile.mkdtemp() + try: + tmplib = os.path.join(tmpdir, 'library.json.gz') + shutil.copy(path_to_data_dir('library.json.gz'), tmplib) - config = {'local': self.config['local'].copy()} - config['local']['tag_cache_file'] = tag_cache.name - backend = actor.LocalBackend(config=config, audio=None) + config = {'local': self.config['local'].copy()} + config['local']['data_dir'] = tmpdir + backend = actor.LocalBackend(config=config, audio=None) - # Sanity check that value is in tag cache - result = backend.library.lookup(self.tracks[0].uri) - self.assertEqual(result, self.tracks[0:1]) + # Sanity check that value is in the library + result = backend.library.lookup(self.tracks[0].uri) + self.assertEqual(result, self.tracks[0:1]) - # Clear tag cache and refresh - tag_cache.seek(0) - tag_cache.truncate() - backend.library.refresh() + # Clear and refresh. + open(tmplib, 'w').close() + backend.library.refresh() - # Now it should be gone. - result = backend.library.lookup(self.tracks[0].uri) - self.assertEqual(result, []) + # Now it should be gone. + result = backend.library.lookup(self.tracks[0].uri) + self.assertEqual(result, []) + + finally: + shutil.rmtree(tmpdir) + + @unittest.SkipTest + def test_browse(self): + pass # TODO def test_lookup(self): tracks = self.library.lookup(self.tracks[0].uri) @@ -115,6 +129,7 @@ class LocalLibraryProviderTest(unittest.TestCase): tracks = self.library.lookup('fake uri') self.assertEqual(tracks, []) + # TODO: move to search_test module def test_find_exact_no_hits(self): result = self.library.find_exact(track_name=['unknown track']) self.assertEqual(list(result[0].tracks), []) diff --git a/tests/backends/local/playback_test.py b/tests/local/test_playback.py similarity index 99% rename from tests/backends/local/playback_test.py rename to tests/local/test_playback.py index 8fbc4415..4aae8b04 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/local/test_playback.py @@ -7,12 +7,12 @@ import unittest import pykka from mopidy import audio, core -from mopidy.backends.local import actor from mopidy.core import PlaybackState +from mopidy.local import actor from mopidy.models import Track from tests import path_to_data_dir -from tests.backends.local import generate_song, populate_tracklist +from tests.local import generate_song, populate_tracklist # TODO Test 'playlist repeat', e.g. repeat=1,single=0 @@ -22,8 +22,9 @@ class LocalPlaybackProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', - 'tag_cache_file': path_to_data_dir('empty_tag_cache'), + 'library': 'json', } } diff --git a/tests/backends/local/playlists_test.py b/tests/local/test_playlists.py similarity index 91% rename from tests/backends/local/playlists_test.py rename to tests/local/test_playlists.py index c8fedd62..f054ffc9 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/local/test_playlists.py @@ -8,11 +8,11 @@ import unittest import pykka from mopidy import audio, core -from mopidy.backends.local import actor +from mopidy.local import actor from mopidy.models import Playlist, Track from tests import path_to_data_dir -from tests.backends.local import generate_song +from tests.local import generate_song class LocalPlaylistsProviderTest(unittest.TestCase): @@ -20,7 +20,8 @@ class LocalPlaylistsProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), - 'tag_cache_file': path_to_data_dir('library_tag_cache'), + 'data_dir': path_to_data_dir(''), + 'library': 'json', } } @@ -100,6 +101,18 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assertEqual(track.uri, contents.strip()) + def test_extended_playlist_contents_is_written_to_disk(self): + track = Track(uri=generate_song(1), name='Test', length=60000) + playlist = self.core.playlists.create('test') + playlist_path = os.path.join(self.playlists_dir, 'test.m3u') + playlist = playlist.copy(tracks=[track]) + playlist = self.core.playlists.save(playlist) + + with open(playlist_path) as playlist_file: + contents = playlist_file.read().splitlines() + + self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri]) + def test_playlists_are_loaded_at_startup(self): track = Track(uri='local:track:path2') playlist = self.core.playlists.create('test') diff --git a/tests/backends/local/tracklist_test.py b/tests/local/test_tracklist.py similarity index 98% rename from tests/backends/local/tracklist_test.py rename to tests/local/test_tracklist.py index ac135a25..7717f1a5 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/local/test_tracklist.py @@ -6,20 +6,21 @@ import unittest import pykka from mopidy import audio, core -from mopidy.backends.local import actor from mopidy.core import PlaybackState +from mopidy.local import actor from mopidy.models import Playlist, TlTrack, Track from tests import path_to_data_dir -from tests.backends.local import generate_song, populate_tracklist +from tests.local import generate_song, populate_tracklist class LocalTracklistProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', - 'tag_cache_file': path_to_data_dir('empty_tag_cache'), + 'library': 'json', } } tracks = [ diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py new file mode 100644 index 00000000..b7ffd5cf --- /dev/null +++ b/tests/local/test_translator.py @@ -0,0 +1,117 @@ +# encoding: utf-8 + +from __future__ import unicode_literals + +import os +import tempfile +import unittest + +from mopidy.local.translator import parse_m3u +from mopidy.models import Track +from mopidy.utils.path import path_to_uri + +from tests import path_to_data_dir + +data_dir = path_to_data_dir('') +song1_path = path_to_data_dir('song1.mp3') +song2_path = path_to_data_dir('song2.mp3') +encoded_path = path_to_data_dir('æøå.mp3') +song1_uri = path_to_uri(song1_path) +song2_uri = path_to_uri(song2_path) +encoded_uri = path_to_uri(encoded_path) +song1_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='æøå') + + +# FIXME use mock instead of tempfile.NamedTemporaryFile + +class M3UToUriTest(unittest.TestCase): + def test_empty_file(self): + tracks = parse_m3u(path_to_data_dir('empty.m3u'), data_dir) + self.assertEqual([], tracks) + + def test_basic_file(self): + tracks = parse_m3u(path_to_data_dir('one.m3u'), data_dir) + self.assertEqual([song1_track], tracks) + + def test_file_with_comment(self): + tracks = parse_m3u(path_to_data_dir('comment.m3u'), data_dir) + self.assertEqual([song1_track], tracks) + + def test_file_is_relative_to_correct_dir(self): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write('song1.mp3') + try: + tracks = parse_m3u(tmp.name, data_dir) + self.assertEqual([song1_track], tracks) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) + + def test_file_with_absolute_files(self): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write(song1_path) + try: + tracks = parse_m3u(tmp.name, data_dir) + self.assertEqual([song1_track], tracks) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) + + def test_file_with_multiple_absolute_files(self): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write(song1_path + '\n') + tmp.write('# comment \n') + tmp.write(song2_path) + try: + tracks = parse_m3u(tmp.name, data_dir) + self.assertEqual([song1_track, song2_track], tracks) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) + + def test_file_with_uri(self): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write(song1_uri) + try: + tracks = parse_m3u(tmp.name, data_dir) + self.assertEqual([song1_track], tracks) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) + + def test_encoding_is_latin1(self): + tracks = parse_m3u(path_to_data_dir('encoding.m3u'), data_dir) + self.assertEqual([encoded_track], tracks) + + def test_open_missing_file(self): + tracks = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir) + self.assertEqual([], tracks) + + def test_empty_ext_file(self): + tracks = parse_m3u(path_to_data_dir('empty-ext.m3u'), data_dir) + self.assertEqual([], tracks) + + def test_basic_ext_file(self): + tracks = parse_m3u(path_to_data_dir('one-ext.m3u'), data_dir) + self.assertEqual([song1_ext_track], tracks) + + def test_multi_ext_file(self): + tracks = parse_m3u(path_to_data_dir('two-ext.m3u'), data_dir) + self.assertEqual([song1_ext_track, song2_ext_track], tracks) + + def test_ext_file_with_comment(self): + tracks = parse_m3u(path_to_data_dir('comment-ext.m3u'), data_dir) + self.assertEqual([song1_ext_track], tracks) + + def test_ext_encoding_is_latin1(self): + tracks = parse_m3u(path_to_data_dir('encoding-ext.m3u'), data_dir) + self.assertEqual([encoded_ext_track], tracks) + + +class URItoM3UTest(unittest.TestCase): + pass diff --git a/tests/backends/__init__.py b/tests/mpd/__init__.py similarity index 100% rename from tests/backends/__init__.py rename to tests/mpd/__init__.py diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py similarity index 96% rename from tests/frontends/mpd/protocol/__init__.py rename to tests/mpd/protocol/__init__.py index aa9a5a6d..97b73b7a 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -6,8 +6,8 @@ import unittest import pykka from mopidy import core -from mopidy.backends import dummy -from mopidy.frontends.mpd import session +from mopidy.backend import dummy +from mopidy.mpd import session class MockConnection(mock.Mock): diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/mpd/protocol/test_audio_output.py similarity index 97% rename from tests/frontends/mpd/protocol/audio_output_test.py rename to tests/mpd/protocol/test_audio_output.py index 4871f169..643682ef 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from tests.frontends.mpd import protocol +from tests.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/authentication_test.py b/tests/mpd/protocol/test_authentication.py similarity index 98% rename from tests/frontends/mpd/protocol/authentication_test.py rename to tests/mpd/protocol/test_authentication.py index 2597ddef..6a39ba81 100644 --- a/tests/frontends/mpd/protocol/authentication_test.py +++ b/tests/mpd/protocol/test_authentication.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from tests.frontends.mpd import protocol +from tests.mpd import protocol class AuthenticationActiveTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/channels_test.py b/tests/mpd/protocol/test_channels.py similarity index 54% rename from tests/frontends/mpd/protocol/channels_test.py rename to tests/mpd/protocol/test_channels.py index 86cf8197..be3b96a8 100644 --- a/tests/frontends/mpd/protocol/channels_test.py +++ b/tests/mpd/protocol/test_channels.py @@ -1,25 +1,25 @@ from __future__ import unicode_literals -from tests.frontends.mpd import protocol +from tests.mpd import protocol class ChannelsHandlerTest(protocol.BaseTestCase): def test_subscribe(self): self.sendRequest('subscribe "topic"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {subscribe} Not implemented') def test_unsubscribe(self): self.sendRequest('unsubscribe "topic"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {unsubscribe} Not implemented') def test_channels(self): self.sendRequest('channels') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {channels} Not implemented') def test_readmessages(self): self.sendRequest('readmessages') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {readmessages} Not implemented') def test_sendmessage(self): self.sendRequest('sendmessage "topic" "a message"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sendmessage} Not implemented') diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/mpd/protocol/test_command_list.py similarity index 98% rename from tests/frontends/mpd/protocol/command_list_test.py rename to tests/mpd/protocol/test_command_list.py index 222dcb61..9d66bd5d 100644 --- a/tests/frontends/mpd/protocol/command_list_test.py +++ b/tests/mpd/protocol/test_command_list.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from tests.frontends.mpd import protocol +from tests.mpd import protocol class CommandListsTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/connection_test.py b/tests/mpd/protocol/test_connection.py similarity index 81% rename from tests/frontends/mpd/protocol/connection_test.py rename to tests/mpd/protocol/test_connection.py index 01deb7a7..34cce6a0 100644 --- a/tests/frontends/mpd/protocol/connection_test.py +++ b/tests/mpd/protocol/test_connection.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from mock import patch -from tests.frontends.mpd import protocol +from tests.mpd import protocol class ConnectionHandlerTest(protocol.BaseTestCase): @@ -14,10 +14,10 @@ class ConnectionHandlerTest(protocol.BaseTestCase): def test_empty_request(self): self.sendRequest('') - self.assertEqualResponse('OK') + self.assertEqualResponse('ACK [5@0] {} No command given') self.sendRequest(' ') - self.assertEqualResponse('OK') + self.assertEqualResponse('ACK [5@0] {} No command given') def test_kill(self): self.sendRequest('kill') diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/mpd/protocol/test_current_playlist.py similarity index 92% rename from tests/frontends/mpd/protocol/current_playlist_test.py rename to tests/mpd/protocol/test_current_playlist.py index fc4640b1..e2db8b05 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals -from mopidy.models import Track +from mopidy.models import Ref, Track -from tests.frontends.mpd import protocol +from tests.mpd import protocol class CurrentPlaylistHandlerTest(protocol.BaseTestCase): @@ -24,9 +24,36 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse( 'ACK [50@0] {add} directory or file not found') - def test_add_with_empty_uri_should_add_all_known_tracks_and_ok(self): + def test_add_with_empty_uri_should_not_add_anything_and_ok(self): + self.backend.library.dummy_library = [Track(uri='dummy:/a', name='a')] + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} + self.sendRequest('add ""') - # TODO check that we add all tracks (we currently don't) + self.assertEqual(len(self.core.tracklist.tracks.get()), 0) + self.assertInResponse('OK') + + def test_add_with_library_should_recurse(self): + tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/foo/b', name='b')] + + self.backend.library.dummy_library = tracks + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')], + 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} + + self.sendRequest('add "/dummy"') + self.assertEqual(self.core.tracklist.tracks.get(), tracks) + self.assertInResponse('OK') + + def test_add_root_should_not_add_anything_and_ok(self): + self.backend.library.dummy_library = [Track(uri='dummy:/a', name='a')] + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} + + self.sendRequest('add "/"') + self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') def test_addid_without_songpos(self): @@ -226,7 +253,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistfind(self): self.sendRequest('playlistfind "tag" "needle"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistfind} Not implemented') def test_playlistfind_by_filename_not_in_tracklist(self): self.sendRequest('playlistfind "filename" "file:///dev/null"') @@ -364,11 +391,11 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistsearch(self): self.sendRequest('playlistsearch "any" "needle"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') def test_playlistsearch_without_quotes(self): self.sendRequest('playlistsearch any "needle"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') def test_plchanges_with_lower_version_returns_changes(self): self.core.tracklist.add( diff --git a/tests/frontends/mpd/protocol/idle_test.py b/tests/mpd/protocol/test_idle.py similarity index 98% rename from tests/frontends/mpd/protocol/idle_test.py rename to tests/mpd/protocol/test_idle.py index e6910988..cc937119 100644 --- a/tests/frontends/mpd/protocol/idle_test.py +++ b/tests/mpd/protocol/test_idle.py @@ -2,9 +2,9 @@ from __future__ import unicode_literals from mock import patch -from mopidy.frontends.mpd.protocol.status import SUBSYSTEMS +from mopidy.mpd.protocol.status import SUBSYSTEMS -from tests.frontends.mpd import protocol +from tests.mpd import protocol class IdleHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/mpd/protocol/test_music_db.py similarity index 80% rename from tests/frontends/mpd/protocol/music_db_test.py rename to tests/mpd/protocol/test_music_db.py index 52a7a390..8d74fb95 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/mpd/protocol/test_music_db.py @@ -1,11 +1,12 @@ from __future__ import unicode_literals +import datetime import unittest -from mopidy.frontends.mpd.protocol import music_db -from mopidy.models import Album, Artist, SearchResult, Track +from mopidy.mpd.protocol import music_db +from mopidy.models import Album, Artist, Playlist, Ref, SearchResult, Track -from tests.frontends.mpd import protocol +from tests.mpd import protocol class QueryFromMpdSearchFormatTest(unittest.TestCase): @@ -122,35 +123,194 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listall_without_uri(self): + tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/foo/b', name='b')] + self.backend.library.dummy_library = tracks + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')], + 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} + self.sendRequest('listall') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + + self.assertInResponse('file: dummy:/a') + self.assertInResponse('directory: /dummy/foo') + self.assertInResponse('file: dummy:/foo/b') + self.assertInResponse('OK') def test_listall_with_uri(self): - self.sendRequest('listall "file:///dev/urandom"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/foo/b', name='b')] + self.backend.library.dummy_library = tracks + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')], + 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} + + self.sendRequest('listall "/dummy/foo"') + + self.assertNotInResponse('file: dummy:/a') + self.assertInResponse('directory: /dummy/foo') + self.assertInResponse('file: dummy:/foo/b') + self.assertInResponse('OK') + + def test_listall_with_unknown_uri(self): + self.sendRequest('listall "/unknown"') + + self.assertEqualResponse('ACK [50@0] {listall} Not found') + + def test_listall_for_dir_with_and_without_leading_slash_is_the_same(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + + response1 = self.sendRequest('listall "dummy"') + response2 = self.sendRequest('listall "/dummy"') + self.assertEqual(response1, response2) + + def test_listall_for_dir_with_and_without_trailing_slash_is_the_same(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + + response1 = self.sendRequest('listall "dummy"') + response2 = self.sendRequest('listall "dummy/"') + self.assertEqual(response1, response2) def test_listallinfo_without_uri(self): + tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/foo/b', name='b')] + self.backend.library.dummy_library = tracks + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')], + 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} + self.sendRequest('listallinfo') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + + self.assertInResponse('file: dummy:/a') + self.assertInResponse('Title: a') + self.assertInResponse('directory: /dummy/foo') + self.assertInResponse('file: dummy:/foo/b') + self.assertInResponse('Title: b') + self.assertInResponse('OK') def test_listallinfo_with_uri(self): - self.sendRequest('listallinfo "file:///dev/urandom"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/foo/b', name='b')] + self.backend.library.dummy_library = tracks + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')], + 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} - def test_lsinfo_without_path_returns_same_as_listplaylists(self): - lsinfo_response = self.sendRequest('lsinfo') - listplaylists_response = self.sendRequest('listplaylists') - self.assertEqual(lsinfo_response, listplaylists_response) + self.sendRequest('listallinfo "/dummy/foo"') - def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): - lsinfo_response = self.sendRequest('lsinfo ""') - listplaylists_response = self.sendRequest('listplaylists') - self.assertEqual(lsinfo_response, listplaylists_response) + self.assertNotInResponse('file: dummy:/a') + self.assertNotInResponse('Title: a') + self.assertInResponse('directory: /dummy/foo') + self.assertInResponse('file: dummy:/foo/b') + self.assertInResponse('Title: b') + self.assertInResponse('OK') - def test_lsinfo_for_root_returns_same_as_listplaylists(self): - lsinfo_response = self.sendRequest('lsinfo "/"') - listplaylists_response = self.sendRequest('listplaylists') - self.assertEqual(lsinfo_response, listplaylists_response) + def test_listallinfo_with_unknown_uri(self): + self.sendRequest('listallinfo "/unknown"') + + self.assertEqualResponse('ACK [50@0] {listallinfo} Not found') + + def test_listallinfo_for_dir_with_and_without_leading_slash_is_same(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + + response1 = self.sendRequest('listallinfo "dummy"') + response2 = self.sendRequest('listallinfo "/dummy"') + self.assertEqual(response1, response2) + + def test_listallinfo_for_dir_with_and_without_trailing_slash_is_same(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + + response1 = self.sendRequest('listallinfo "dummy"') + response2 = self.sendRequest('listallinfo "dummy/"') + self.assertEqual(response1, response2) + + def test_lsinfo_without_path_returns_same_as_for_root(self): + last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + self.backend.playlists.playlists = [ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + + response1 = self.sendRequest('lsinfo') + response2 = self.sendRequest('lsinfo "/"') + self.assertEqual(response1, response2) + + def test_lsinfo_with_empty_path_returns_same_as_for_root(self): + last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + self.backend.playlists.playlists = [ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + + response1 = self.sendRequest('lsinfo ""') + response2 = self.sendRequest('lsinfo "/"') + self.assertEqual(response1, response2) + + def test_lsinfo_for_root_includes_playlists(self): + last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + self.backend.playlists.playlists = [ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + + self.sendRequest('lsinfo "/"') + self.assertInResponse('playlist: a') + # Date without microseconds and with time zone information + self.assertInResponse('Last-Modified: 2001-03-17T13:41:17Z') + self.assertInResponse('OK') + + def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + + self.sendRequest('lsinfo "/"') + self.assertInResponse('directory: dummy') + self.assertInResponse('OK') + + def test_lsinfo_for_dir_with_and_without_leading_slash_is_the_same(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + + response1 = self.sendRequest('lsinfo "dummy"') + response2 = self.sendRequest('lsinfo "/dummy"') + self.assertEqual(response1, response2) + + def test_lsinfo_for_dir_with_and_without_trailing_slash_is_the_same(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + + response1 = self.sendRequest('lsinfo "dummy"') + response2 = self.sendRequest('lsinfo "dummy/"') + self.assertEqual(response1, response2) + + def test_lsinfo_for_dir_includes_tracks(self): + self.backend.library.dummy_library = [ + Track(uri='dummy:/a', name='a'), + ] + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} + + self.sendRequest('lsinfo "/dummy"') + self.assertInResponse('file: dummy:/a') + self.assertInResponse('Title: a') + self.assertInResponse('OK') + + def test_lsinfo_for_dir_includes_subdirs(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.directory(uri='/foo', name='foo')]} + + self.sendRequest('lsinfo "/dummy"') + self.assertInResponse('directory: dummy/foo') + self.assertInResponse('OK') def test_update_without_uri(self): self.sendRequest('update') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/mpd/protocol/test_playback.py similarity index 98% rename from tests/frontends/mpd/protocol/playback_test.py rename to tests/mpd/protocol/test_playback.py index fc91c09c..67b4e787 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/mpd/protocol/test_playback.py @@ -5,7 +5,7 @@ import unittest from mopidy.core import PlaybackState from mopidy.models import Track -from tests.frontends.mpd import protocol +from tests.mpd import protocol PAUSED = PlaybackState.PAUSED @@ -36,7 +36,7 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_crossfade(self): self.sendRequest('crossfade "10"') - self.assertInResponse('ACK [0@0] {} Not implemented') + self.assertInResponse('ACK [0@0] {crossfade} Not implemented') def test_random_off(self): self.sendRequest('random "0"') @@ -135,15 +135,15 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_replay_gain_mode_off(self): self.sendRequest('replay_gain_mode "off"') - self.assertInResponse('ACK [0@0] {} Not implemented') + self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_mode_track(self): self.sendRequest('replay_gain_mode "track"') - self.assertInResponse('ACK [0@0] {} Not implemented') + self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_mode_album(self): self.sendRequest('replay_gain_mode "album"') - self.assertInResponse('ACK [0@0] {} Not implemented') + self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_status_default(self): self.sendRequest('replay_gain_status') diff --git a/tests/frontends/mpd/protocol/reflection_test.py b/tests/mpd/protocol/test_reflection.py similarity index 98% rename from tests/frontends/mpd/protocol/reflection_test.py rename to tests/mpd/protocol/test_reflection.py index 16f4579f..160c9876 100644 --- a/tests/frontends/mpd/protocol/reflection_test.py +++ b/tests/mpd/protocol/test_reflection.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from tests.frontends.mpd import protocol +from tests.mpd import protocol class ReflectionHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/mpd/protocol/test_regression.py similarity index 99% rename from tests/frontends/mpd/protocol/regression_test.py rename to tests/mpd/protocol/test_regression.py index 0bc488fd..3389573f 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/mpd/protocol/test_regression.py @@ -4,7 +4,7 @@ import random from mopidy.models import Track -from tests.frontends.mpd import protocol +from tests.mpd import protocol class IssueGH17RegressionTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/status_test.py b/tests/mpd/protocol/test_status.py similarity index 91% rename from tests/frontends/mpd/protocol/status_test.py rename to tests/mpd/protocol/test_status.py index 1cf5f253..7d30ea89 100644 --- a/tests/frontends/mpd/protocol/status_test.py +++ b/tests/mpd/protocol/test_status.py @@ -2,13 +2,13 @@ from __future__ import unicode_literals from mopidy.models import Track -from tests.frontends.mpd import protocol +from tests.mpd import protocol class StatusHandlerTest(protocol.BaseTestCase): def test_clearerror(self): self.sendRequest('clearerror') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {clearerror} Not implemented') def test_currentsong(self): track = Track() diff --git a/tests/frontends/mpd/protocol/stickers_test.py b/tests/mpd/protocol/test_stickers.py similarity index 65% rename from tests/frontends/mpd/protocol/stickers_test.py rename to tests/mpd/protocol/test_stickers.py index de610521..c3ce264a 100644 --- a/tests/frontends/mpd/protocol/stickers_test.py +++ b/tests/mpd/protocol/test_stickers.py @@ -1,35 +1,35 @@ from __future__ import unicode_literals -from tests.frontends.mpd import protocol +from tests.mpd import protocol class StickersHandlerTest(protocol.BaseTestCase): def test_sticker_get(self): self.sendRequest( 'sticker get "song" "file:///dev/urandom" "a_name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_set(self): self.sendRequest( 'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_delete_with_name(self): self.sendRequest( 'sticker delete "song" "file:///dev/urandom" "a_name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_delete_without_name(self): self.sendRequest( 'sticker delete "song" "file:///dev/urandom"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_list(self): self.sendRequest( 'sticker list "song" "file:///dev/urandom"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_find(self): self.sendRequest( 'sticker find "song" "file:///dev/urandom" "a_name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/mpd/protocol/test_stored_playlists.py similarity index 93% rename from tests/frontends/mpd/protocol/stored_playlists_test.py rename to tests/mpd/protocol/test_stored_playlists.py index d75944c4..636c5c2c 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -4,7 +4,7 @@ import datetime from mopidy.models import Track, Playlist -from tests.frontends.mpd import protocol +from tests.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): @@ -189,28 +189,28 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_playlistadd(self): self.sendRequest('playlistadd "name" "dummy:a"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistadd} Not implemented') def test_playlistclear(self): self.sendRequest('playlistclear "name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistclear} Not implemented') def test_playlistdelete(self): self.sendRequest('playlistdelete "name" "5"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistdelete} Not implemented') def test_playlistmove(self): self.sendRequest('playlistmove "name" "5" "10"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistmove} Not implemented') def test_rename(self): self.sendRequest('rename "old_name" "new_name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {rename} Not implemented') def test_rm(self): self.sendRequest('rm "name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {rm} Not implemented') def test_save(self): self.sendRequest('save "name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {save} Not implemented') diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/mpd/test_dispatcher.py similarity index 90% rename from tests/frontends/mpd/dispatcher_test.py rename to tests/mpd/test_dispatcher.py index 9ef88e44..c4da1714 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/mpd/test_dispatcher.py @@ -5,10 +5,10 @@ import unittest import pykka from mopidy import core -from mopidy.backends import dummy -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.frontends.mpd.exceptions import MpdAckError -from mopidy.frontends.mpd.protocol import request_handlers, handle_request +from mopidy.backend import dummy +from mopidy.mpd.dispatcher import MpdDispatcher +from mopidy.mpd.exceptions import MpdAckError +from mopidy.mpd.protocol import request_handlers, handle_request class MpdDispatcherTest(unittest.TestCase): diff --git a/tests/frontends/mpd/exception_test.py b/tests/mpd/test_exceptions.py similarity index 73% rename from tests/frontends/mpd/exception_test.py rename to tests/mpd/test_exceptions.py index 3b42f1b9..ef84a5f9 100644 --- a/tests/frontends/mpd/exception_test.py +++ b/tests/mpd/test_exceptions.py @@ -2,9 +2,9 @@ from __future__ import unicode_literals import unittest -from mopidy.frontends.mpd.exceptions import ( - MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdSystemError, - MpdNotImplemented) +from mopidy.mpd.exceptions import ( + MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdNoCommand, + MpdSystemError, MpdNotImplemented) class MpdExceptionsTest(unittest.TestCase): @@ -25,7 +25,7 @@ class MpdExceptionsTest(unittest.TestCase): def test_get_mpd_ack_with_default_values(self): e = MpdAckError('A description') - self.assertEqual(e.get_mpd_ack(), 'ACK [0@0] {} A description') + self.assertEqual(e.get_mpd_ack(), 'ACK [0@0] {None} A description') def test_get_mpd_ack_with_values(self): try: @@ -38,16 +38,21 @@ class MpdExceptionsTest(unittest.TestCase): raise MpdUnknownCommand(command='play') except MpdAckError as e: self.assertEqual( - e.get_mpd_ack(), - 'ACK [5@0] {} unknown command "play"') + e.get_mpd_ack(), 'ACK [5@0] {} unknown command "play"') + + def test_mpd_no_command(self): + try: + raise MpdNoCommand + except MpdAckError as e: + self.assertEqual( + e.get_mpd_ack(), 'ACK [5@0] {} No command given') def test_mpd_system_error(self): try: raise MpdSystemError('foo') except MpdSystemError as e: self.assertEqual( - e.get_mpd_ack(), - 'ACK [52@0] {} foo') + e.get_mpd_ack(), 'ACK [52@0] {None} foo') def test_mpd_permission_error(self): try: diff --git a/tests/frontends/mpd/status_test.py b/tests/mpd/test_status.py similarity index 98% rename from tests/frontends/mpd/status_test.py rename to tests/mpd/test_status.py index d86f7dcd..cd910340 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/mpd/test_status.py @@ -5,12 +5,11 @@ import unittest import pykka from mopidy import core -from mopidy.backends import dummy +from mopidy.backend import dummy from mopidy.core import PlaybackState -from mopidy.frontends.mpd import dispatcher -from mopidy.frontends.mpd.protocol import status from mopidy.models import Track - +from mopidy.mpd import dispatcher +from mopidy.mpd.protocol import status PAUSED = PlaybackState.PAUSED PLAYING = PlaybackState.PLAYING diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py new file mode 100644 index 00000000..c2648311 --- /dev/null +++ b/tests/mpd/test_translator.py @@ -0,0 +1,127 @@ +from __future__ import unicode_literals + +import datetime +import unittest + +from mopidy.utils.path import mtime +from mopidy.mpd import translator +from mopidy.models import Album, Artist, TlTrack, Playlist, Track + + +class TrackMpdFormatTest(unittest.TestCase): + track = Track( + uri='a uri', + artists=[Artist(name='an artist')], + name='a name', + album=Album(name='an album', num_tracks=13, + artists=[Artist(name='an other artist')]), + track_no=7, + composers=[Artist(name='a composer')], + performers=[Artist(name='a performer')], + genre='a genre', + date=datetime.date(1977, 1, 1), + disc_no='1', + comment='a comment', + length=137000, + ) + + def setUp(self): + self.media_dir = '/dir/subdir' + mtime.set_fake_time(1234567) + + def tearDown(self): + mtime.undo_fake() + + def test_track_to_mpd_format_for_empty_track(self): + result = translator.track_to_mpd_format(Track()) + self.assertIn(('file', ''), result) + self.assertIn(('Time', 0), result) + self.assertIn(('Artist', ''), result) + self.assertIn(('Title', ''), result) + self.assertIn(('Album', ''), result) + self.assertIn(('Track', 0), result) + self.assertNotIn(('Date', ''), result) + self.assertEqual(len(result), 6) + + def test_track_to_mpd_format_with_position(self): + result = translator.track_to_mpd_format(Track(), position=1) + self.assertNotIn(('Pos', 1), result) + + def test_track_to_mpd_format_with_tlid(self): + result = translator.track_to_mpd_format(TlTrack(1, Track())) + self.assertNotIn(('Id', 1), result) + + def test_track_to_mpd_format_with_position_and_tlid(self): + result = translator.track_to_mpd_format( + TlTrack(2, Track()), position=1) + self.assertIn(('Pos', 1), result) + self.assertIn(('Id', 2), result) + + def test_track_to_mpd_format_for_nonempty_track(self): + result = translator.track_to_mpd_format( + TlTrack(122, self.track), position=9) + self.assertIn(('file', 'a uri'), result) + self.assertIn(('Time', 137), result) + self.assertIn(('Artist', 'an artist'), result) + self.assertIn(('Title', 'a name'), result) + self.assertIn(('Album', 'an album'), result) + self.assertIn(('AlbumArtist', 'an other artist'), result) + self.assertIn(('Composer', 'a composer'), result) + 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(('Comment', 'a comment'), result) + self.assertIn(('Pos', 9), result) + self.assertIn(('Id', 122), result) + self.assertEqual(len(result), 15) + + def test_track_to_mpd_format_musicbrainz_trackid(self): + track = self.track.copy(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) + 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) + 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]) + result = translator.track_to_mpd_format(track) + self.assertIn(('MUSICBRAINZ_ARTISTID', 'foo'), result) + + def test_artists_to_mpd_format(self): + artists = [Artist(name='ABBA'), Artist(name='Beatles')] + translated = translator.artists_to_mpd_format(artists) + self.assertEqual(translated, 'ABBA, Beatles') + + def test_artists_to_mpd_format_artist_with_no_name(self): + artists = [Artist(name=None)] + translated = translator.artists_to_mpd_format(artists) + self.assertEqual(translated, '') + + +class PlaylistMpdFormatTest(unittest.TestCase): + def test_mpd_format(self): + playlist = Playlist(tracks=[ + Track(track_no=1), Track(track_no=2), Track(track_no=3)]) + result = translator.playlist_to_mpd_format(playlist) + self.assertEqual(len(result), 3) + + def test_mpd_format_with_range(self): + playlist = Playlist(tracks=[ + Track(track_no=1), Track(track_no=2), Track(track_no=3)]) + result = translator.playlist_to_mpd_format(playlist, 1, 2) + self.assertEqual(len(result), 1) + self.assertEqual(dict(result[0])['Track'], 2) diff --git a/tests/outputs/__init__.py b/tests/outputs/__init__.py deleted file mode 100644 index baffc488..00000000 --- a/tests/outputs/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import unicode_literals diff --git a/tests/commands_test.py b/tests/test_commands.py similarity index 100% rename from tests/commands_test.py rename to tests/test_commands.py diff --git a/tests/exceptions_test.py b/tests/test_exceptions.py similarity index 100% rename from tests/exceptions_test.py rename to tests/test_exceptions.py diff --git a/tests/ext_test.py b/tests/test_ext.py similarity index 100% rename from tests/ext_test.py rename to tests/test_ext.py diff --git a/tests/help_test.py b/tests/test_help.py similarity index 100% rename from tests/help_test.py rename to tests/test_help.py diff --git a/tests/models_test.py b/tests/test_models.py similarity index 90% rename from tests/models_test.py rename to tests/test_models.py index 9f43e624..9a4f97b7 100644 --- a/tests/models_test.py +++ b/tests/test_models.py @@ -5,7 +5,7 @@ import json import unittest from mopidy.models import ( - Artist, Album, TlTrack, Track, Playlist, SearchResult, + Ref, Artist, Album, TlTrack, Track, Playlist, SearchResult, ModelJSONEncoder, model_json_decoder) @@ -53,6 +53,81 @@ class GenericCopyTest(unittest.TestCase): test = lambda: Track().copy(invalid_key=True) self.assertRaises(TypeError, test) + def test_copying_track_to_remove(self): + track = Track(name='foo').copy(name=None) + self.assertEquals(track.__dict__, Track().__dict__) + + +class RefTest(unittest.TestCase): + def test_uri(self): + uri = 'an_uri' + ref = Ref(uri=uri) + self.assertEqual(ref.uri, uri) + self.assertRaises(AttributeError, setattr, ref, 'uri', None) + + def test_name(self): + name = 'a name' + ref = Ref(name=name) + self.assertEqual(ref.name, name) + self.assertRaises(AttributeError, setattr, ref, 'name', None) + + def test_invalid_kwarg(self): + test = lambda: SearchResult(foo='baz') + self.assertRaises(TypeError, test) + + def test_repr_without_results(self): + self.assertEquals( + "Ref(name=u'foo', type=u'artist', uri=u'uri')", + repr(Ref(uri='uri', name='foo', type='artist'))) + + def test_serialize_without_results(self): + self.assertDictEqual( + {'__model__': 'Ref', 'uri': 'uri'}, + Ref(uri='uri').serialize()) + + def test_to_json_and_back(self): + ref1 = Ref(uri='uri') + serialized = json.dumps(ref1, cls=ModelJSONEncoder) + ref2 = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(ref1, ref2) + + def test_type_constants(self): + self.assertEqual(Ref.ALBUM, 'album') + self.assertEqual(Ref.ARTIST, 'artist') + self.assertEqual(Ref.DIRECTORY, 'directory') + self.assertEqual(Ref.PLAYLIST, 'playlist') + self.assertEqual(Ref.TRACK, 'track') + + def test_album_constructor(self): + ref = Ref.album(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.ALBUM) + + def test_artist_constructor(self): + ref = Ref.artist(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.ARTIST) + + def test_directory_constructor(self): + ref = Ref.directory(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.DIRECTORY) + + def test_playlist_constructor(self): + ref = Ref.playlist(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.PLAYLIST) + + def test_track_constructor(self): + ref = Ref.track(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.TRACK) + class ArtistTest(unittest.TestCase): def test_uri(self): @@ -201,6 +276,9 @@ class AlbumTest(unittest.TestCase): self.assertIn(artist, album.artists) self.assertRaises(AttributeError, setattr, album, 'artists', None) + def test_artists_none(self): + self.assertEqual(set(), Album(artists=None).artists) + def test_num_tracks(self): num_tracks = 11 album = Album(num_tracks=num_tracks) @@ -232,6 +310,9 @@ class AlbumTest(unittest.TestCase): self.assertIn(image, album.images) self.assertRaises(AttributeError, setattr, album, 'images', None) + def test_images_none(self): + self.assertEqual(set(), Album(images=None).images) + def test_invalid_kwarg(self): test = lambda: Album(foo='baz') self.assertRaises(TypeError, test) @@ -401,6 +482,27 @@ class TrackTest(unittest.TestCase): self.assertEqual(set(track.artists), set(artists)) self.assertRaises(AttributeError, setattr, track, 'artists', None) + def test_artists_none(self): + self.assertEqual(set(), Track(artists=None).artists) + + def test_composers(self): + artists = [Artist(name='name1'), Artist(name='name2')] + track = Track(composers=artists) + self.assertEqual(set(track.composers), set(artists)) + self.assertRaises(AttributeError, setattr, track, 'composers', None) + + def test_composers_none(self): + self.assertEqual(set(), Track(composers=None).composers) + + def test_performers(self): + artists = [Artist(name='name1'), Artist(name='name2')] + track = Track(performers=artists) + self.assertEqual(set(track.performers), set(artists)) + self.assertRaises(AttributeError, setattr, track, 'performers', None) + + def test_performers_none(self): + self.assertEqual(set(), Track(performers=None).performers) + def test_album(self): album = Album() track = Track(album=album) diff --git a/tests/version_test.py b/tests/test_version.py similarity index 92% rename from tests/version_test.py rename to tests/test_version.py index 6c113265..5fb1a60d 100644 --- a/tests/version_test.py +++ b/tests/test_version.py @@ -41,5 +41,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.14.1'), SV('0.14.2')) self.assertLess(SV('0.14.2'), SV('0.15.0')) self.assertLess(SV('0.15.0'), SV('0.16.0')) - self.assertLess(SV('0.16.0'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.17.1')) + self.assertLess(SV('0.16.0'), SV('0.17.0')) + self.assertLess(SV('0.17.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.18.1')) diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/test_connection.py similarity index 100% rename from tests/utils/network/connection_test.py rename to tests/utils/network/test_connection.py diff --git a/tests/utils/network/lineprotocol_test.py b/tests/utils/network/test_lineprotocol.py similarity index 100% rename from tests/utils/network/lineprotocol_test.py rename to tests/utils/network/test_lineprotocol.py diff --git a/tests/utils/network/server_test.py b/tests/utils/network/test_server.py similarity index 100% rename from tests/utils/network/server_test.py rename to tests/utils/network/test_server.py diff --git a/tests/utils/network/utils_test.py b/tests/utils/network/test_utils.py similarity index 100% rename from tests/utils/network/utils_test.py rename to tests/utils/network/test_utils.py diff --git a/tests/utils/deps_test.py b/tests/utils/test_deps.py similarity index 100% rename from tests/utils/deps_test.py rename to tests/utils/test_deps.py diff --git a/tests/utils/encoding_test.py b/tests/utils/test_encoding.py similarity index 100% rename from tests/utils/encoding_test.py rename to tests/utils/test_encoding.py diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/test_jsonrpc.py similarity index 99% rename from tests/utils/jsonrpc_test.py rename to tests/utils/test_jsonrpc.py index c6f516bb..6bd6a32b 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/test_jsonrpc.py @@ -7,7 +7,7 @@ import unittest import pykka from mopidy import core, models -from mopidy.backends import dummy +from mopidy.backend import dummy from mopidy.utils import jsonrpc diff --git a/tests/utils/path_test.py b/tests/utils/test_path.py similarity index 89% rename from tests/utils/path_test.py rename to tests/utils/test_path.py index 673fda73..3accab39 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/test_path.py @@ -221,9 +221,12 @@ class FindFilesTest(unittest.TestCase): self.assertEqual(self.find('does-not-exist'), []) def test_file(self): - files = self.find('blank.mp3') - self.assertEqual(len(files), 1) - self.assertEqual(files[0], path_to_data_dir('blank.mp3')) + self.assertEqual([], self.find('blank.mp3')) + + def test_files(self): + files = self.find('find') + expected = [b'foo/bar/file', b'foo/file', b'baz/file'] + self.assertItemsEqual(expected, files) def test_names_are_bytestrings(self): is_bytes = lambda f: isinstance(f, bytes) @@ -231,35 +234,6 @@ class FindFilesTest(unittest.TestCase): self.assert_( is_bytes(name), '%s is not bytes object' % repr(name)) - def test_ignores_hidden_dirs(self): - self.assertEqual(self.find('.hidden'), []) - - def test_ignores_hidden_files(self): - self.assertEqual(self.find('.blank.mp3'), []) - - -class FindUrisTest(unittest.TestCase): - def find(self, value): - return list(path.find_uris(path_to_data_dir(value))) - - def test_basic_dir(self): - self.assert_(self.find('')) - - def test_nonexistant_dir(self): - self.assertEqual(self.find('does-not-exist'), []) - - def test_file(self): - uris = self.find('blank.mp3') - expected = path.path_to_uri(path_to_data_dir('blank.mp3')) - self.assertEqual(len(uris), 1) - self.assertEqual(uris[0], expected) - - def test_ignores_hidden_dirs(self): - self.assertEqual(self.find('.hidden'), []) - - def test_ignores_hidden_files(self): - self.assertEqual(self.find('.blank.mp3'), []) - # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): diff --git a/tools/debug-proxy.py b/tools/debug-proxy.py deleted file mode 100755 index 938afa57..00000000 --- a/tools/debug-proxy.py +++ /dev/null @@ -1,195 +0,0 @@ -#! /usr/bin/env python - -from __future__ import unicode_literals - -import argparse -import difflib -import sys - -from gevent import select, server, socket - -COLORS = ['\033[1;%dm' % (30 + i) for i in range(8)] -BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = COLORS -RESET = "\033[0m" -BOLD = "\033[1m" - - -def proxy(client, address, reference_address, actual_address): - """Main handler code that gets called for each connection.""" - client.setblocking(False) - - reference = connect(reference_address) - actual = connect(actual_address) - - if reference and actual: - loop(client, address, reference, actual) - else: - print 'Could not connect to one of the backends.' - - for sock in (client, reference, actual): - close(sock) - - -def connect(address): - """Connect to given address and set socket non blocking.""" - try: - sock = socket.socket() - sock.connect(address) - sock.setblocking(False) - except socket.error: - return None - return sock - - -def close(sock): - """Shutdown and close our sockets.""" - try: - sock.shutdown(socket.SHUT_WR) - sock.close() - except socket.error: - pass - - -def loop(client, address, reference, actual): - """Loop that handles one MPD reqeust/response pair per iteration.""" - - # Consume banners from backends - responses = dict() - disconnected = read( - [reference, actual], responses, find_response_end_token) - diff(address, '', responses[reference], responses[actual]) - - # We lost a backend, might as well give up. - if disconnected: - return - - client.sendall(responses[reference]) - - while True: - responses = dict() - - # Get the command from the client. Not sure how an if this will handle - # client sending multiple commands currently :/ - disconnected = read([client], responses, find_request_end_token) - - # We lost the client, might as well give up. - if disconnected: - return - - # Send the entire command to both backends. - reference.sendall(responses[client]) - actual.sendall(responses[client]) - - # Get the entire resonse from both backends. - disconnected = read( - [reference, actual], responses, find_response_end_token) - - # Send the client the complete reference response - client.sendall(responses[reference]) - - # Compare our responses - diff(address, - responses[client], responses[reference], responses[actual]) - - # Give up if we lost a backend. - if disconnected: - return - - -def read(sockets, responses, find_end_token): - """Keep reading from sockets until they disconnet or we find our token.""" - - # This function doesn't go to well with idle when backends are out of sync. - disconnected = False - - for sock in sockets: - responses.setdefault(sock, '') - - while sockets: - for sock in select.select(sockets, [], [])[0]: - data = sock.recv(4096) - responses[sock] += data - - if find_end_token(responses[sock]): - sockets.remove(sock) - - if not data: - sockets.remove(sock) - disconnected = True - - return disconnected - - -def find_response_end_token(data): - """Find token that indicates the response is over.""" - for line in data.splitlines(True): - if line.startswith(('OK', 'ACK')) and line.endswith('\n'): - return True - return False - - -def find_request_end_token(data): - """Find token that indicates that request is over.""" - lines = data.splitlines(True) - if not lines: - return False - elif 'command_list_ok_begin' == lines[0].strip(): - return 'command_list_end' == lines[-1].strip() - else: - return lines[0].endswith('\n') - - -def diff(address, command, reference_response, actual_response): - """Print command from client and a unified diff of the responses.""" - sys.stdout.write('[%s]:%s\n%s' % (address[0], address[1], command)) - for line in difflib.unified_diff(reference_response.splitlines(True), - actual_response.splitlines(True), - fromfile='Reference response', - tofile='Actual response'): - - if line.startswith('+') and not line.startswith('+++'): - sys.stdout.write(GREEN) - elif line.startswith('-') and not line.startswith('---'): - sys.stdout.write(RED) - elif line.startswith('@@'): - sys.stdout.write(CYAN) - - sys.stdout.write(line) - sys.stdout.write(RESET) - - sys.stdout.flush() - - -def parse_args(): - """Handle flag parsing.""" - parser = argparse.ArgumentParser( - description='Proxy and compare MPD protocol interactions.') - parser.add_argument('--listen', default=':6600', type=parse_address, - help='address:port to listen on.') - parser.add_argument('--reference', default=':6601', type=parse_address, - help='address:port for the reference backend.') - parser.add_argument('--actual', default=':6602', type=parse_address, - help='address:port for the actual backend.') - - return parser.parse_args() - - -def parse_address(address): - """Convert host:port or port to address to pass to connect.""" - if ':' not in address: - return ('', int(address)) - host, port = address.rsplit(':', 1) - return (host, int(port)) - - -if __name__ == '__main__': - args = parse_args() - - def handle(client, address): - """Wrapper that adds reference and actual backends to proxy calls.""" - return proxy(client, address, args.reference, args.actual) - - try: - server.StreamServer(args.listen, handle).serve_forever() - except (KeyboardInterrupt, SystemExit): - pass diff --git a/tools/idle.py b/tools/idle.py deleted file mode 100644 index 122e998d..00000000 --- a/tools/idle.py +++ /dev/null @@ -1,203 +0,0 @@ -#! /usr/bin/env python - -# This script is helper to systematicly test the behaviour of MPD's idle -# command. It is simply provided as a quick hack, expect nothing more. - -from __future__ import unicode_literals - -import logging -import pprint -import socket - -host = '' -port = 6601 - -url = "13 - a-ha - White Canvas.mp3" -artist = "a-ha" - -data = {'id': None, 'id2': None, 'url': url, 'artist': artist} - -# Commands to run before test requests to coerce MPD into right state -setup_requests = [ - 'clear', - 'add "%(url)s"', - 'add "%(url)s"', - 'add "%(url)s"', - 'play', - #'pause', # Uncomment to test paused idle behaviour - #'stop', # Uncomment to test stopped idle behaviour -] - -# List of commands to test for idle behaviour. Ordering of list is important in -# order to keep MPD state as intended. Commands that are obviously -# informational only or "harmfull" have been excluded. -test_requests = [ - 'add "%(url)s"', - 'addid "%(url)s" "1"', - 'clear', - #'clearerror', - #'close', - #'commands', - 'consume "1"', - 'consume "0"', - # 'count', - 'crossfade "1"', - 'crossfade "0"', - #'currentsong', - #'delete "1:2"', - 'delete "0"', - 'deleteid "%(id)s"', - 'disableoutput "0"', - 'enableoutput "0"', - #'find', - #'findadd "artist" "%(artist)s"', - #'idle', - #'kill', - #'list', - #'listall', - #'listallinfo', - #'listplaylist', - #'listplaylistinfo', - #'listplaylists', - #'lsinfo', - 'move "0:1" "2"', - 'move "0" "1"', - 'moveid "%(id)s" "1"', - 'next', - #'notcommands', - #'outputs', - #'password', - 'pause', - #'ping', - 'play', - 'playid "%(id)s"', - #'playlist', - 'playlistadd "foo" "%(url)s"', - 'playlistclear "foo"', - 'playlistadd "foo" "%(url)s"', - 'playlistdelete "foo" "0"', - #'playlistfind', - #'playlistid', - #'playlistinfo', - 'playlistadd "foo" "%(url)s"', - 'playlistadd "foo" "%(url)s"', - 'playlistmove "foo" "0" "1"', - #'playlistsearch', - #'plchanges', - #'plchangesposid', - 'previous', - 'random "1"', - 'random "0"', - 'rm "bar"', - 'rename "foo" "bar"', - 'repeat "0"', - 'rm "bar"', - 'save "bar"', - 'load "bar"', - #'search', - 'seek "1" "10"', - 'seekid "%(id)s" "10"', - #'setvol "10"', - 'shuffle', - 'shuffle "0:1"', - 'single "1"', - 'single "0"', - #'stats', - #'status', - 'stop', - 'swap "1" "2"', - 'swapid "%(id)s" "%(id2)s"', - #'tagtypes', - #'update', - #'urlhandlers', - #'volume', -] - - -def create_socketfile(): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect((host, port)) - sock.settimeout(0.5) - fd = sock.makefile('rw', 1) # 1 = line buffered - fd.readline() # Read banner - return fd - - -def wait(fd, prefix=None, collect=None): - while True: - line = fd.readline().rstrip() - if prefix: - logging.debug('%s: %s', prefix, repr(line)) - if line.split()[0] in ('OK', 'ACK'): - break - - -def collect_ids(fd): - fd.write('playlistinfo\n') - - ids = [] - while True: - line = fd.readline() - if line.split()[0] == 'OK': - break - if line.split()[0] == 'Id:': - ids.append(line.split()[1]) - return ids - - -def main(): - subsystems = {} - - command = create_socketfile() - - for test in test_requests: - # Remove any old ids - del data['id'] - del data['id2'] - - # Run setup code to force MPD into known state - for setup in setup_requests: - command.write(setup % data + '\n') - wait(command) - - data['id'], data['id2'] = collect_ids(command)[:2] - - # This connection needs to be make after setup commands are done or - # else they will cause idle events. - idle = create_socketfile() - - # Wait for new idle events - idle.write('idle\n') - - test = test % data - - logging.debug('idle: %s', repr('idle')) - logging.debug('command: %s', repr(test)) - - command.write(test + '\n') - wait(command, prefix='command') - - while True: - try: - line = idle.readline().rstrip() - except socket.timeout: - # Abort try if we time out. - idle.write('noidle\n') - break - - logging.debug('idle: %s', repr(line)) - - if line == 'OK': - break - - request_type = test.split()[0] - subsystem = line.split()[1] - subsystems.setdefault(request_type, set()).add(subsystem) - - logging.debug('---') - - pprint.pprint(subsystems) - - -if __name__ == '__main__': - main()