diff --git a/AUTHORS.rst b/AUTHORS.rst deleted file mode 100644 index fc4b5611..00000000 --- a/AUTHORS.rst +++ /dev/null @@ -1,9 +0,0 @@ -Authors -======= - -Contributors to Mopidy in the order of appearance: - -- Stein Magnus Jodal -- Johannes Knutsen -- Thomas Adamcik -- Kristian Klette diff --git a/MANIFEST.in b/MANIFEST.in index cb752f87..38819adb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ -include COPYING pylintrc *.rst *.txt +include LICENSE pylintrc *.rst *.txt +include mopidy/backends/libspotify/spotify_appkey.key recursive-include docs * prune docs/_build recursive-include tests *.py diff --git a/README.rst b/README.rst index 350f959b..1e4430e2 100644 --- a/README.rst +++ b/README.rst @@ -2,10 +2,11 @@ Mopidy ****** -Mopidy is an `Music Player Daemon `_ (MPD) server with a -`Spotify `_ backend. Using a standard MPD client you -can search for music in Spotify's vast archive, manage Spotify playlists and -play music from Spotify. +Mopidy is a music server which can play music from `Spotify +`_ or from your local hard drive. To search for music +in Spotify's vast archive, manage playlists, and play music, you can use most +`MPD clients `_. MPD clients are available for most +platforms, including Windows, Mac OS X, Linux, and iPhone and Android phones. To install Mopidy, check out `the installation docs `_. @@ -14,4 +15,3 @@ To install Mopidy, check out * `Source code `_ * `Issue tracker `_ * IRC: ``#mopidy`` at `irc.freenode.net `_ -* `Presentation of Mopidy `_ diff --git a/docs/api/backends.rst b/docs/api/backends.rst index adb87e56..f675541a 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -82,14 +82,6 @@ Manages the music library, e.g. searching for tracks to be added to a playlist. :undoc-members: -:mod:`mopidy.backends.despotify` -- Despotify backend -===================================================== - -.. automodule:: mopidy.backends.despotify - :synopsis: Spotify backend using the Despotify library - :members: - - :mod:`mopidy.backends.dummy` -- Dummy backend for testing ========================================================= diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst index 70ac450a..91c2e7aa 100644 --- a/docs/api/mixers.rst +++ b/docs/api/mixers.rst @@ -67,6 +67,16 @@ methods as described below. .. inheritance-diagram:: mopidy.mixers.dummy +:mod:`mopidy.mixers.gstreamer_software` -- Software mixer for all platforms +=========================================================================== + +.. automodule:: mopidy.mixers.gstreamer_software + :synopsis: Software mixer for all platforms + :members: + +.. inheritance-diagram:: mopidy.mixers.gstreamer_software + + :mod:`mopidy.mixers.osa` -- Osa mixer for OS X ============================================== diff --git a/docs/api/settings.rst b/docs/api/settings.rst index 12d2833f..cfc270d6 100644 --- a/docs/api/settings.rst +++ b/docs/api/settings.rst @@ -13,7 +13,7 @@ there. A complete ``~/.mopidy/settings.py`` may look like this:: - MPD_SERVER_HOSTNAME = u'0.0.0.0' + MPD_SERVER_HOSTNAME = u'::' SPOTIFY_USERNAME = u'alice' SPOTIFY_PASSWORD = u'mysecret' diff --git a/docs/authors.rst b/docs/authors.rst index e122f914..f56242a5 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -1 +1,22 @@ -.. include:: ../AUTHORS.rst +******* +Authors +******* + +Contributors to Mopidy in the order of appearance: + +- Stein Magnus Jodal +- Johannes Knutsen +- Thomas Adamcik +- Kristian Klette + + +Donations +========= + +If you already enjoy Mopidy, or don't enjoy it and want to help us making +Mopidy better, you can `donate money `_ to +Mopidy's development. + +Any donated money will be used to cover service subscriptions (e.g. Spotify +and Last.fm) and hardware devices (e.g. an used iPod Touch for testing Mopidy +with MPod) needed for developing Mopidy. diff --git a/docs/changes.rst b/docs/changes.rst index beea0d04..341ef850 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,45 +8,117 @@ This change log is used to track all major changes to Mopidy. 0.1.0a4 (in development) ======================== -Another great release. +The greatest release ever! We present to you important improvements in search +functionality, working track position seeking, no known stability issues, and +greatly improved MPD client support. -**Changes** +**Important changes** - License changed from GPLv2 to Apache License, version 2.0. - GStreamer is now a required dependency. +- :mod:`mopidy.backends.libspotify` is now the default backend. + :mod:`mopidy.backends.despotify` is no longer available. This means that you + need to install the :doc:`dependencies for libspotify + `. +- If you used :mod:`mopidy.backends.libspotify` previously, pyspotify must be + updated when updating to this release, to get working seek functionality. +- :attr:`mopidy.settings.SERVER_HOSTNAME` and + :attr:`mopidy.settings.SERVER_PORT` has been renamed to + :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` and + :attr:`mopidy.settings.MPD_SERVER_PORT` to allow for multiple frontends in + the future. + +**Changes** + - Exit early if not Python >= 2.6, < 3. +- Validate settings at startup and print useful error messages if the settings + has not been updated or anything is misspelled. +- Add command line option :option:`--list-settings` to print the currently + active settings. - Include Sphinx scripts for building docs, pylintrc, tests and test data in the packages created by ``setup.py`` for i.e. PyPI. -- Rename :mod:`mopidy.backends.gstreamer` to :mod:`mopidy.backends.local`. -- Changed ``SERVER_HOSTNAME`` and ``SERVER_PORT`` settings to - ``MPD_SERVER_HOSTNAME`` and ``MPD_SERVER_PORT`` - MPD frontend: - Relocate from :mod:`mopidy.mpd` to :mod:`mopidy.frontends.mpd`. - Split gigantic protocol implementation into eleven modules. - Search improvements, including support for multi-word search. - - Fixed ``play "-1"`` and ``playid "-1"`` behaviour when playlist is empty. + - Fixed ``play "-1"`` and ``playid "-1"`` behaviour when playlist is empty + or when a current track is set. - Support ``plchanges "-1"`` to work better with MPDroid. - Support ``pause`` without arguments to work better with MPDroid. - Support ``plchanges``, ``play``, ``consume``, ``random``, ``repeat``, and ``single`` without quotes to work better with BitMPC. - - Fixed delete current playing track from playlist, which crashed several - clients. + - Fixed deletion of the currently playing track from the current playlist, + which crashed several clients. + - Implement ``seek`` and ``seekid``. + - Fix ``playlistfind`` output so the correct song is played when playing + songs directly from search results in GMPC. + - Fix ``load`` so that one can append a playlist to the current playlist, and + make it return the correct error message if the playlist is not found. + - Support for single track repeat added. (Fixes: :issue:`4`) - Rename ``mopidy.frontends.mpd.{serializer => translator}`` to match naming in backends. +- Backends: + + - Rename :mod:`mopidy.backends.gstreamer` to :mod:`mopidy.backends.local`. + - Remove :mod:`mopidy.backends.despotify`, as Despotify is little maintained + and the Libspotify backend is working much better. (Fixes: :issue:`9`, + :issue:`10`, :issue:`13`) + - A Spotify application key is now bundled with the source. + :attr:`mopidy.settings.SPOTIFY_LIB_APPKEY` is thus removed. + - If failing to play a track, playback will skip to the next track. + +- Mixers: + + - Added new :mod:`mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer` + which now is the default mixer on all platforms. + - New setting :attr:`mopidy.settings.MIXER_MAX_VOLUME` for capping the + maximum output volume. + - Backend API: - Relocate from :mod:`mopidy.backends` to :mod:`mopidy.backends.base`. - The ``id`` field of :class:`mopidy.models.Track` has been removed, as it is no longer needed after the CPID refactoring. + - :meth:`mopidy.backends.base.BaseBackend()` now accepts an + ``output_queue`` which it can use to send messages (i.e. audio data) + to the output process. - :meth:`mopidy.backends.base.BaseLibraryController.find_exact()` now accepts keyword arguments of the form ``find_exact(artist=['foo'], album=['bar'])``. - :meth:`mopidy.backends.base.BaseLibraryController.search()` now accepts keyword arguments of the form ``search(artist=['foo', 'fighters'], album=['bar', 'grooves'])``. + - :meth:`mopidy.backends.base.BaseCurrentPlaylistController.append()` + replaces + :meth:`mopidy.backends.base.BaseCurrentPlaylistController.load()`. Use + :meth:`mopidy.backends.base.BaseCurrentPlaylistController.clear()` if you + want to clear the current playlist. + - The following fields in + :class:`mopidy.backends.base.BasePlaybackController` has been renamed to + reflect their relation to methods called on the controller: + - ``next_track`` to ``track_at_next`` + - ``next_cp_track`` to ``cp_track_at_next`` + - ``previous_track`` to ``track_at_previous`` + - ``previous_cp_track`` to ``cp_track_at_previous`` + + - :attr:`mopidy.backends.base.BasePlaybackController.track_at_eot` and + :attr:`mopidy.backends.base.BasePlaybackController.cp_track_at_eot` has + been added to better handle the difference between the user pressing next + and the current track ending. + - Rename + :meth:`mopidy.backends.base.BasePlaybackController.new_playlist_loaded_callback()` + to + :meth:`mopidy.backends.base.BasePlaybackController.on_current_playlist_change()`. + - Rename + :meth:`mopidy.backends.base.BasePlaybackController.end_of_track_callback()` + to :meth:`mopidy.backends.base.BasePlaybackController.on_end_of_track()`. + - Remove :meth:`mopidy.backends.base.BaseStoredPlaylistsController.search()` + since it was barely used, untested, and we got no use case for non-exact + search in stored playlists yet. Use + :meth:`mopidy.backends.base.BaseStoredPlaylistsController.get()` instead. 0.1.0a3 (2010-08-03) @@ -137,8 +209,8 @@ the established pace of at least a release per month. - Improvements to MPD protocol handling, making Mopidy work much better with a group of clients, including ncmpc, MPoD, and Theremin. -- New command line flag ``--dump`` for dumping debug log to ``dump.log`` in the - current directory. +- New command line flag :option:`--dump` for dumping debug log to ``dump.log`` + in the current directory. - New setting :attr:`mopidy.settings.MIXER_ALSA_CONTROL` for forcing what ALSA control :class:`mopidy.mixers.alsa.AlsaMixer` should use. diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index 7dc284df..dff9a9d7 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -11,8 +11,7 @@ Version 0.1 - Core MPD server functionality working. Gracefully handle clients' use of non-supported functionality. -- Read-only support for Spotify through :mod:`mopidy.backends.despotify` and/or - :mod:`mopidy.backends.libspotify`. +- Read-only support for Spotify through :mod:`mopidy.backends.libspotify`. - Initial support for local file playback through :mod:`mopidy.backends.local`. The state of local file playback will not block the release of 0.1. @@ -29,37 +28,53 @@ released when we reach the other goal. possible to have both Spotify tracks and local tracks in the same playlist. -Stuff we really want to do, but just not right now -================================================== +Stuff we want to do, but not right now, and maybe never +======================================================= -- Replace libspotify with `openspotify - `_ for - :mod:`mopidy.backends.libspotify`. *Update:* Seems like openspotify - development has stalled. -- Create `Debian packages `_ of all our - dependencies and Mopidy itself (hosted in our own Debian repo until we get - stuff into the various distros) to make Debian/Ubuntu installation a breeze. -- **[WIP]** Create `Homebrew `_ recipies for - all our dependencies and Mopidy itself to make OS X installation a breeze. -- Run frontend tests against a real MPD server to ensure we are in sync. -- Start working with MPD client maintainers to get rid of weird assumptions - like only searching for first two letters and doing the rest of the filtering - locally in the client, etc. +- Packaging and distribution: + - **[PENDING]** Create `Homebrew `_ + recipies for all our dependencies and Mopidy itself to make OS X + installation a breeze. See `Homebrew's issue #1612 + `_. + - Create `Debian packages `_ of all + our dependencies and Mopidy itself (hosted in our own Debian repo until we + get stuff into the various distros) to make Debian/Ubuntu installation a + breeze. -Crazy stuff we had to write down somewhere -========================================== +- Compatability: -- Add an `XMMS2 `_ frontend, so Mopidy can serve XMMS2 - clients. -- Add support for serving the music as an `Icecast `_ - stream instead of playing it locally. -- Integrate with `Squeezebox `_ in some - way. -- AirPort Express support, like in - `PulseAudio `_. -- DNLA and/or UPnP support. Maybe using - `Coherence `_. -- `Media Player Remote Interfacing Specification - `_ - support. + - Run frontend tests against a real MPD server to ensure we are in sync. + - Start working with MPD client maintainers to get rid of weird assumptions + like only searching for first two letters and doing the rest of the + filtering locally in the client (:issue:`1`), etc. + +- Backends: + + - `Last.fm `_ + - `WIMP `_ + - DNLA/UPnP to Mopidy can play music from other DNLA MediaServers. + +- Frontends: + + - Publish the server's presence to the network using `Zeroconf + `_/Avahi. + - D-Bus/`MPRIS `_ + - REST/JSON web service with a jQuery client as example application. Maybe + based upon `Tornado `_ and `jQuery + Mobile `_. + - DNLA/UPnP to Mopidy can be controlled from i.e. TVs. + - `XMMS2 `_ + - LIRC frontend for controlling Mopidy with a remote. + +- Mixers: + + - LIRC mixer for controlling arbitrary amplifiers remotely. + +- Audio streaming: + + - Ogg Vorbis/MP3 audio stream over HTTP, to MPD clients, `Squeezeboxes + `_, etc. + - Feed audio to an `Icecast `_ server. + - Stream to AirPort Express using `RAOP + `_. diff --git a/docs/installation/despotify.rst b/docs/installation/despotify.rst deleted file mode 100644 index 6787070d..00000000 --- a/docs/installation/despotify.rst +++ /dev/null @@ -1,73 +0,0 @@ -********************** -Despotify installation -********************** - -To use the `Despotify `_ backend, you first need to -install Despotify and spytify. - -.. warning:: - - This backend requires a Spotify premium account. - - -Installing Despotify on Linux -============================= - -Install Despotify's dependencies. At Debian/Ubuntu systems:: - - sudo aptitude install libssl-dev zlib1g-dev libvorbis-dev \ - libtool libncursesw5-dev libao-dev python-dev - -Check out revision 508 of the Despotify source code:: - - svn checkout https://despotify.svn.sourceforge.net/svnroot/despotify@508 - -Build and install Despotify:: - - cd despotify/src/ - sudo make install - -When Despotify has been installed, continue with :ref:`spytify_installation`. - - -Installing Despotify on OS X -============================ - -In OS X you need to have `XCode `_ and -`Homebrew `_ installed. Then, to install -Despotify:: - - brew install despotify - -When Despotify has been installed, continue with :ref:`spytify_installation`. - - -.. _spytify_installation: - -Installing spytify -================== - -spytify's source comes bundled with despotify. If you haven't already checkout -out the despotify source, do it now:: - - svn checkout https://despotify.svn.sourceforge.net/svnroot/despotify@508 - -Build and install spytify:: - - cd despotify/src/bindings/python/ - export PKG_CONFIG_PATH=../../lib # Needed on OS X - sudo make install - - -Testing the installation -======================== - -To validate that everything is working, run the ``test.py`` script which is -distributed with spytify:: - - python test.py - -The test script should ask for your username and password (which must be for a -Spotify Premium account), ask for a search query, list all your playlists with -tracks, play 10s from a random song from the search result, pause for two -seconds, play for five more seconds, and quit. diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 73ae62cb..abd185f1 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -2,12 +2,10 @@ Installation ************ -Mopidy itself is a breeze to install, as it just requires a standard Python -installation and the GStreamer library. The libraries we depend on to connect -to the Spotify service is far more tricky to get working for the time being. -Until installation of these libraries are either well documented by their -developers, or the libraries are packaged for various Linux distributions, we -will supply our own installation guides, as linked to below. +To get a basic version of Mopidy running, you need Python and the GStreamer +library. To use Spotify with Mopidy, you also need :doc:`libspotify and +pyspotify `. Mopidy itself can either be installed from the Python +package index, PyPI, or from git. Install dependencies @@ -18,12 +16,11 @@ Install dependencies gstreamer libspotify - despotify Make sure you got the required dependencies installed. - Python >= 2.6, < 3 -- :doc:`GStreamer ` (>= 0.10 ?) with Python bindings +- :doc:`GStreamer ` >= 0.10, with Python bindings - Dependencies for at least one Mopidy mixer: - :mod:`mopidy.mixers.alsa` (Linux only) @@ -44,10 +41,6 @@ Make sure you got the required dependencies installed. - Dependencies for at least one Mopidy backend: - - :mod:`mopidy.backends.despotify` (Linux and OS X) - - - :doc:`Despotify and spytify ` - - :mod:`mopidy.backends.libspotify` (Linux, OS X, and Windows) - :doc:`libspotify and pyspotify ` @@ -106,16 +99,8 @@ username and password into the file, like this:: SPOTIFY_USERNAME = u'myusername' SPOTIFY_PASSWORD = u'mysecret' -Currently :mod:`mopidy.backends.despotify` is the default -backend. - -If you want to use :mod:`mopidy.backends.libspotify`, copy the Spotify -application key to ``~/.mopidy/spotify_appkey.key``, and add the following -setting:: - - BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',) - -If you want to use :mod:`mopidy.backends.local`, add the following setting:: +Currently :mod:`mopidy.backends.libspotify` is the default backend. If you want +to use :mod:`mopidy.backends.local`, add the following setting:: BACKENDS = (u'mopidy.backends.local.LocalBackend',) diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst index 9dc9689f..b3ea06fa 100644 --- a/docs/installation/libspotify.rst +++ b/docs/installation/libspotify.rst @@ -2,15 +2,21 @@ libspotify installation *********************** -As an alternative to the despotify backend, we are working on a -`libspotify `_ backend. -To use the libspotify backend you must install libspotify and -`pyspotify `_. +Mopidy uses `libspotify +`_ for playing music from +the Spotify music service. To use :mod:`mopidy.backends.libspotify` you must +install libspotify and `pyspotify `_. .. warning:: - This backend requires a Spotify premium account, and it requires you to get - an application key from Spotify before use. + This backend requires a `Spotify premium account + `_. + +.. note:: + + This product uses SPOTIFY CORE but is not endorsed, certified or otherwise + approved in any way by Spotify. Spotify is the registered trade mark of the + Spotify Group. Installing libspotify on Linux @@ -57,22 +63,20 @@ Installing pyspotify Install pyspotify's dependencies. At Debian/Ubuntu systems:: - sudo aptitude install python-dev python-alsaaudio + sudo aptitude install python-dev + +In OS X no additional dependencies are needed. Check out the pyspotify code, and install it:: git clone git://github.com/jodal/pyspotify.git cd pyspotify/pyspotify/ + sudo rm -rf build/ # If you are upgrading pyspotify sudo python setup.py install +.. note:: -Testing the installation -======================== - -Apply for an application key at -https://developer.spotify.com/en/libspotify/application-key, download the -binary version, and place the file at ``pyspotify/spotify_appkey.key``. - -Test your libspotify setup:: - - examples/example1.py -u USERNAME -p PASSWORD + The ``sudo rm -rf build/`` step is needed if you are upgrading pyspotify. + Simply running ``python setup.py clean`` will *not* clean out the C parts + of the ``build/`` directory, and you will thus not get any changes to the C + code included in your installation. diff --git a/docs/licenses.rst b/docs/licenses.rst index c7bf9433..c3a13904 100644 --- a/docs/licenses.rst +++ b/docs/licenses.rst @@ -2,7 +2,7 @@ Licenses ******** -For a list of contributors, see :ref:`authors`. For details on who have +For a list of contributors, see :doc:`authors`. For details on who have contributed what, please refer to our git repository. Source code license diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 09d72b26..e3321041 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -2,8 +2,6 @@ import sys if not (2, 6) <= sys.version_info < (3,): sys.exit(u'Mopidy requires Python >= 2.6, < 3') -from mopidy import settings as raw_settings - def get_version(): return u'0.1.0a4' @@ -27,13 +25,6 @@ class MopidyException(Exception): class SettingsError(MopidyException): pass -class Settings(object): - def __getattr__(self, attr): - if attr.isupper() and not hasattr(raw_settings, attr): - raise SettingsError(u'Setting "%s" is not set.' % attr) - value = getattr(raw_settings, attr) - if type(value) != bool and not value: - raise SettingsError(u'Setting "%s" is empty.' % attr) - return value - -settings = Settings() +from mopidy import settings as default_settings_module +from mopidy.utils.settings import SettingsProxy +settings = SettingsProxy(default_settings_module) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 7c62033b..a2230180 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -11,18 +11,24 @@ sys.path.insert(0, from mopidy import get_version, settings, SettingsError from mopidy.process import CoreProcess -from mopidy.utils import get_class, get_or_create_folder +from mopidy.utils import get_class +from mopidy.utils.path import get_or_create_folder +from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.main') def main(): options = _parse_options() _setup_logging(options.verbosity_level, options.dump) + settings.validate() logger.info('-- Starting Mopidy --') get_or_create_folder('~/.mopidy/') core_queue = multiprocessing.Queue() get_class(settings.SERVER)(core_queue).start() - core = CoreProcess(core_queue) + output_class = get_class(settings.OUTPUT) + backend_class = get_class(settings.BACKENDS[0]) + frontend_class = get_class(settings.FRONTEND) + core = CoreProcess(core_queue, output_class, backend_class, frontend_class) core.start() asyncore.loop() @@ -37,6 +43,9 @@ def _parse_options(): parser.add_option('--dump', action='store_true', dest='dump', help='dump debug log to file') + parser.add_option('--list-settings', + action='callback', callback=list_settings_optparse_callback, + help='list current settings') return parser.parse_args()[0] def _setup_logging(verbosity_level, dump): diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 7e4d47b4..80c4d0c0 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -23,17 +23,20 @@ class BaseBackend(object): :param core_queue: a queue for sending messages to :class:`mopidy.process.CoreProcess` :type core_queue: :class:`multiprocessing.Queue` - :param mixer: either a mixer instance, or :class:`None` to use the mixer + :param output_queue: a queue for sending messages to the output process + :type output_queue: :class:`multiprocessing.Queue` + :param mixer_class: either a mixer class, or :class:`None` to use the mixer defined in settings - :type mixer: :class:`mopidy.mixers.BaseMixer` or :class:`None` + :type mixer_class: a subclass of :class:`mopidy.mixers.BaseMixer` or + :class:`None` """ - def __init__(self, core_queue=None, mixer=None): + def __init__(self, core_queue=None, output_queue=None, mixer_class=None): self.core_queue = core_queue - if mixer is not None: - self.mixer = mixer - else: - self.mixer = get_class(settings.MIXER)() + self.output_queue = output_queue + if mixer_class is None: + mixer_class = get_class(settings.MIXER) + self.mixer = mixer_class(self) #: A :class:`multiprocessing.Queue` which can be used by e.g. library #: callbacks executing in other threads to send messages to the core diff --git a/mopidy/backends/base/current_playlist.py b/mopidy/backends/base/current_playlist.py index fc17bbee..c8c83a62 100644 --- a/mopidy/backends/base/current_playlist.py +++ b/mopidy/backends/base/current_playlist.py @@ -64,12 +64,23 @@ class BaseCurrentPlaylistController(object): self.version += 1 return cp_track + def append(self, tracks): + """ + Append the given tracks to the current playlist. + + :param tracks: tracks to append + :type tracks: list of :class:`mopidy.models.Track` + """ + self.version += 1 + for track in tracks: + self.add(track) + self.backend.playback.on_current_playlist_change() + def clear(self): """Clear the current playlist.""" - self.backend.playback.stop() - self.backend.playback.current_cp_track = None self._cp_tracks = [] self.version += 1 + self.backend.playback.on_current_playlist_change() def get(self, **criteria): """ @@ -105,19 +116,6 @@ class BaseCurrentPlaylistController(object): else: raise LookupError(u'"%s" match multiple tracks' % criteria_string) - def load(self, tracks): - """ - Replace the tracks in the current playlist with the given tracks. - - :param tracks: tracks to load - :type tracks: list of :class:`mopidy.models.Track` - """ - self._cp_tracks = [] - self.version += 1 - for track in tracks: - self.add(track) - self.backend.playback.new_playlist_loaded_callback() - def move(self, start, end, to_position): """ Move the tracks in the slice ``[start:end]`` to ``to_position``. @@ -148,6 +146,7 @@ class BaseCurrentPlaylistController(object): to_position += 1 self._cp_tracks = new_cp_tracks self.version += 1 + self.backend.playback.on_current_playlist_change() def remove(self, **criteria): """ @@ -192,6 +191,7 @@ class BaseCurrentPlaylistController(object): random.shuffle(shuffled) self._cp_tracks = before + shuffled + after self.version += 1 + self.backend.playback.on_current_playlist_change() def mpd_format(self, *args, **kwargs): """Not a part of the generic backend API.""" diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 88617d83..d1acc05a 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -25,7 +25,7 @@ class BasePlaybackController(object): #: Tracks are not removed from the playlist. consume = False - #: The currently playing or selected track + #: The currently playing or selected track. #: #: A two-tuple of (CPID integer, :class:`mopidy.models.Track`) or #: :class:`None`. @@ -45,7 +45,8 @@ class BasePlaybackController(object): repeat = False #: :class:`True` - #: Playback is stopped after current song, unless in repeat mode. + #: Playback is stopped after current song, unless in :attr:`repeat` + #: mode. #: :class:`False` #: Playback continues after current song. single = False @@ -59,19 +60,32 @@ class BasePlaybackController(object): self._play_time_started = None def destroy(self): - """Cleanup after component.""" + """ + Cleanup after component. + + May be overridden by subclasses. + """ pass + def _get_cpid(self, cp_track): + if cp_track is None: + return None + return cp_track[0] + + def _get_track(self, cp_track): + if cp_track is None: + return None + return cp_track[1] + @property def current_cpid(self): """ - The CPID (current playlist ID) of :attr:`current_track`. + The CPID (current playlist ID) of the currently playing or selected + track. Read-only. Extracted from :attr:`current_cp_track` for convenience. """ - if self.current_cp_track is None: - return None - return self.current_cp_track[0] + return self._get_cpid(self.current_cp_track) @property def current_track(self): @@ -80,13 +94,15 @@ class BasePlaybackController(object): Read-only. Extracted from :attr:`current_cp_track` for convenience. """ - if self.current_cp_track is None: - return None - return self.current_cp_track[1] + return self._get_track(self.current_cp_track) @property def current_playlist_position(self): - """The position of the current track in the current playlist.""" + """ + The position of the current track in the current playlist. + + Read-only. + """ if self.current_cp_track is None: return None try: @@ -96,24 +112,71 @@ class BasePlaybackController(object): return None @property - def next_track(self): + def track_at_eot(self): """ - The next track in the playlist. + The track that will be played at the end of the current track. - A :class:`mopidy.models.Track` extracted from :attr:`next_cp_track` for - convenience. + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_eot` for convenience. """ - next_cp_track = self.next_cp_track - if next_cp_track is None: - return None - return next_cp_track[1] + return self._get_track(self.cp_track_at_eot) @property - def next_cp_track(self): + def cp_track_at_eot(self): """ - The next track in the playlist. + The track that will be played at the end of the current track. - A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + Not necessarily the same track as :attr:`cp_track_at_next`. + """ + cp_tracks = self.backend.current_playlist.cp_tracks + + if not cp_tracks: + return None + + if self.random and not self._shuffled: + if self.repeat or self._first_shuffle: + logger.debug('Shuffling tracks') + self._shuffled = cp_tracks + random.shuffle(self._shuffled) + self._first_shuffle = False + + if self._shuffled: + return self._shuffled[0] + + if self.current_cp_track is None: + return cp_tracks[0] + + if self.repeat and self.single: + return cp_tracks[ + (self.current_playlist_position) % len(cp_tracks)] + + if self.repeat: + return cp_tracks[ + (self.current_playlist_position + 1) % len(cp_tracks)] + + try: + return cp_tracks[self.current_playlist_position + 1] + except IndexError: + return None + + @property + def track_at_next(self): + """ + The track that will be played if calling :meth:`next()`. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_next` for convenience. + """ + return self._get_track(self.cp_track_at_next) + + @property + def cp_track_at_next(self): + """ + The track that will be played if calling :meth:`next()`. + + Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). For normal playback this is the next track in the playlist. If repeat is enabled the next track can loop around the playlist. When random is @@ -148,22 +211,19 @@ class BasePlaybackController(object): return None @property - def previous_track(self): + def track_at_previous(self): """ - The previous track in the playlist. + The track that will be played if calling :meth:`previous()`. - A :class:`mopidy.models.Track` extracted from :attr:`previous_cp_track` - for convenience. + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_previous` for convenience. """ - previous_cp_track = self.previous_cp_track - if previous_cp_track is None: - return None - return previous_cp_track[1] + return self._get_track(self.cp_track_at_previous) @property - def previous_cp_track(self): + def cp_track_at_previous(self): """ - The previous track in the playlist. + The track that will be played if calling :meth:`previous()`. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). @@ -240,109 +300,125 @@ class BasePlaybackController(object): def _current_wall_time(self): return int(time.time() * 1000) - def end_of_track_callback(self): + def on_end_of_track(self): """ Tell the playback controller that end of track is reached. Typically called by :class:`mopidy.process.CoreProcess` after a message from a library thread is received. """ - if self.next_cp_track is not None: - self.next() + if self.state == self.STOPPED: + return + + original_cp_track = self.current_cp_track + if self.cp_track_at_eot: + self.play(self.cp_track_at_eot) + + if self.random and self.current_cp_track in self._shuffled: + self._shuffled.remove(self.current_cp_track) else: self.stop() self.current_cp_track = None - def new_playlist_loaded_callback(self): - """ - Tell the playback controller that a new playlist has been loaded. + if self.consume: + self.backend.current_playlist.remove(cpid=original_cp_track[0]) - Typically called by :class:`mopidy.process.CoreProcess` after a message - from a library thread is received. + def on_current_playlist_change(self): + """ + Tell the playback controller that the current playlist has changed. + + Used by :class:`mopidy.backends.base.BaseCurrentPlaylistController`. """ - self.current_cp_track = None self._first_shuffle = True self._shuffled = [] - if self.state == self.PLAYING: - if len(self.backend.current_playlist.tracks) > 0: - self.play() - else: - self.stop() - elif self.state == self.PAUSED: + if not self.backend.current_playlist.cp_tracks: + self.stop() + self.current_cp_track = None + elif (self.current_cp_track not in + self.backend.current_playlist.cp_tracks): + self.current_cp_track = None self.stop() def next(self): """Play the next track.""" - original_cp_track = self.current_cp_track - if self.state == self.STOPPED: return - elif self.next_cp_track is not None and self._next(self.next_track): - self.current_cp_track = self.next_cp_track - self.state = self.PLAYING - elif self.next_cp_track is None: + + if self.cp_track_at_next: + self.play(self.cp_track_at_next) + else: self.stop() self.current_cp_track = None - # FIXME handle in play aswell? - if self.consume: - self.backend.current_playlist.remove(cpid=original_cp_track[0]) - if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) - def _next(self, track): - return self._play(track) - def pause(self): """Pause playback.""" if self.state == self.PLAYING and self._pause(): self.state = self.PAUSED def _pause(self): + """ + To be overridden by subclass. Implement your backend's pause + functionality here. + + :rtype: :class:`True` if successful, else :class:`False` + """ raise NotImplementedError - def play(self, cp_track=None): + def play(self, cp_track=None, on_error_step=1): """ - Play the given track or the currently active track. + Play the given track, or if the given track is :class:`None`, play the + currently active track. :param cp_track: track to play :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) or :class:`None` + :param on_error_step: direction to step at play error, 1 for next + track (default), -1 for previous track + :type on_error_step: int, -1 or 1 """ if cp_track is not None: assert cp_track in self.backend.current_playlist.cp_tracks elif not self.current_cp_track: - cp_track = self.next_cp_track + cp_track = self.cp_track_at_next if self.state == self.PAUSED and cp_track is None: self.resume() - elif cp_track is not None and self._play(cp_track[1]): + elif cp_track is not None: self.current_cp_track = cp_track self.state = self.PLAYING - - # TODO Do something sensible when _play() returns False, like calling - # next(). Adding this todo instead of just implementing it as I want a - # test case first. + if not self._play(cp_track[1]): + if on_error_step == 1: + self.next() + elif on_error_step == -1: + self.previous() if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) def _play(self, track): + """ + To be overridden by subclass. Implement your backend's play + functionality here. + + :param track: the track to play + :type track: :class:`mopidy.models.Track` + :rtype: :class:`True` if successful, else :class:`False` + """ + raise NotImplementedError def previous(self): """Play the previous track.""" - if (self.previous_cp_track is not None - and self.state != self.STOPPED - and self._previous(self.previous_track)): - self.current_cp_track = self.previous_cp_track - self.state = self.PLAYING - - def _previous(self, track): - return self._play(track) + if self.cp_track_at_previous is None: + return + if self.state == self.STOPPED: + return + self.play(self.cp_track_at_previous, on_error_step=-1) def resume(self): """If paused, resume playing the current track.""" @@ -350,6 +426,12 @@ class BasePlaybackController(object): self.state = self.PLAYING def _resume(self): + """ + To be overridden by subclass. Implement your backend's resume + functionality here. + + :rtype: :class:`True` if successful, else :class:`False` + """ raise NotImplementedError def seek(self, time_position): @@ -370,9 +452,20 @@ class BasePlaybackController(object): self.next() return + self._play_time_started = self._current_wall_time + self._play_time_accumulated = time_position + self._seek(time_position) def _seek(self, time_position): + """ + To be overridden by subclass. Implement your backend's seek + functionality here. + + :param time_position: time position in milliseconds + :type time_position: int + :rtype: :class:`True` if successful, else :class:`False` + """ raise NotImplementedError def stop(self): @@ -381,4 +474,10 @@ class BasePlaybackController(object): self.state = self.STOPPED def _stop(self): + """ + To be overridden by subclass. Implement your backend's stop + functionality here. + + :rtype: :class:`True` if successful, else :class:`False` + """ raise NotImplementedError diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py index 31185cd4..61722c81 100644 --- a/mopidy/backends/base/stored_playlists.py +++ b/mopidy/backends/base/stored_playlists.py @@ -107,13 +107,3 @@ class BaseStoredPlaylistsController(object): :type playlist: :class:`mopidy.models.Playlist` """ raise NotImplementedError - - def search(self, query): - """ - Search for playlists whose name contains ``query``. - - :param query: query to search for - :type query: string - :rtype: list of :class:`mopidy.models.Playlist` - """ - return filter(lambda p: query in p.name, self._playlists) diff --git a/mopidy/backends/despotify/__init__.py b/mopidy/backends/despotify/__init__.py deleted file mode 100644 index 78c7f774..00000000 --- a/mopidy/backends/despotify/__init__.py +++ /dev/null @@ -1,209 +0,0 @@ -import datetime as dt -import logging -import sys - -import spytify - -from mopidy import settings -from mopidy.backends.base import (BaseBackend, BaseCurrentPlaylistController, - BaseLibraryController, BasePlaybackController, - BaseStoredPlaylistsController) -from mopidy.models import Artist, Album, Track, Playlist - -logger = logging.getLogger('mopidy.backends.despotify') - -ENCODING = 'utf-8' - -class DespotifyBackend(BaseBackend): - """ - A Spotify backend which uses the open source `despotify library - `_. - - `spytify `_ - is the Python bindings for the despotify library. It got litle - documentation, but a couple of examples are available. - - **Issues:** http://github.com/jodal/mopidy/issues/labels/backend-despotify - """ - - def __init__(self, *args, **kwargs): - super(DespotifyBackend, self).__init__(*args, **kwargs) - self.current_playlist = DespotifyCurrentPlaylistController(backend=self) - self.library = DespotifyLibraryController(backend=self) - self.playback = DespotifyPlaybackController(backend=self) - self.stored_playlists = DespotifyStoredPlaylistsController(backend=self) - self.uri_handlers = [u'spotify:', u'http://open.spotify.com/'] - self.spotify = self._connect() - self.stored_playlists.refresh() - - def _connect(self): - logger.info(u'Connecting to Spotify') - try: - return DespotifySessionManager( - settings.SPOTIFY_USERNAME.encode(ENCODING), - settings.SPOTIFY_PASSWORD.encode(ENCODING), - core_queue=self.core_queue) - except spytify.SpytifyError as e: - logger.exception(e) - sys.exit(1) - - -class DespotifyCurrentPlaylistController(BaseCurrentPlaylistController): - pass - - -class DespotifyLibraryController(BaseLibraryController): - def find_exact(self, **query): - return self.search(**query) - - def lookup(self, uri): - track = self.backend.spotify.lookup(uri.encode(ENCODING)) - return DespotifyTranslator.to_mopidy_track(track) - - def refresh(self, uri=None): - pass # TODO - - def search(self, **query): - spotify_query = [] - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - for value in values: - if field == u'track': - field = u'title' - if field == u'any': - spotify_query.append(value) - else: - spotify_query.append(u'%s:"%s"' % (field, value)) - spotify_query = u' '.join(spotify_query) - logger.debug(u'Spotify search query: %s', spotify_query) - result = self.backend.spotify.search(spotify_query.encode(ENCODING)) - if (result is None or result.playlist.tracks[0].get_uri() == - 'spotify:track:0000000000000000000000'): - return Playlist() - return DespotifyTranslator.to_mopidy_playlist(result.playlist) - - -class DespotifyPlaybackController(BasePlaybackController): - def _pause(self): - try: - self.backend.spotify.pause() - return True - except spytify.SpytifyError as e: - logger.error(e) - return False - - def _play(self, track): - try: - self.backend.spotify.play(self.backend.spotify.lookup(track.uri)) - return True - except spytify.SpytifyError as e: - logger.error(e) - return False - - def _resume(self): - try: - self.backend.spotify.resume() - return True - except spytify.SpytifyError as e: - logger.error(e) - return False - - def _seek(self, time_position): - pass # TODO - - def _stop(self): - try: - self.backend.spotify.stop() - return True - except spytify.SpytifyError as e: - logger.error(e) - return False - - -class DespotifyStoredPlaylistsController(BaseStoredPlaylistsController): - def create(self, name): - pass # TODO - - def delete(self, playlist): - pass # TODO - - def lookup(self, uri): - pass # TODO - - def refresh(self): - logger.info(u'Caching stored playlists') - playlists = [] - for spotify_playlist in self.backend.spotify.stored_playlists: - playlists.append( - DespotifyTranslator.to_mopidy_playlist(spotify_playlist)) - self._playlists = playlists - logger.debug(u'Available playlists: %s', - u', '.join([u'<%s>' % p.name for p in self.playlists])) - logger.info(u'Done caching stored playlists') - - def rename(self, playlist, new_name): - pass # TODO - - def save(self, playlist): - pass # TODO - - -class DespotifyTranslator(object): - @classmethod - def to_mopidy_artist(cls, spotify_artist): - return Artist( - uri=spotify_artist.get_uri(), - name=spotify_artist.name.decode(ENCODING) - ) - - @classmethod - def to_mopidy_album(cls, spotify_album_name): - return Album(name=spotify_album_name.decode(ENCODING)) - - @classmethod - def to_mopidy_track(cls, spotify_track): - if spotify_track is None or not spotify_track.has_meta_data(): - return None - if dt.MINYEAR <= int(spotify_track.year) <= dt.MAXYEAR: - date = dt.date(spotify_track.year, 1, 1) - else: - date = None - return Track( - uri=spotify_track.get_uri(), - name=spotify_track.title.decode(ENCODING), - artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists], - album=cls.to_mopidy_album(spotify_track.album), - track_no=spotify_track.tracknumber, - date=date, - length=spotify_track.length, - bitrate=320, - ) - - @classmethod - def to_mopidy_playlist(cls, spotify_playlist): - return Playlist( - uri=spotify_playlist.get_uri(), - name=spotify_playlist.name.decode(ENCODING), - tracks=filter(None, - [cls.to_mopidy_track(t) for t in spotify_playlist.tracks]), - ) - - -class DespotifySessionManager(spytify.Spytify): - DESPOTIFY_NEW_TRACK = 1 - DESPOTIFY_TIME_TELL = 2 - DESPOTIFY_END_OF_PLAYLIST = 3 - DESPOTIFY_TRACK_PLAY_ERROR = 4 - - def __init__(self, *args, **kwargs): - kwargs['callback'] = self.callback - self.core_queue = kwargs.pop('core_queue') - super(DespotifySessionManager, self).__init__(*args, **kwargs) - - def callback(self, signal, data): - if signal == self.DESPOTIFY_END_OF_PLAYLIST: - logger.debug('Despotify signalled end of playlist') - self.core_queue.put({'command': 'end_of_track'}) - elif signal == self.DESPOTIFY_TRACK_PLAY_ERROR: - logger.error('Despotify signalled track play error') diff --git a/mopidy/backends/libspotify/__init__.py b/mopidy/backends/libspotify/__init__.py index bbbb5556..f00ec1f0 100644 --- a/mopidy/backends/libspotify/__init__.py +++ b/mopidy/backends/libspotify/__init__.py @@ -1,20 +1,7 @@ -import datetime as dt import logging -import os -import multiprocessing -import threading -from spotify import Link, SpotifyError -from spotify.manager import SpotifySessionManager -from spotify.alsahelper import AlsaController - -from mopidy import get_version, settings -from mopidy.backends.base import (BaseBackend, BaseCurrentPlaylistController, - BaseLibraryController, BasePlaybackController, - BaseStoredPlaylistsController) -from mopidy.models import Artist, Album, Track, Playlist - -import alsaaudio +from mopidy import settings +from mopidy.backends.base import BaseBackend, BaseCurrentPlaylistController logger = logging.getLogger('mopidy.backends.libspotify') @@ -22,269 +9,46 @@ ENCODING = 'utf-8' class LibspotifyBackend(BaseBackend): """ - A Spotify backend which uses the official `libspotify library - `_. - - `pyspotify `_ is the Python bindings - for libspotify. It got no documentation, but multiple examples are - available. Like libspotify, pyspotify's calls are mostly asynchronous. - - This backend should also work with `openspotify - `_, but we haven't tested - that yet. + A `Spotify `_ backend which uses the official + `libspotify `_ + library and the `pyspotify `_ Python + bindings for libspotify. **Issues:** http://github.com/jodal/mopidy/issues/labels/backend-libspotify + + .. note:: + + This product uses SPOTIFY(R) CORE but is not endorsed, certified or + otherwise approved in any way by Spotify. Spotify is the registered + trade mark of the Spotify Group. """ + # Imports inside methods are to prevent loading of __init__.py to fail on + # missing spotify dependencies. + def __init__(self, *args, **kwargs): + from .library import LibspotifyLibraryController + from .playback import LibspotifyPlaybackController + from .stored_playlists import LibspotifyStoredPlaylistsController + super(LibspotifyBackend, self).__init__(*args, **kwargs) + self.current_playlist = BaseCurrentPlaylistController(backend=self) self.library = LibspotifyLibraryController(backend=self) self.playback = LibspotifyPlaybackController(backend=self) self.stored_playlists = LibspotifyStoredPlaylistsController( backend=self) self.uri_handlers = [u'spotify:', u'http://open.spotify.com/'] - self.audio_controller_class = kwargs.get( - 'audio_controller_class', AlsaController) self.spotify = self._connect() def _connect(self): + from .session_manager import LibspotifySessionManager + + logger.info(u'Mopidy uses SPOTIFY(R) CORE') logger.info(u'Connecting to Spotify') spotify = LibspotifySessionManager( settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD, core_queue=self.core_queue, - audio_controller_class=self.audio_controller_class) + output_queue=self.output_queue) spotify.start() return spotify - - -class LibspotifyLibraryController(BaseLibraryController): - def find_exact(self, **query): - return self.search(**query) - - def lookup(self, uri): - spotify_track = Link.from_string(uri).as_track() - return LibspotifyTranslator.to_mopidy_track(spotify_track) - - def refresh(self, uri=None): - pass # TODO - - def search(self, **query): - spotify_query = [] - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - for value in values: - if field == u'track': - field = u'title' - if field == u'any': - spotify_query.append(value) - else: - spotify_query.append(u'%s:"%s"' % (field, value)) - spotify_query = u' '.join(spotify_query) - logger.debug(u'In search method, search for: %s' % spotify_query) - my_end, other_end = multiprocessing.Pipe() - self.backend.spotify.search(spotify_query.encode(ENCODING), other_end) - logger.debug(u'In Library.search(), waiting for search results') - my_end.poll(None) - logger.debug(u'In Library.search(), receiving search results') - playlist = my_end.recv() - logger.debug(u'In Library.search(), done receiving search results') - logger.debug(['%s' % t.name for t in playlist.tracks]) - return playlist - - -class LibspotifyPlaybackController(BasePlaybackController): - def _pause(self): - # TODO - return False - - def _play(self, track): - if self.state == self.PLAYING: - self.stop() - if track.uri is None: - return False - try: - self.backend.spotify.session.load( - Link.from_string(track.uri).as_track()) - self.backend.spotify.session.play(1) - return True - except SpotifyError as e: - logger.warning('Play %s failed: %s', track.uri, e) - return False - - def _resume(self): - # TODO - return False - - def _seek(self, time_position): - pass # TODO - - def _stop(self): - self.backend.spotify.session.play(0) - return True - - -class LibspotifyStoredPlaylistsController(BaseStoredPlaylistsController): - def create(self, name): - pass # TODO - - def delete(self, playlist): - pass # TODO - - def lookup(self, uri): - pass # TODO - - def refresh(self): - pass # TODO - - def rename(self, playlist, new_name): - pass # TODO - - def save(self, playlist): - pass # TODO - - -class LibspotifyTranslator(object): - @classmethod - def to_mopidy_artist(cls, spotify_artist): - if not spotify_artist.is_loaded(): - return Artist(name=u'[loading...]') - return Artist( - uri=str(Link.from_artist(spotify_artist)), - name=spotify_artist.name().decode(ENCODING), - ) - - @classmethod - def to_mopidy_album(cls, spotify_album): - if not spotify_album.is_loaded(): - return Album(name=u'[loading...]') - # TODO pyspotify got much more data on albums than this - return Album(name=spotify_album.name().decode(ENCODING)) - - @classmethod - def to_mopidy_track(cls, spotify_track): - if not spotify_track.is_loaded(): - return Track(name=u'[loading...]') - uri = str(Link.from_track(spotify_track, 0)) - if dt.MINYEAR <= int(spotify_track.album().year()) <= dt.MAXYEAR: - date = dt.date(spotify_track.album().year(), 1, 1) - else: - date = None - return Track( - uri=uri, - name=spotify_track.name().decode(ENCODING), - artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists()], - album=cls.to_mopidy_album(spotify_track.album()), - track_no=spotify_track.index(), - date=date, - length=spotify_track.duration(), - bitrate=320, - ) - - @classmethod - def to_mopidy_playlist(cls, spotify_playlist): - if not spotify_playlist.is_loaded(): - return Playlist(name=u'[loading...]') - return Playlist( - uri=str(Link.from_playlist(spotify_playlist)), - name=spotify_playlist.name().decode(ENCODING), - tracks=[cls.to_mopidy_track(t) for t in spotify_playlist], - ) - - -class LibspotifySessionManager(SpotifySessionManager, threading.Thread): - cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) - settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) - appkey_file = os.path.expanduser(settings.SPOTIFY_LIB_APPKEY) - user_agent = 'Mopidy %s' % get_version() - - def __init__(self, username, password, core_queue, audio_controller_class): - SpotifySessionManager.__init__(self, username, password) - threading.Thread.__init__(self) - self.core_queue = core_queue - self.connected = threading.Event() - self.audio = audio_controller_class(alsaaudio.PCM_NORMAL) - self.session = None - - def run(self): - self.connect() - - def logged_in(self, session, error): - """Callback used by pyspotify""" - logger.info('Logged in') - self.session = session - self.connected.set() - - def logged_out(self, session): - """Callback used by pyspotify""" - logger.info('Logged out') - - def metadata_updated(self, session): - """Callback used by pyspotify""" - logger.debug('Metadata updated, refreshing stored playlists') - playlists = [] - for spotify_playlist in session.playlist_container(): - playlists.append( - LibspotifyTranslator.to_mopidy_playlist(spotify_playlist)) - self.core_queue.put({ - 'command': 'set_stored_playlists', - 'playlists': playlists, - }) - - def connection_error(self, session, error): - """Callback used by pyspotify""" - logger.error('Connection error: %s', error) - - def message_to_user(self, session, message): - """Callback used by pyspotify""" - logger.info(message) - - def notify_main_thread(self, session): - """Callback used by pyspotify""" - logger.debug('Notify main thread') - - def music_delivery(self, session, frames, frame_size, num_frames, - sample_type, sample_rate, channels): - """Callback used by pyspotify""" - self.audio.music_delivery(session, frames, frame_size, num_frames, - sample_type, sample_rate, channels) - - def play_token_lost(self, session): - """Callback used by pyspotify""" - logger.debug('Play token lost') - self.core_queue.put({'command': 'stop_playback'}) - - def log_message(self, session, data): - """Callback used by pyspotify""" - logger.debug(data) - - def end_of_track(self, session): - """Callback used by pyspotify""" - logger.debug('End of track') - self.core_queue.put({'command': 'end_of_track'}) - - def search(self, query, connection): - """Search method used by Mopidy backend""" - def callback(results, userdata): - logger.debug(u'In SessionManager.search().callback(), ' - 'translating search results') - logger.debug(results.tracks()) - # TODO Include results from results.albums(), etc. too - playlist = Playlist(tracks=[ - LibspotifyTranslator.to_mopidy_track(t) - for t in results.tracks()]) - logger.debug(u'In SessionManager.search().callback(), ' - 'sending search results') - logger.debug(['%s' % t.name for t in playlist.tracks]) - connection.send(playlist) - logger.debug(u'In SessionManager.search().callback(), ' - 'done sending search results') - logger.debug(u'In SessionManager.search(), ' - 'waiting for Spotify connection') - self.connected.wait() - logger.debug(u'In SessionManager.search(), ' - 'sending search query') - self.session.search(query, callback) - logger.debug(u'In SessionManager.search(), ' - 'done sending search query') diff --git a/mopidy/backends/libspotify/library.py b/mopidy/backends/libspotify/library.py new file mode 100644 index 00000000..c2b70dca --- /dev/null +++ b/mopidy/backends/libspotify/library.py @@ -0,0 +1,41 @@ +import logging +import multiprocessing + +from spotify import Link + +from mopidy.backends.base import BaseLibraryController +from mopidy.backends.libspotify import ENCODING +from mopidy.backends.libspotify.translator import LibspotifyTranslator + +logger = logging.getLogger('mopidy.backends.libspotify.library') + +class LibspotifyLibraryController(BaseLibraryController): + def find_exact(self, **query): + return self.search(**query) + + def lookup(self, uri): + spotify_track = Link.from_string(uri).as_track() + return LibspotifyTranslator.to_mopidy_track(spotify_track) + + def refresh(self, uri=None): + pass # TODO + + def search(self, **query): + spotify_query = [] + for (field, values) in query.iteritems(): + if not hasattr(values, '__iter__'): + values = [values] + for value in values: + if field == u'track': + field = u'title' + if field == u'any': + spotify_query.append(value) + else: + spotify_query.append(u'%s:"%s"' % (field, value)) + spotify_query = u' '.join(spotify_query) + logger.debug(u'Spotify search query: %s' % spotify_query) + my_end, other_end = multiprocessing.Pipe() + self.backend.spotify.search(spotify_query.encode(ENCODING), other_end) + my_end.poll(None) + playlist = my_end.recv() + return playlist diff --git a/mopidy/backends/libspotify/playback.py b/mopidy/backends/libspotify/playback.py new file mode 100644 index 00000000..1195e9bc --- /dev/null +++ b/mopidy/backends/libspotify/playback.py @@ -0,0 +1,54 @@ +import logging +import multiprocessing + +from spotify import Link, SpotifyError + +from mopidy.backends.base import BasePlaybackController +from mopidy.process import pickle_connection + +logger = logging.getLogger('mopidy.backends.libspotify.playback') + +class LibspotifyPlaybackController(BasePlaybackController): + def _set_output_state(self, state_name): + logger.debug(u'Setting output state to %s ...', state_name) + (my_end, other_end) = multiprocessing.Pipe() + self.backend.output_queue.put({ + 'command': 'set_state', + 'state': state_name, + 'reply_to': pickle_connection(other_end), + }) + my_end.poll(None) + return my_end.recv() + + def _pause(self): + return self._set_output_state('PAUSED') + + def _play(self, track): + self._set_output_state('READY') + if self.state == self.PLAYING: + self.backend.spotify.session.play(0) + if track.uri is None: + return False + try: + self.backend.spotify.session.load( + Link.from_string(track.uri).as_track()) + self.backend.spotify.session.play(1) + self._set_output_state('PLAYING') + return True + except SpotifyError as e: + logger.warning('Play %s failed: %s', track.uri, e) + return False + + def _resume(self): + return self._seek(self.time_position) + + def _seek(self, time_position): + self._set_output_state('READY') + self.backend.spotify.session.seek(time_position) + self._set_output_state('PLAYING') + return True + + def _stop(self): + result = self._set_output_state('READY') + self.backend.spotify.session.play(0) + return result diff --git a/mopidy/backends/libspotify/session_manager.py b/mopidy/backends/libspotify/session_manager.py new file mode 100644 index 00000000..707423aa --- /dev/null +++ b/mopidy/backends/libspotify/session_manager.py @@ -0,0 +1,106 @@ +import logging +import os +import threading + +from spotify.manager import SpotifySessionManager + +from mopidy import get_version, settings +from mopidy.models import Playlist +from mopidy.backends.libspotify.translator import LibspotifyTranslator + +logger = logging.getLogger('mopidy.backends.libspotify.session_manager') + +class LibspotifySessionManager(SpotifySessionManager, threading.Thread): + cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) + settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) + appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') + user_agent = 'Mopidy %s' % get_version() + + def __init__(self, username, password, core_queue, output_queue): + SpotifySessionManager.__init__(self, username, password) + threading.Thread.__init__(self) + self.core_queue = core_queue + self.output_queue = output_queue + self.connected = threading.Event() + self.session = None + + def run(self): + self.connect() + + def logged_in(self, session, error): + """Callback used by pyspotify""" + logger.info('Logged in') + self.session = session + self.connected.set() + + def logged_out(self, session): + """Callback used by pyspotify""" + logger.info('Logged out') + + def metadata_updated(self, session): + """Callback used by pyspotify""" + logger.debug('Metadata updated, refreshing stored playlists') + playlists = [] + for spotify_playlist in session.playlist_container(): + playlists.append( + LibspotifyTranslator.to_mopidy_playlist(spotify_playlist)) + self.core_queue.put({ + 'command': 'set_stored_playlists', + 'playlists': playlists, + }) + + def connection_error(self, session, error): + """Callback used by pyspotify""" + logger.error('Connection error: %s', error) + + def message_to_user(self, session, message): + """Callback used by pyspotify""" + logger.info(message.strip()) + + def notify_main_thread(self, session): + """Callback used by pyspotify""" + logger.debug('Notify main thread') + + def music_delivery(self, session, frames, frame_size, num_frames, + sample_type, sample_rate, channels): + """Callback used by pyspotify""" + # TODO Base caps_string on arguments + caps_string = """ + audio/x-raw-int, + endianness=(int)1234, + channels=(int)2, + width=(int)16, + depth=(int)16, + signed=True, + rate=(int)44100 + """ + self.output_queue.put({ + 'command': 'deliver_data', + 'caps': caps_string, + 'data': bytes(frames), + }) + + def play_token_lost(self, session): + """Callback used by pyspotify""" + logger.debug('Play token lost') + self.core_queue.put({'command': 'stop_playback'}) + + def log_message(self, session, data): + """Callback used by pyspotify""" + logger.debug(data.strip()) + + def end_of_track(self, session): + """Callback used by pyspotify""" + logger.debug('End of data stream.') + self.output_queue.put({'command': 'end_of_data_stream'}) + + def search(self, query, connection): + """Search method used by Mopidy backend""" + def callback(results, userdata): + # TODO Include results from results.albums(), etc. too + playlist = Playlist(tracks=[ + LibspotifyTranslator.to_mopidy_track(t) + for t in results.tracks()]) + connection.send(playlist) + self.connected.wait() + self.session.search(query, callback) diff --git a/mopidy/backends/libspotify/spotify_appkey.key b/mopidy/backends/libspotify/spotify_appkey.key new file mode 100644 index 00000000..1f840b96 Binary files /dev/null and b/mopidy/backends/libspotify/spotify_appkey.key differ diff --git a/mopidy/backends/libspotify/stored_playlists.py b/mopidy/backends/libspotify/stored_playlists.py new file mode 100644 index 00000000..3339578c --- /dev/null +++ b/mopidy/backends/libspotify/stored_playlists.py @@ -0,0 +1,20 @@ +from mopidy.backends.base import BaseStoredPlaylistsController + +class LibspotifyStoredPlaylistsController(BaseStoredPlaylistsController): + def create(self, name): + pass # TODO + + def delete(self, playlist): + pass # TODO + + def lookup(self, uri): + pass # TODO + + def refresh(self): + pass # TODO + + def rename(self, playlist, new_name): + pass # TODO + + def save(self, playlist): + pass # TODO diff --git a/mopidy/backends/libspotify/translator.py b/mopidy/backends/libspotify/translator.py new file mode 100644 index 00000000..ff8f3c5c --- /dev/null +++ b/mopidy/backends/libspotify/translator.py @@ -0,0 +1,53 @@ +import datetime as dt + +from spotify import Link + +from mopidy.models import Artist, Album, Track, Playlist +from mopidy.backends.libspotify import ENCODING + +class LibspotifyTranslator(object): + @classmethod + def to_mopidy_artist(cls, spotify_artist): + if not spotify_artist.is_loaded(): + return Artist(name=u'[loading...]') + return Artist( + uri=str(Link.from_artist(spotify_artist)), + name=spotify_artist.name().decode(ENCODING), + ) + + @classmethod + def to_mopidy_album(cls, spotify_album): + if not spotify_album.is_loaded(): + return Album(name=u'[loading...]') + # TODO pyspotify got much more data on albums than this + return Album(name=spotify_album.name().decode(ENCODING)) + + @classmethod + def to_mopidy_track(cls, spotify_track): + if not spotify_track.is_loaded(): + return Track(name=u'[loading...]') + uri = str(Link.from_track(spotify_track, 0)) + if dt.MINYEAR <= int(spotify_track.album().year()) <= dt.MAXYEAR: + date = dt.date(spotify_track.album().year(), 1, 1) + else: + date = None + return Track( + uri=uri, + name=spotify_track.name().decode(ENCODING), + artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists()], + album=cls.to_mopidy_album(spotify_track.album()), + track_no=spotify_track.index(), + date=date, + length=spotify_track.duration(), + bitrate=160, + ) + + @classmethod + def to_mopidy_playlist(cls, spotify_playlist): + if not spotify_playlist.is_loaded(): + return Playlist(name=u'[loading...]') + return Playlist( + uri=str(Link.from_playlist(spotify_playlist)), + name=spotify_playlist.name().decode(ENCODING), + tracks=[cls.to_mopidy_track(t) for t in spotify_playlist], + ) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 87d2f7c0..45e74e5d 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -14,10 +14,10 @@ import glob import shutil import threading +from mopidy import settings from mopidy.backends.base import * from mopidy.models import Playlist, Track, Album -from mopidy import settings -from mopidy.utils import parse_m3u, parse_mpd_tag_cache +from .translator import parse_m3u, parse_mpd_tag_cache logger = logging.getLogger(u'mopidy.backends.local') @@ -69,7 +69,7 @@ class LocalPlaybackController(BasePlaybackController): def _message(self, bus, message): if message.type == gst.MESSAGE_EOS: - self.end_of_track_callback() + self.on_end_of_track() elif message.type == gst.MESSAGE_ERROR: self._bin.set_state(gst.STATE_NULL) error, debug = message.parse_error() diff --git a/mopidy/utils.py b/mopidy/backends/local/translator.py similarity index 65% rename from mopidy/utils.py rename to mopidy/backends/local/translator.py index 7eac9239..87ea15df 100644 --- a/mopidy/utils.py +++ b/mopidy/backends/local/translator.py @@ -1,66 +1,10 @@ import logging -from multiprocessing.reduction import reduce_connection import os -import pickle -import sys -import urllib -logger = logging.getLogger('mopidy.utils') +logger = logging.getLogger('mopidy.backends.local.translator') from mopidy.models import Track, Artist, Album - -def flatten(the_list): - result = [] - for element in the_list: - if isinstance(element, list): - result.extend(flatten(element)) - else: - result.append(element) - return result - -def import_module(name): - __import__(name) - return sys.modules[name] - -def get_class(name): - module_name = name[:name.rindex('.')] - class_name = name[name.rindex('.') + 1:] - logger.debug('Loading: %s', name) - module = import_module(module_name) - class_object = getattr(module, class_name) - return class_object - -def get_or_create_folder(folder): - folder = os.path.expanduser(folder) - if not os.path.isdir(folder): - logger.info(u'Creating %s', folder) - os.mkdir(folder, 0755) - return folder - -def path_to_uri(*paths): - path = os.path.join(*paths) - #path = os.path.expanduser(path) # FIXME - path = path.encode('utf-8') - if sys.platform == 'win32': - return 'file:' + urllib.pathname2url(path) - return 'file://' + urllib.pathname2url(path) - -def indent(string, places=4, linebreak='\n'): - lines = string.split(linebreak) - if len(lines) == 1: - return string - result = u'' - for line in lines: - result += linebreak + ' ' * places + line - return result - -def pickle_connection(connection): - return pickle.dumps(reduce_connection(connection)) - -def unpickle_connection(pickled_connection): - # From http://stackoverflow.com/questions/1446004 - (func, args) = pickle.loads(pickled_connection) - return func(*args) +from mopidy.utils.path import path_to_uri def parse_m3u(file_path): """ diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 30acbe89..b9111d9e 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -11,11 +11,15 @@ def add(frontend, uri): Adds the file ``URI`` to the playlist (directories add recursively). ``URI`` can also be a single file. """ - track = frontend.backend.library.lookup(uri) - if track is None: - raise MpdNoExistError( - u'directory or file not found', command=u'add') - frontend.backend.current_playlist.add(track) + for handler_prefix in frontend.backend.uri_handlers: + if uri.startswith(handler_prefix): + track = frontend.backend.library.lookup(uri) + if track is not None: + frontend.backend.current_playlist.add(track) + return + + raise MpdNoExistError( + u'directory or file not found', command=u'add') @handle_pattern(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') def addid(frontend, uri, songpos=None): @@ -173,7 +177,10 @@ def playlistfind(frontend, tag, needle): if tag == 'filename': try: cp_track = frontend.backend.current_playlist.get(uri=needle) - return cp_track[1].mpd_format() + (cpid, track) = cp_track + position = frontend.backend.current_playlist.cp_tracks.index( + cp_track) + return track.mpd_format(cpid=cpid, position=position) except LookupError: return None raise MpdNotImplemented # TODO @@ -338,7 +345,8 @@ def swap(frontend, songpos1, songpos2): tracks.insert(songpos1, song2) del tracks[songpos2] tracks.insert(songpos2, song1) - frontend.backend.current_playlist.load(tracks) + frontend.backend.current_playlist.clear() + frontend.backend.current_playlist.append(tracks) @handle_pattern(r'^swapid "(?P\d+)" "(?P\d+)"$') def swapid(frontend, cpid1, cpid2): diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index cf803c6d..7abc4509 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -139,9 +139,7 @@ def playid(frontend, cpid): cpid = int(cpid) try: if cpid == -1: - if not frontend.backend.current_playlist.cp_tracks: - return # Fail silently - cp_track = frontend.backend.current_playlist.cp_tracks[0] + cp_track = _get_cp_track_for_play_minus_one(frontend) else: cp_track = frontend.backend.current_playlist.get(cpid=cpid) return frontend.backend.playback.play(cp_track) @@ -158,10 +156,11 @@ def playpos(frontend, songpos): Begins playing the playlist at song number ``SONGPOS``. - *MPoD:* + *Many clients:* - - issues ``play "-1"`` after playlist replacement to start playback at - the first track. + - issue ``play "-1"`` after playlist replacement to start the current + track. If the current track is not set, start playback at the first + track. *BitMPC:* @@ -170,15 +169,21 @@ def playpos(frontend, songpos): songpos = int(songpos) try: if songpos == -1: - if not frontend.backend.current_playlist.cp_tracks: - return # Fail silently - cp_track = frontend.backend.current_playlist.cp_tracks[0] + cp_track = _get_cp_track_for_play_minus_one(frontend) else: cp_track = frontend.backend.current_playlist.cp_tracks[songpos] return frontend.backend.playback.play(cp_track) except IndexError: raise MpdArgError(u'Bad song index', command=u'play') +def _get_cp_track_for_play_minus_one(frontend): + if not frontend.backend.current_playlist.cp_tracks: + return # Fail silently + cp_track = frontend.backend.playback.current_cp_track + if cp_track is None: + cp_track = frontend.backend.current_playlist.cp_tracks[0] + return cp_track + @handle_pattern(r'^previous$') def previous(frontend): """ @@ -293,7 +298,9 @@ def seek(frontend, songpos, seconds): Seeks to the position ``TIME`` (in seconds) of entry ``SONGPOS`` in the playlist. """ - raise MpdNotImplemented # TODO + if frontend.backend.playback.current_playlist_position != songpos: + playpos(frontend, songpos) + return frontend.backend.playback.seek(int(seconds) * 1000) @handle_pattern(r'^seekid "(?P\d+)" "(?P\d+)"$') def seekid(frontend, cpid, seconds): @@ -304,7 +311,9 @@ def seekid(frontend, cpid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ - raise MpdNotImplemented # TODO + if frontend.backend.playback.current_cpid != cpid: + playid(frontend, cpid) + return frontend.backend.playback.seek(int(seconds) * 1000) @handle_pattern(r'^setvol "(?P[-+]*\d+)"$') def setvol(frontend, volume): diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index ecd8b321..3d7a8710 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -86,10 +86,16 @@ def load(frontend, name): ``load {NAME}`` Loads the playlist ``NAME.m3u`` from the playlist directory. + + *Clarifications:* + + - ``load`` appends the given playlist to the current playlist. """ - matches = frontend.backend.stored_playlists.search(name) - if matches: - frontend.backend.current_playlist.load(matches[0].tracks) + try: + playlist = frontend.backend.stored_playlists.get(name=name) + frontend.backend.current_playlist.append(playlist.tracks) + except LookupError: + raise MpdNoExistError(u'No such playlist', command=u'load') @handle_pattern(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') def playlistadd(frontend, name, uri): @@ -139,9 +145,9 @@ def playlistmove(frontend, name, from_pos, to_pos): *Clarifications:* - - The second argument is not a ``SONGID`` as used elsewhere in the - protocol documentation, but just the ``SONGPOS`` to move *from*, - i.e. ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. + - The second argument is not a ``SONGID`` as used elsewhere in the protocol + documentation, but just the ``SONGPOS`` to move *from*, i.e. + ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. """ raise MpdNotImplemented # TODO diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index 5a124d19..91d8e67a 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -8,7 +8,8 @@ import sys from mopidy import get_mpd_protocol_version, settings from mopidy.frontends.mpd.protocol import ENCODING, LINE_TERMINATOR -from mopidy.utils import indent, pickle_connection +from mopidy.process import pickle_connection +from mopidy.utils import indent logger = logging.getLogger('mopidy.frontends.mpd.server') diff --git a/mopidy/mixers/__init__.py b/mopidy/mixers/__init__.py index 31e5ae8e..c9543863 100644 --- a/mopidy/mixers/__init__.py +++ b/mopidy/mixers/__init__.py @@ -1,6 +1,14 @@ +from mopidy import settings + class BaseMixer(object): - def __init__(self, *args, **kwargs): - pass + """ + :param backend: a backend instance + :type mixer: :class:`mopidy.backends.base.BaseBackend` + """ + + def __init__(self, backend, *args, **kwargs): + self.backend = backend + self.amplification_factor = settings.MIXER_MAX_VOLUME / 100.0 @property def volume(self): @@ -10,11 +18,13 @@ class BaseMixer(object): Integer in range [0, 100]. :class:`None` if unknown. Values below 0 is equal to 0. Values above 100 is equal to 100. """ - return self._get_volume() + if self._get_volume() is None: + return None + return int(self._get_volume() / self.amplification_factor) @volume.setter def volume(self, volume): - volume = int(volume) + volume = int(int(volume) * self.amplification_factor) if volume < 0: volume = 0 elif volume > 100: diff --git a/mopidy/mixers/gstreamer_software.py b/mopidy/mixers/gstreamer_software.py new file mode 100644 index 00000000..2910ef72 --- /dev/null +++ b/mopidy/mixers/gstreamer_software.py @@ -0,0 +1,25 @@ +import multiprocessing + +from mopidy.mixers import BaseMixer +from mopidy.process import pickle_connection + +class GStreamerSoftwareMixer(BaseMixer): + """Mixer which uses GStreamer to control volume in software.""" + + def __init__(self, *args, **kwargs): + super(GStreamerSoftwareMixer, self).__init__(*args, **kwargs) + + def _get_volume(self): + my_end, other_end = multiprocessing.Pipe() + self.backend.output_queue.put({ + 'command': 'get_volume', + 'reply_to': pickle_connection(other_end), + }) + my_end.poll(None) + return my_end.recv() + + def _set_volume(self, volume): + self.backend.output_queue.put({ + 'command': 'set_volume', + 'volume': volume, + }) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 1f7f4710..d78863aa 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -22,10 +22,7 @@ class NadMixer(BaseMixer): currently used by this mixer. Sadly, this means that if you use the remote control to change the volume - on the amplifier, Mopidy will no longer report the correct volume. To - recalibrate the mixer, set the volume to 0 through Mopidy. This will reset - the amplifier to a known state, including powering on the device, selecting - the configured speakers and input sources. + on the amplifier, Mopidy will no longer report the correct volume. **Dependencies** @@ -51,8 +48,6 @@ class NadMixer(BaseMixer): def _set_volume(self, volume): self._volume = volume - if volume == 0: - self._pipe.send({'command': 'reset_device'}) self._pipe.send({'command': 'set_volume', 'volume': volume}) @@ -83,7 +78,7 @@ class NadTalker(BaseProcess): self.pipe = pipe self._device = None - def _run(self): + def run_inside_try(self): self._open_connection() self._set_device_to_known_state() while self.pipe.poll(None): diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py new file mode 100644 index 00000000..ca5a98c5 --- /dev/null +++ b/mopidy/outputs/gstreamer.py @@ -0,0 +1,180 @@ +import gobject +gobject.threads_init() + +import pygst +pygst.require('0.10') +import gst + +import logging +import threading + +from mopidy.process import BaseProcess, unpickle_connection + +logger = logging.getLogger('mopidy.outputs.gstreamer') + +class GStreamerOutput(object): + """ + Audio output through GStreamer. + + Starts the :class:`GStreamerProcess`. + """ + + def __init__(self, core_queue, output_queue): + self.process = GStreamerProcess(core_queue, output_queue) + self.process.start() + + def destroy(self): + self.process.terminate() + +class GStreamerMessagesThread(threading.Thread): + def run(self): + gobject.MainLoop().run() + +class GStreamerProcess(BaseProcess): + """ + A process for all work related to GStreamer. + + The main loop processes events from both Mopidy and GStreamer. + + Make sure this subprocess is started by the MainThread in the top-most + parent process, and not some other thread. If not, we can get into the + problems described at + http://jameswestby.net/weblog/tech/14-caution-python-multiprocessing-and-glib-dont-mix.html. + """ + + pipeline_description = ' ! '.join([ + 'appsrc name=src', + 'volume name=volume', + 'autoaudiosink name=sink', + ]) + + def __init__(self, core_queue, output_queue): + super(GStreamerProcess, self).__init__() + self.core_queue = core_queue + self.output_queue = output_queue + self.gst_pipeline = None + self.gst_bus = None + self.gst_bus_id = None + self.gst_uri_bin = None + self.gst_data_src = None + self.gst_volume = None + + def run_inside_try(self): + self.setup() + while True: + message = self.output_queue.get() + self.process_mopidy_message(message) + + def setup(self): + logger.debug(u'Setting up GStreamer pipeline') + + # Start a helper thread that can run the gobject.MainLoop + messages_thread = GStreamerMessagesThread() + messages_thread.daemon = True + messages_thread.start() + + self.gst_pipeline = gst.parse_launch(self.pipeline_description) + self.gst_data_src = self.gst_pipeline.get_by_name('src') + #self.gst_uri_bin = self.gst_pipeline.get_by_name('uri') + self.gst_volume = self.gst_pipeline.get_by_name('volume') + + # Setup bus and message processor + self.gst_bus = self.gst_pipeline.get_bus() + self.gst_bus.add_signal_watch() + self.gst_bus_id = self.gst_bus.connect('message', + self.process_gst_message) + + def process_mopidy_message(self, message): + """Process messages from the rest of Mopidy.""" + if message['command'] == 'play_uri': + response = self.play_uri(message['uri']) + connection = unpickle_connection(message['reply_to']) + connection.send(response) + elif message['command'] == 'deliver_data': + self.deliver_data(message['caps'], message['data']) + elif message['command'] == 'end_of_data_stream': + self.end_of_data_stream() + elif message['command'] == 'set_state': + response = self.set_state(message['state']) + connection = unpickle_connection(message['reply_to']) + connection.send(response) + elif message['command'] == 'get_volume': + volume = self.get_volume() + connection = unpickle_connection(message['reply_to']) + connection.send(volume) + elif message['command'] == 'set_volume': + self.set_volume(message['volume']) + else: + logger.warning(u'Cannot handle message: %s', message) + + def process_gst_message(self, bus, message): + """Process messages from GStreamer.""" + if message.type == gst.MESSAGE_EOS: + logger.debug(u'GStreamer signalled end-of-stream. ' + 'Sending end_of_track to core_queue ...') + self.core_queue.put({'command': 'end_of_track'}) + elif message.type == gst.MESSAGE_ERROR: + self.set_state('NULL') + error, debug = message.parse_error() + logger.error(u'%s %s', error, debug) + # FIXME Should we send 'stop_playback' to core here? Can we + # differentiate on how serious the error is? + + def play_uri(self, uri): + """Play audio at URI""" + self.set_state('READY') + self.gst_uri_bin.set_property('uri', uri) + return self.set_state('PLAYING') + + def deliver_data(self, caps_string, data): + """Deliver audio data to be played""" + caps = gst.caps_from_string(caps_string) + buffer_ = gst.Buffer(buffer(data)) + buffer_.set_caps(caps) + self.gst_data_src.set_property('caps', caps) + self.gst_data_src.emit('push-buffer', buffer_) + + def end_of_data_stream(self): + """ + Add end-of-stream token to source. + + We will get a GStreamer message when the stream playback reaches the + token, and can then do any end-of-stream related tasks. + """ + self.gst_data_src.emit('end-of-stream') + + def set_state(self, state_name): + """ + Set the GStreamer state. Returns :class:`True` if successful. + + .. digraph:: gst_state_transitions + + "NULL" -> "READY" + "PAUSED" -> "PLAYING" + "PAUSED" -> "READY" + "PLAYING" -> "PAUSED" + "READY" -> "NULL" + "READY" -> "PAUSED" + + :param state_name: NULL, READY, PAUSED, or PLAYING + :type state_name: string + :rtype: :class:`True` or :class:`False` + """ + result = self.gst_pipeline.set_state( + getattr(gst, 'STATE_' + state_name)) + if result == gst.STATE_CHANGE_FAILURE: + logger.warning('Setting GStreamer state to %s: failed', state_name) + return False + else: + logger.debug('Setting GStreamer state to %s: OK', state_name) + return True + + def get_volume(self): + """Get volume in range [0..100]""" + gst_volume = self.gst_volume.get_property('volume') + return int(gst_volume * 100) + + def set_volume(self, volume): + """Set volume in range [0..100]""" + gst_volume = volume / 100.0 + self.gst_volume.set_property('volume', gst_volume) diff --git a/mopidy/process.py b/mopidy/process.py index d3c1d03e..01ac8ed4 100644 --- a/mopidy/process.py +++ b/mopidy/process.py @@ -1,55 +1,77 @@ import logging import multiprocessing +from multiprocessing.reduction import reduce_connection +import pickle import sys -from mopidy import settings, SettingsError -from mopidy.utils import get_class, unpickle_connection +from mopidy import SettingsError logger = logging.getLogger('mopidy.process') +def pickle_connection(connection): + return pickle.dumps(reduce_connection(connection)) + +def unpickle_connection(pickled_connection): + # From http://stackoverflow.com/questions/1446004 + (func, args) = pickle.loads(pickled_connection) + return func(*args) + + class BaseProcess(multiprocessing.Process): def run(self): try: - self._run() + self.run_inside_try() except KeyboardInterrupt: logger.info(u'Interrupted by user') sys.exit(0) except SettingsError as e: logger.error(e.message) sys.exit(1) + except ImportError as e: + logger.error(e) + sys.exit(1) - def _run(self): + def run_inside_try(self): raise NotImplementedError class CoreProcess(BaseProcess): - def __init__(self, core_queue): + def __init__(self, core_queue, output_class, backend_class, + frontend_class): super(CoreProcess, self).__init__() self.core_queue = core_queue - self._backend = None - self._frontend = None + self.output_queue = None + self.output_class = output_class + self.backend_class = backend_class + self.frontend_class = frontend_class + self.output = None + self.backend = None + self.frontend = None - def _run(self): - self._setup() + def run_inside_try(self): + self.setup() while True: message = self.core_queue.get() - self._process_message(message) + self.process_message(message) - def _setup(self): - self._backend = get_class(settings.BACKENDS[0])( - core_queue=self.core_queue) - self._frontend = get_class(settings.FRONTEND)(backend=self._backend) + def setup(self): + self.output_queue = multiprocessing.Queue() + self.output = self.output_class(self.core_queue, self.output_queue) + self.backend = self.backend_class(self.core_queue, self.output_queue) + self.frontend = self.frontend_class(self.backend) - def _process_message(self, message): - if message['command'] == 'mpd_request': - response = self._frontend.handle_request(message['request']) + def process_message(self, message): + if message.get('to') == 'output': + self.output_queue.put(message) + elif message['command'] == 'mpd_request': + response = self.frontend.handle_request(message['request']) connection = unpickle_connection(message['reply_to']) connection.send(response) elif message['command'] == 'end_of_track': - self._backend.playback.end_of_track_callback() + self.backend.playback.on_end_of_track() elif message['command'] == 'stop_playback': - self._backend.playback.stop() + self.backend.playback.stop() elif message['command'] == 'set_stored_playlists': - self._backend.stored_playlists.playlists = message['playlists'] + self.backend.stored_playlists.playlists = message['playlists'] else: logger.warning(u'Cannot handle message: %s', message) diff --git a/mopidy/settings.py b/mopidy/settings.py index 96b95575..c9e3606e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -3,24 +3,21 @@ Available settings and their default values. .. warning:: - Do *not* change settings in ``mopidy/settings.py``. Instead, add a file - called ``~/.mopidy/settings.py`` and redefine settings there. + Do *not* change settings directly in :mod:`mopidy.settings`. Instead, add a + file called ``~/.mopidy/settings.py`` and redefine settings there. """ -from __future__ import absolute_import -import os -import sys - #: List of playback backends to use. See :mod:`mopidy.backends` for all -#: available backends. Default:: +#: available backends. #: -#: BACKENDS = (u'mopidy.backends.despotify.DespotifyBackend',) +#: Default:: +#: +#: BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',) #: #: .. note:: #: Currently only the first backend in the list is used. BACKENDS = ( - u'mopidy.backends.despotify.DespotifyBackend', - #u'mopidy.backends.libspotify.LibspotifyBackend', + u'mopidy.backends.libspotify.LibspotifyBackend', ) #: The log format used on the console. See @@ -29,54 +26,61 @@ BACKENDS = ( CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \ ' [%(process)d:%(threadName)s] %(name)s\n %(message)s' -#: The log format used for dump logs. Default:: +#: The log format used for dump logs. +#: +#: Default:: #: #: DUMP_LOG_FILENAME = CONSOLE_LOG_FORMAT DUMP_LOG_FORMAT = CONSOLE_LOG_FORMAT -#: The file to dump debug log data to. Default:: +#: The file to dump debug log data to when Mopidy is run with the +#: :option:`--dump` option. +#: +#: Default:: #: #: DUMP_LOG_FILENAME = u'dump.log' DUMP_LOG_FILENAME = u'dump.log' -#: Protocol frontend to use. Default:: +#: Protocol frontend to use. +#: +#: Default:: #: #: FRONTEND = u'mopidy.frontends.mpd.frontend.MpdFrontend' FRONTEND = u'mopidy.frontends.mpd.frontend.MpdFrontend' -#: Path to folder with local music. Default:: +#: Path to folder with local music. +#: +#: Used by :mod:`mopidy.backends.local`. +#: +#: Default:: #: #: LOCAL_MUSIC_FOLDER = u'~/music' LOCAL_MUSIC_FOLDER = u'~/music' -#: Path to playlist folder with m3u files for local music. Default:: +#: Path to playlist folder with m3u files for local music. +#: +#: Used by :mod:`mopidy.backends.local`. +#: +#: Default:: #: #: LOCAL_PLAYLIST_FOLDER = u'~/.mopidy/playlists' LOCAL_PLAYLIST_FOLDER = u'~/.mopidy/playlists' -#: Path to tag cache for local music. Default:: +#: Path to tag cache for local music. +#: +#: Used by :mod:`mopidy.backends.local`. +#: +#: Default:: #: #: LOCAL_TAG_CACHE = u'~/.mopidy/tag_cache' LOCAL_TAG_CACHE = u'~/.mopidy/tag_cache' #: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers. #: -#: Default on Linux:: +#: Default:: #: -#: MIXER = u'mopidy.mixers.alsa.AlsaMixer' -#: -#: Default on OS X:: -#: -#: MIXER = u'mopidy.mixers.osa.OsaMixer' -#: -#: Default on other operating systems:: -#: -#: MIXER = u'mopidy.mixers.dummy.DummyMixer' -MIXER = u'mopidy.mixers.dummy.DummyMixer' -if sys.platform == 'linux2': - MIXER = u'mopidy.mixers.alsa.AlsaMixer' -elif sys.platform == 'darwin': - MIXER = u'mopidy.mixers.osa.OsaMixer' +#: MIXER = u'mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer' +MIXER = u'mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer' #: ALSA mixer only. What mixer control to use. If set to :class:`False`, first #: ``Master`` and then ``PCM`` will be tried. @@ -87,6 +91,7 @@ MIXER_ALSA_CONTROL = False #: External mixers only. Which port the mixer is connected to. #: #: This must point to the device port like ``/dev/ttyUSB0``. +#: #: Default: :class:`None` MIXER_EXT_PORT = None @@ -105,12 +110,33 @@ MIXER_EXT_SPEAKERS_A = None #: Default: :class:`None`. MIXER_EXT_SPEAKERS_B = None -#: Server to use. Default:: +#: The maximum volume. Integer in the range 0 to 100. +#: +#: If this settings is set to 80, the mixer will set the actual volume to 80 +#: when asked to set it to 100. +#: +#: Default:: +#: +#: MIXER_MAX_VOLUME = 100 +MIXER_MAX_VOLUME = 100 + +#: Audio output handler to use. +#: +#: Default:: +#: +#: OUTPUT = u'mopidy.outputs.gstreamer.GStreamerOutput' +OUTPUT = u'mopidy.outputs.gstreamer.GStreamerOutput' + +#: Server to use. +#: +#: Default:: #: #: SERVER = u'mopidy.frontends.mpd.server.MpdServer' SERVER = u'mopidy.frontends.mpd.server.MpdServer' -#: Which address Mopidy should bind to. Examples: +#: Which address Mopidy's MPD server should bind to. +#: +#:Examples: #: #: ``127.0.0.1`` #: Listens only on the IPv4 loopback interface. Default. @@ -122,24 +148,22 @@ SERVER = u'mopidy.frontends.mpd.server.MpdServer' #: Listens on all interfaces, both IPv4 and IPv6. MPD_SERVER_HOSTNAME = u'127.0.0.1' -#: Which TCP port Mopidy should listen to. Default: 6600 +#: Which TCP port Mopidy's MPD server should listen to. +#: +#: Default: 6600 MPD_SERVER_PORT = 6600 -#: Your Spotify Premium username. Used by all Spotify backends. -SPOTIFY_USERNAME = u'' - -#: Your Spotify Premium password. Used by all Spotify backends. -SPOTIFY_PASSWORD = u'' - -#: Path to your libspotify application key. Used by LibspotifyBackend. -SPOTIFY_LIB_APPKEY = u'~/.mopidy/spotify_appkey.key' - -#: Path to the libspotify cache. Used by LibspotifyBackend. +#: Path to the libspotify cache. +#: +#: Used by :mod:`mopidy.backends.libspotify`. SPOTIFY_LIB_CACHE = u'~/.mopidy/libspotify_cache' -# Import user specific settings -dotdir = os.path.expanduser(u'~/.mopidy/') -settings_file = os.path.join(dotdir, u'settings.py') -if os.path.isfile(settings_file): - sys.path.insert(0, dotdir) - from settings import * +#: Your Spotify Premium username. +#: +#: Used by :mod:`mopidy.backends.libspotify`. +SPOTIFY_USERNAME = u'' + +#: Your Spotify Premium password. +#: +#: Used by :mod:`mopidy.backends.libspotify`. +SPOTIFY_PASSWORD = u'' diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py new file mode 100644 index 00000000..277d2f3b --- /dev/null +++ b/mopidy/utils/__init__.py @@ -0,0 +1,38 @@ +import logging +import os +import sys + +logger = logging.getLogger('mopidy.utils') + +def flatten(the_list): + result = [] + for element in the_list: + if isinstance(element, list): + result.extend(flatten(element)) + else: + result.append(element) + return result + +def import_module(name): + __import__(name) + return sys.modules[name] + +def get_class(name): + module_name = name[:name.rindex('.')] + class_name = name[name.rindex('.') + 1:] + logger.debug('Loading: %s', name) + try: + module = import_module(module_name) + class_object = getattr(module, class_name) + except (ImportError, AttributeError): + raise ImportError("Couldn't load: %s" % name) + return class_object + +def indent(string, places=4, linebreak='\n'): + lines = string.split(linebreak) + if len(lines) == 1: + return string + result = u'' + for line in lines: + result += linebreak + ' ' * places + line + return result diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py new file mode 100644 index 00000000..002b54c8 --- /dev/null +++ b/mopidy/utils/path.py @@ -0,0 +1,21 @@ +import logging +import os +import sys +import urllib + +logger = logging.getLogger('mopidy.utils.path') + +def get_or_create_folder(folder): + folder = os.path.expanduser(folder) + if not os.path.isdir(folder): + logger.info(u'Creating %s', folder) + os.mkdir(folder, 0755) + return folder + +def path_to_uri(*paths): + path = os.path.join(*paths) + #path = os.path.expanduser(path) # FIXME Waiting for test case? + path = path.encode('utf-8') + if sys.platform == 'win32': + return 'file:' + urllib.pathname2url(path) + return 'file://' + urllib.pathname2url(path) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py new file mode 100644 index 00000000..478a03e6 --- /dev/null +++ b/mopidy/utils/settings.py @@ -0,0 +1,132 @@ +# Absolute import needed to import ~/.mopidy/settings.py and not ourselves +from __future__ import absolute_import +from copy import copy +import logging +import os +import sys + +from mopidy import SettingsError +from mopidy.utils import indent + +logger = logging.getLogger('mopidy.utils.settings') + +class SettingsProxy(object): + def __init__(self, default_settings_module): + self.default = self._get_settings_dict_from_module( + default_settings_module) + self.local = self._get_local_settings() + + def _get_local_settings(self): + dotdir = os.path.expanduser(u'~/.mopidy/') + settings_file = os.path.join(dotdir, u'settings.py') + if not os.path.isfile(settings_file): + return {} + sys.path.insert(0, dotdir) + import settings as local_settings_module + return self._get_settings_dict_from_module(local_settings_module) + + def _get_settings_dict_from_module(self, module): + settings = filter(lambda (key, value): self._is_setting(key), + module.__dict__.iteritems()) + return dict(settings) + + def _is_setting(self, name): + return name.isupper() + + @property + def current(self): + current = copy(self.default) + current.update(self.local) + return current + + def __getattr__(self, attr): + if not self._is_setting(attr): + return + if attr not in self.current: + raise SettingsError(u'Setting "%s" is not set.' % attr) + value = self.current[attr] + if type(value) != bool and not value: + raise SettingsError(u'Setting "%s" is empty.' % attr) + return value + + def validate(self): + if self.get_errors(): + logger.error(u'Settings validation errors: %s', + indent(self.get_errors_as_string())) + raise SettingsError(u'Settings validation failed.') + + def get_errors(self): + return validate_settings(self.default, self.local) + + def get_errors_as_string(self): + lines = [] + for (setting, error) in self.get_errors().iteritems(): + lines.append(u'%s: %s' % (setting, error)) + return '\n'.join(lines) + + +def validate_settings(defaults, settings): + """ + Checks the settings for both errors like misspellings and against a set of + rules for renamed settings, etc. + + Returns of setting names with associated errors. + + :param defaults: Mopidy's default settings + :type defaults: dict + :param settings: the user's local settings + :type settings: dict + :rtype: dict + """ + errors = {} + + changed = { + 'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME', + 'SERVER_PORT': 'MPD_SERVER_PORT', + 'SPOTIFY_LIB_APPKEY': None, + } + + for setting, value in settings.iteritems(): + if setting in changed: + if changed[setting] is None: + errors[setting] = u'Deprecated setting. It may be removed.' + else: + errors[setting] = u'Deprecated setting. Use %s.' % ( + changed[setting],) + continue + + if setting == 'BACKENDS': + if 'mopidy.backends.despotify.DespotifyBackend' in value: + errors[setting] = (u'Deprecated setting value. ' + + '"mopidy.backends.despotify.DespotifyBackend" is no ' + + 'longer available.') + continue + + if setting not in defaults: + errors[setting] = u'Unknown setting. Is it misspelled?' + continue + + return errors + +def list_settings_optparse_callback(*args): + """ + Prints a list of all settings. + + Called by optparse when Mopidy is run with the :option:`--list-settings` + option. + """ + from mopidy import settings + errors = settings.get_errors() + lines = [] + for (key, value) in sorted(settings.current.iteritems()): + default_value = settings.default.get(key) + if key.endswith('PASSWORD'): + value = u'********' + lines.append(u'%s:' % key) + lines.append(u' Value: %s' % repr(value)) + if value != default_value and default_value is not None: + lines.append(u' Default: %s' % repr(default_value)) + if errors.get(key) is not None: + lines.append(u' Error: %s' % errors[key]) + print u'Settings: %s' % indent('\n'.join(lines), places=2) + sys.exit(0) diff --git a/setup.py b/setup.py index bbf300f7..fabc8353 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,34 @@ +""" +Most of this file is taken from the Django project, which is BSD licensed. +""" + from distutils.core import setup +from distutils.command.install_data import install_data from distutils.command.install import INSTALL_SCHEMES import os +import sys from mopidy import get_version +class osx_install_data(install_data): + # On MacOS, the platform-specific lib dir is + # /System/Library/Framework/Python/.../ which is wrong. Python 2.5 supplied + # with MacOS 10.5 has an Apple-specific fix for this in + # distutils.command.install_data#306. It fixes install_lib but not + # install_data, which is why we roll our own install_data class. + + def finalize_options(self): + # By the time finalize_options is called, install.install_lib is set to + # the fixed directory, so we set the installdir to install_lib. The + # install_data class uses ('install_data', 'install_dir') instead. + self.set_undefined_options('install', ('install_lib', 'install_dir')) + install_data.finalize_options(self) + +if sys.platform == "darwin": + cmdclasses = {'install_data': osx_install_data} +else: + cmdclasses = {'install_data': install_data} + def fullsplit(path, result=None): """ Split a pathname into components (the opposite of os.path.join) in a @@ -20,7 +45,8 @@ def fullsplit(path, result=None): # Tell distutils to put the data_files in platform-specific installation # locations. See here for an explanation: -# http://groups.google.com/group/comp.lang.python/browse_thread/thread/35ec7b2fed36eaec/2105ee4d9e8042cb +# http://groups.google.com/group/comp.lang.python/browse_thread/ +# thread/35ec7b2fed36eaec/2105ee4d9e8042cb for scheme in INSTALL_SCHEMES.values(): scheme['data'] = scheme['purelib'] @@ -49,17 +75,19 @@ setup( author='Stein Magnus Jodal', author_email='stein.magnus@jodal.no', packages=packages, + package_data={'mopidy': ['backends/libspotify/spotify_appkey.key']}, + cmdclass=cmdclasses, data_files=data_files, scripts=['bin/mopidy'], url='http://www.mopidy.com/', - license='GPLv2', + license='Apache License, Version 2.0', description='MPD server with Spotify support', long_description=open('README.rst').read(), classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Environment :: No Input/Output (Daemon)', 'Intended Audience :: End Users/Desktop', - 'License :: OSI Approved :: GNU General Public License (GPL)', + 'License :: OSI Approved :: Apache Software License', 'Operating System :: MacOS :: MacOS X', 'Operating System :: POSIX :: Linux', 'Programming Language :: Python :: 2.6', diff --git a/tests/backends/base.py b/tests/backends/base.py index 416c1799..733c63cc 100644 --- a/tests/backends/base.py +++ b/tests/backends/base.py @@ -32,7 +32,7 @@ class BaseCurrentPlaylistControllerTest(object): backend_class = None def setUp(self): - self.backend = self.backend_class(mixer=DummyMixer()) + self.backend = self.backend_class(mixer_class=DummyMixer) self.controller = self.backend.current_playlist self.playback = self.backend.playback @@ -91,20 +91,14 @@ class BaseCurrentPlaylistControllerTest(object): self.controller.clear() self.assertEqual(self.playback.state, self.playback.STOPPED) - def test_load(self): - tracks = [] - self.assertNotEqual(id(tracks), id(self.controller.tracks)) - self.controller.load(tracks) - self.assertEqual(tracks, self.controller.tracks) - def test_get_by_uri_returns_unique_match(self): track = Track(uri='a') - self.controller.load([Track(uri='z'), track, Track(uri='y')]) + self.controller.append([Track(uri='z'), track, Track(uri='y')]) self.assertEqual(track, self.controller.get(uri='a')[1]) def test_get_by_uri_raises_error_if_multiple_matches(self): track = Track(uri='a') - self.controller.load([Track(uri='z'), track, track]) + self.controller.append([Track(uri='z'), track, track]) try: self.controller.get(uri='a') self.fail(u'Should raise LookupError if multiple matches') @@ -124,7 +118,7 @@ class BaseCurrentPlaylistControllerTest(object): track1 = Track(uri='a', name='x') track2 = Track(uri='b', name='x') track3 = Track(uri='b', name='y') - self.controller.load([track1, track2, track3]) + self.controller.append([track1, track2, track3]) self.assertEqual(track1, self.controller.get(uri='a', name='x')[1]) self.assertEqual(track2, self.controller.get(uri='b', name='x')[1]) self.assertEqual(track3, self.controller.get(uri='b', name='y')[1]) @@ -133,37 +127,37 @@ class BaseCurrentPlaylistControllerTest(object): track1 = Track() track2 = Track(uri='b') track3 = Track() - self.controller.load([track1, track2, track3]) + self.controller.append([track1, track2, track3]) self.assertEqual(track2, self.controller.get(uri='b')[1]) - @populate_playlist - def test_load_replaces_playlist(self): - self.backend.current_playlist.load([]) - self.assertEqual(len(self.backend.current_playlist.tracks), 0) + def test_append_appends_to_the_current_playlist(self): + self.controller.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.controller.tracks), 2) + self.controller.append([Track(uri='c'), Track(uri='d')]) + self.assertEqual(len(self.controller.tracks), 4) + self.assertEqual(self.controller.tracks[0].uri, 'a') + self.assertEqual(self.controller.tracks[1].uri, 'b') + self.assertEqual(self.controller.tracks[2].uri, 'c') + self.assertEqual(self.controller.tracks[3].uri, 'd') - def test_load_does_not_reset_version(self): + def test_append_does_not_reset_version(self): version = self.controller.version - self.controller.load([]) + self.controller.append([]) self.assertEqual(self.controller.version, version + 1) @populate_playlist - def test_load_preserves_playing_state(self): - tracks = self.controller.tracks - playback = self.playback - + def test_append_preserves_playing_state(self): self.playback.play() - self.controller.load([tracks[1]]) - self.assertEqual(playback.state, playback.PLAYING) - self.assertEqual(tracks[1], self.playback.current_track) + track = self.playback.current_track + self.controller.append(self.controller.tracks[1:2]) + self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.current_track, track) @populate_playlist - def test_load_preserves_stopped_state(self): - tracks = self.controller.tracks - playback = self.playback - - self.controller.load([tracks[2]]) - self.assertEqual(playback.state, playback.STOPPED) - self.assertEqual(None, self.playback.current_track) + def test_append_preserves_stopped_state(self): + self.controller.append(self.controller.tracks[1:2]) + self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.current_track, None) @populate_playlist def test_move_single(self): @@ -272,7 +266,7 @@ class BaseCurrentPlaylistControllerTest(object): def test_version(self): version = self.controller.version - self.controller.load([]) + self.controller.append([]) self.assert_(version < self.controller.version) @@ -281,7 +275,7 @@ class BasePlaybackControllerTest(object): backend_class = None def setUp(self): - self.backend = self.backend_class(mixer=DummyMixer()) + self.backend = self.backend_class(mixer_class=DummyMixer) self.playback = self.backend.playback self.current_playlist = self.backend.current_playlist @@ -351,10 +345,18 @@ class BasePlaybackControllerTest(object): self.playback.play(self.current_playlist.cp_tracks[-1]) self.assertEqual(self.playback.current_track, self.tracks[-1]) + @populate_playlist + def test_play_skips_to_next_track_on_failure(self): + # If _play() returns False, it is a failure. + self.playback._play = lambda track: track != self.tracks[0] + self.playback.play() + self.assertNotEqual(self.playback.current_track, self.tracks[0]) + self.assertEqual(self.playback.current_track, self.tracks[1]) + @populate_playlist def test_current_track_after_completed_playlist(self): self.playback.play(self.current_playlist.cp_tracks[-1]) - self.playback.end_of_track_callback() + self.playback.on_end_of_track() self.assertEqual(self.playback.state, self.playback.STOPPED) self.assertEqual(self.playback.current_track, None) @@ -363,6 +365,56 @@ class BasePlaybackControllerTest(object): self.assertEqual(self.playback.state, self.playback.STOPPED) self.assertEqual(self.playback.current_track, None) + @populate_playlist + def test_previous(self): + self.playback.play() + self.playback.next() + self.playback.previous() + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_playlist + def test_previous_more(self): + self.playback.play() # At track 0 + self.playback.next() # At track 1 + self.playback.next() # At track 2 + self.playback.previous() # At track 1 + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_playlist + def test_previous_return_value(self): + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.previous(), None) + + @populate_playlist + def test_previous_does_not_trigger_playback(self): + self.playback.play() + self.playback.next() + self.playback.stop() + self.playback.previous() + self.assertEqual(self.playback.state, self.playback.STOPPED) + + @populate_playlist + def test_previous_at_start_of_playlist(self): + self.playback.previous() + self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.current_track, None) + + def test_previous_for_empty_playlist(self): + self.playback.previous() + self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.current_track, None) + + @populate_playlist + def test_previous_skips_to_previous_track_on_failure(self): + # If _play() returns False, it is a failure. + self.playback._play = lambda track: track != self.tracks[1] + self.playback.play(self.current_playlist.cp_tracks[2]) + self.assertEqual(self.playback.current_track, self.tracks[2]) + self.playback.previous() + self.assertNotEqual(self.playback.current_track, self.tracks[1]) + self.assertEqual(self.playback.current_track, self.tracks[0]) + @populate_playlist def test_next(self): self.playback.play() @@ -418,70 +470,40 @@ class BasePlaybackControllerTest(object): self.assertEqual(self.playback.state, self.playback.STOPPED) @populate_playlist - def test_previous(self): + def test_next_skips_to_next_track_on_failure(self): + # If _play() returns False, it is a failure. + self.playback._play = lambda track: track != self.tracks[1] self.playback.play() - self.playback.next() - self.playback.previous() self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_playlist - def test_previous_more(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_playlist - def test_previous_return_value(self): - self.playback.play() self.playback.next() - self.assertEqual(self.playback.previous(), None) - - @populate_playlist - def test_previous_does_not_trigger_playback(self): - self.playback.play() - self.playback.next() - self.playback.stop() - self.playback.previous() - self.assertEqual(self.playback.state, self.playback.STOPPED) - - @populate_playlist - def test_previous_at_start_of_playlist(self): - self.playback.previous() - self.assertEqual(self.playback.state, self.playback.STOPPED) - self.assertEqual(self.playback.current_track, None) - - def test_previous_for_empty_playlist(self): - self.playback.previous() - self.assertEqual(self.playback.state, self.playback.STOPPED) - self.assertEqual(self.playback.current_track, None) + self.assertNotEqual(self.playback.current_track, self.tracks[1]) + self.assertEqual(self.playback.current_track, self.tracks[2]) @populate_playlist def test_next_track_before_play(self): - self.assertEqual(self.playback.next_track, self.tracks[0]) + self.assertEqual(self.playback.track_at_next, self.tracks[0]) @populate_playlist def test_next_track_during_play(self): self.playback.play() - self.assertEqual(self.playback.next_track, self.tracks[1]) + self.assertEqual(self.playback.track_at_next, self.tracks[1]) @populate_playlist def test_next_track_after_previous(self): self.playback.play() self.playback.next() self.playback.previous() - self.assertEqual(self.playback.next_track, self.tracks[1]) + self.assertEqual(self.playback.track_at_next, self.tracks[1]) def test_next_track_empty_playlist(self): - self.assertEqual(self.playback.next_track, None) + self.assertEqual(self.playback.track_at_next, None) @populate_playlist def test_next_track_at_end_of_playlist(self): self.playback.play() for track in self.current_playlist.cp_tracks[1:]: self.playback.next() - self.assertEqual(self.playback.next_track, None) + self.assertEqual(self.playback.track_at_next, None) @populate_playlist def test_next_track_at_end_of_playlist_with_repeat(self): @@ -489,28 +511,189 @@ class BasePlaybackControllerTest(object): self.playback.play() for track in self.tracks[1:]: self.playback.next() - self.assertEqual(self.playback.next_track, self.tracks[0]) + self.assertEqual(self.playback.track_at_next, self.tracks[0]) @populate_playlist def test_next_track_with_random(self): random.seed(1) self.playback.random = True - self.assertEqual(self.playback.next_track, self.tracks[2]) + self.assertEqual(self.playback.track_at_next, self.tracks[2]) + + @populate_playlist + def test_next_with_consume(self): + self.playback.consume = True + self.playback.play() + self.playback.next() + self.assert_(self.tracks[0] in self.backend.current_playlist.tracks) + + @populate_playlist + def test_next_with_single_and_repeat(self): + self.playback.single = True + self.playback.repeat = True + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_playlist + def test_next_with_random(self): + # FIXME feels very fragile + random.seed(1) + self.playback.random = True + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_playlist + def test_next_track_with_random_after_append_playlist(self): + random.seed(1) + self.playback.random = True + self.assertEqual(self.playback.track_at_next, self.tracks[2]) + self.backend.current_playlist.append(self.tracks[:1]) + self.assertEqual(self.playback.track_at_next, self.tracks[1]) + + @populate_playlist + def test_end_of_track(self): + self.playback.play() + + old_position = self.playback.current_playlist_position + old_uri = self.playback.current_track.uri + + self.playback.on_end_of_track() + + self.assertEqual(self.playback.current_playlist_position, + old_position+1) + self.assertNotEqual(self.playback.current_track.uri, old_uri) + + @populate_playlist + def test_end_of_track_return_value(self): + self.playback.play() + self.assertEqual(self.playback.on_end_of_track(), None) + + @populate_playlist + def test_end_of_track_does_not_trigger_playback(self): + self.playback.on_end_of_track() + self.assertEqual(self.playback.state, self.playback.STOPPED) + + @populate_playlist + def test_end_of_track_at_end_of_playlist(self): + self.playback.play() + + for i, track in enumerate(self.tracks): + self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.current_track, track) + self.assertEqual(self.playback.current_playlist_position, i) + + self.playback.on_end_of_track() + + self.assertEqual(self.playback.state, self.playback.STOPPED) + + @populate_playlist + def test_end_of_track_until_end_of_playlist_and_play_from_start(self): + self.playback.play() + + for track in self.tracks: + self.playback.on_end_of_track() + + self.assertEqual(self.playback.current_track, None) + self.assertEqual(self.playback.state, self.playback.STOPPED) + + self.playback.play() + self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.current_track, self.tracks[0]) + + def test_end_of_track_for_empty_playlist(self): + self.playback.on_end_of_track() + self.assertEqual(self.playback.state, self.playback.STOPPED) + + @populate_playlist + def test_end_of_track_skips_to_next_track_on_failure(self): + # If _play() returns False, it is a failure. + self.playback._play = lambda track: track != self.tracks[1] + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.on_end_of_track() + self.assertNotEqual(self.playback.current_track, self.tracks[1]) + self.assertEqual(self.playback.current_track, self.tracks[2]) + + @populate_playlist + def test_end_of_track_track_before_play(self): + self.assertEqual(self.playback.track_at_next, self.tracks[0]) + + @populate_playlist + def test_end_of_track_track_during_play(self): + self.playback.play() + self.assertEqual(self.playback.track_at_next, self.tracks[1]) + + @populate_playlist + def test_end_of_track_track_after_previous(self): + self.playback.play() + self.playback.on_end_of_track() + self.playback.previous() + self.assertEqual(self.playback.track_at_next, self.tracks[1]) + + def test_end_of_track_track_empty_playlist(self): + self.assertEqual(self.playback.track_at_next, None) + + @populate_playlist + def test_end_of_track_track_at_end_of_playlist(self): + self.playback.play() + for track in self.current_playlist.cp_tracks[1:]: + self.playback.on_end_of_track() + self.assertEqual(self.playback.track_at_next, None) + + @populate_playlist + def test_end_of_track_track_at_end_of_playlist_with_repeat(self): + self.playback.repeat = True + self.playback.play() + for track in self.tracks[1:]: + self.playback.on_end_of_track() + self.assertEqual(self.playback.track_at_next, self.tracks[0]) + + @populate_playlist + def test_end_of_track_track_with_random(self): + random.seed(1) + self.playback.random = True + self.assertEqual(self.playback.track_at_next, self.tracks[2]) + + + @populate_playlist + def test_end_of_track_with_consume(self): + self.playback.consume = True + self.playback.play() + self.playback.on_end_of_track() + self.assert_(self.tracks[0] not in self.backend.current_playlist.tracks) + + @populate_playlist + def test_end_of_track_with_random(self): + # FIXME feels very fragile + random.seed(1) + self.playback.random = True + self.playback.play() + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_playlist + def test_end_of_track_track_with_random_after_append_playlist(self): + random.seed(1) + self.playback.random = True + self.assertEqual(self.playback.track_at_next, self.tracks[2]) + self.backend.current_playlist.append(self.tracks[:1]) + self.assertEqual(self.playback.track_at_next, self.tracks[1]) @populate_playlist def test_previous_track_before_play(self): - self.assertEqual(self.playback.previous_track, None) + self.assertEqual(self.playback.track_at_previous, None) @populate_playlist def test_previous_track_after_play(self): self.playback.play() - self.assertEqual(self.playback.previous_track, None) + self.assertEqual(self.playback.track_at_previous, None) @populate_playlist def test_previous_track_after_next(self): self.playback.play() self.playback.next() - self.assertEqual(self.playback.previous_track, self.tracks[0]) + self.assertEqual(self.playback.track_at_previous, self.tracks[0]) @populate_playlist def test_previous_track_after_previous(self): @@ -518,17 +701,17 @@ class BasePlaybackControllerTest(object): self.playback.next() # At track 1 self.playback.next() # At track 2 self.playback.previous() # At track 1 - self.assertEqual(self.playback.previous_track, self.tracks[0]) + self.assertEqual(self.playback.track_at_previous, self.tracks[0]) def test_previous_track_empty_playlist(self): - self.assertEqual(self.playback.previous_track, None) + self.assertEqual(self.playback.track_at_previous, None) @populate_playlist def test_previous_track_with_consume(self): self.playback.consume = True for track in self.tracks: self.playback.next() - self.assertEqual(self.playback.previous_track, + self.assertEqual(self.playback.track_at_previous, self.playback.current_track) @populate_playlist @@ -536,7 +719,7 @@ class BasePlaybackControllerTest(object): self.playback.random = True for track in self.tracks: self.playback.next() - self.assertEqual(self.playback.previous_track, + self.assertEqual(self.playback.track_at_previous, self.playback.current_track) @populate_playlist @@ -572,33 +755,33 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_current_playlist_position_at_end_of_playlist(self): self.playback.play(self.current_playlist.cp_tracks[-1]) - self.playback.end_of_track_callback() + self.playback.on_end_of_track() self.assertEqual(self.playback.current_playlist_position, None) - def test_new_playlist_loaded_callback_gets_called(self): - callback = self.playback.new_playlist_loaded_callback + def test_on_current_playlist_change_gets_called(self): + callback = self.playback.on_current_playlist_change def wrapper(): wrapper.called = True return callback() wrapper.called = False - self.playback.new_playlist_loaded_callback = wrapper - self.backend.current_playlist.load([]) + self.playback.on_current_playlist_change = wrapper + self.backend.current_playlist.append([]) self.assert_(wrapper.called) @populate_playlist - def test_end_of_track_callback_gets_called(self): - end_of_track_callback = self.playback.end_of_track_callback + def test_on_end_of_track_gets_called(self): + on_end_of_track = self.playback.on_end_of_track event = threading.Event() def wrapper(): - result = end_of_track_callback() + result = on_end_of_track() event.set() return result - self.playback.end_of_track_callback = wrapper + self.playback.on_end_of_track = wrapper self.playback.play() self.playback.seek(self.tracks[0].length - 10) @@ -608,27 +791,28 @@ class BasePlaybackControllerTest(object): self.assert_(event.is_set()) @populate_playlist - def test_new_playlist_loaded_callback_when_playing(self): + def test_on_current_playlist_change_when_playing(self): self.playback.play() - self.backend.current_playlist.load([self.tracks[2]]) + current_track = self.playback.current_track + self.backend.current_playlist.append([self.tracks[2]]) self.assertEqual(self.playback.state, self.playback.PLAYING) - self.assertEqual(self.playback.current_track, self.tracks[2]) + self.assertEqual(self.playback.current_track, current_track) @populate_playlist - def test_new_playlist_loaded_callback_when_stopped(self): - self.backend.current_playlist.load([self.tracks[2]]) + def test_on_current_playlist_change_when_stopped(self): + current_track = self.playback.current_track + self.backend.current_playlist.append([self.tracks[2]]) self.assertEqual(self.playback.state, self.playback.STOPPED) self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.next_track, self.tracks[2]) @populate_playlist - def test_new_playlist_loaded_callback_when_paused(self): + def test_on_current_playlist_change_when_paused(self): self.playback.play() self.playback.pause() - self.backend.current_playlist.load([self.tracks[2]]) - self.assertEqual(self.playback.state, self.playback.STOPPED) - self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.next_track, self.tracks[2]) + current_track = self.playback.current_track + self.backend.current_playlist.append([self.tracks[2]]) + self.assertEqual(self.playback.state, self.backend.playback.PAUSED) + self.assertEqual(self.playback.current_track, current_track) @populate_playlist def test_pause_when_stopped(self): @@ -804,19 +988,12 @@ class BasePlaybackControllerTest(object): self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - @populate_playlist - def test_next_with_consume(self): - self.playback.consume = True - self.playback.play() - self.playback.next() - self.assert_(self.tracks[0] not in self.backend.current_playlist.tracks) - @populate_playlist def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): self.playback.consume = True self.playback.play() for i in range(len(self.backend.current_playlist.tracks)): - self.playback.next() + self.playback.on_end_of_track() self.assertEqual(len(self.backend.current_playlist.tracks), 0) @populate_playlist @@ -826,15 +1003,6 @@ class BasePlaybackControllerTest(object): self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[2]) - @populate_playlist - def test_next_with_random(self): - # FIXME feels very fragile - random.seed(1) - self.playback.random = True - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[1]) - @populate_playlist def test_previous_with_random(self): random.seed(1) @@ -848,13 +1016,21 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_end_of_song_starts_next_track(self): self.playback.play() - self.playback.end_of_track_callback() + self.playback.on_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[1]) + @populate_playlist + def test_end_of_song_with_single_and_repeat_starts_same(self): + self.playback.single = True + self.playback.repeat = True + self.playback.play() + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, self.tracks[0]) + @populate_playlist def test_end_of_playlist_stops(self): self.playback.play(self.current_playlist.cp_tracks[-1]) - self.playback.end_of_track_callback() + self.playback.on_end_of_track() self.assertEqual(self.playback.state, self.playback.STOPPED) def test_repeat_off_by_default(self): @@ -872,14 +1048,14 @@ class BasePlaybackControllerTest(object): self.playback.play() for track in self.tracks[1:]: self.playback.next() - self.assertEqual(self.playback.next_track, None) + self.assertEqual(self.playback.track_at_next, None) @populate_playlist def test_random_until_end_of_playlist_and_play_from_start(self): self.playback.repeat = True for track in self.tracks: self.playback.next() - self.assertNotEqual(self.playback.next_track, None) + self.assertNotEqual(self.playback.track_at_next, None) self.assertEqual(self.playback.state, self.playback.STOPPED) self.playback.play() self.assertEqual(self.playback.state, self.playback.PLAYING) @@ -891,15 +1067,7 @@ class BasePlaybackControllerTest(object): self.playback.play() for track in self.tracks: self.playback.next() - self.assertNotEqual(self.playback.next_track, None) - - @populate_playlist - def test_next_track_with_random_after_load_playlist(self): - random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.next_track, self.tracks[2]) - self.backend.current_playlist.load(self.tracks[:1]) - self.assertEqual(self.playback.next_track, self.tracks[0]) + self.assertNotEqual(self.playback.track_at_next, None) @populate_playlist def test_played_track_during_random_not_played_again(self): @@ -911,13 +1079,9 @@ class BasePlaybackControllerTest(object): played.append(self.playback.current_track) self.playback.next() - def test_playing_track_with_invalid_uri(self): - self.backend.current_playlist.load([Track(uri='foobar')]) - self.playback.play() - self.assertEqual(self.playback.state, self.playback.STOPPED) - + @populate_playlist def test_playing_track_that_isnt_in_playlist(self): - test = lambda: self.playback.play(self.tracks[0]) + test = lambda: self.playback.play((17, Track())) self.assertRaises(AssertionError, test) @@ -933,7 +1097,7 @@ class BaseStoredPlaylistsControllerTest(object): settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache') settings.LOCAL_MUSIC_FOLDER = data_folder('') - self.backend = self.backend_class(mixer=DummyMixer()) + self.backend = self.backend_class(mixer_class=DummyMixer) self.stored = self.backend.stored_playlists def tearDown(self): @@ -1002,21 +1166,6 @@ class BaseStoredPlaylistsControllerTest(object): except LookupError as e: self.assertEqual(u'"name=c" match no playlists', e[0]) - def test_search_returns_empty_list(self): - self.assertEqual([], self.stored.search('test')) - - def test_search_returns_playlist(self): - playlist = self.stored.create('test') - playlists = self.stored.search('test') - self.assert_(playlist in playlists) - - def test_search_returns_mulitple_playlists(self): - playlist1 = self.stored.create('test') - playlist2 = self.stored.create('test2') - playlists = self.stored.search('test') - self.assert_(playlist1 in playlists) - self.assert_(playlist2 in playlists) - def test_lookup(self): raise SkipTest @@ -1055,7 +1204,7 @@ class BaseLibraryControllerTest(object): Track()] def setUp(self): - self.backend = self.backend_class(mixer=DummyMixer()) + self.backend = self.backend_class(mixer_class=DummyMixer) self.library = self.backend.library def tearDown(self): diff --git a/tests/backends/despotify_integrationtest.py b/tests/backends/despotify_integrationtest.py deleted file mode 100644 index 4192bf7b..00000000 --- a/tests/backends/despotify_integrationtest.py +++ /dev/null @@ -1,35 +0,0 @@ -# TODO This integration test is work in progress. - -import unittest - -from mopidy.backends.despotify import DespotifyBackend -from mopidy.models import Track - -from tests.backends.base import * - -uris = [ - 'spotify:track:6vqcpVcbI3Zu6sH3ieLDNt', - 'spotify:track:111sulhaZqgsnypz3MkiaW', - 'spotify:track:7t8oznvbeiAPMDRuK0R5ZT', -] - -class DespotifyCurrentPlaylistControllerTest( - BaseCurrentPlaylistControllerTest, unittest.TestCase): - tracks = [Track(uri=uri, length=4464) for i, uri in enumerate(uris)] - backend_class = DespotifyBackend - - -class DespotifyPlaybackControllerTest( - BasePlaybackControllerTest, unittest.TestCase): - tracks = [Track(uri=uri, length=4464) for i, uri in enumerate(uris)] - backend_class = DespotifyBackend - - -class DespotifyStoredPlaylistsControllerTest( - BaseStoredPlaylistsControllerTest, unittest.TestCase): - backend_class = DespotifyBackend - - -class DespotifyLibraryControllerTest( - BaseLibraryControllerTest, unittest.TestCase): - backend_class = DespotifyBackend diff --git a/tests/backends/local/__init__.py b/tests/backends/local/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/backends/local_test.py b/tests/backends/local/backend_test.py similarity index 97% rename from tests/backends/local_test.py rename to tests/backends/local/backend_test.py index 63282bde..aff84658 100644 --- a/tests/backends/local_test.py +++ b/tests/backends/local/backend_test.py @@ -11,7 +11,7 @@ from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.mixers.dummy import DummyMixer from mopidy.models import Playlist, Track -from mopidy.utils import path_to_uri +from mopidy.utils.path import path_to_uri from tests.backends.base import * from tests import SkipTest, data_folder @@ -19,7 +19,6 @@ from tests import SkipTest, data_folder song = data_folder('song%s.wav') generate_song = lambda i: path_to_uri(song % i) - # FIXME can be switched to generic test class LocalCurrentPlaylistControllerTest(BaseCurrentPlaylistControllerTest, unittest.TestCase): @@ -116,7 +115,7 @@ class LocalStoredPlaylistsControllerTest(BaseStoredPlaylistsControllerTest, self.stored.save(playlist) self.backend.destroy() - self.backend = self.backend_class(mixer=DummyMixer()) + self.backend = self.backend_class(mixer_class=DummyMixer) self.stored = self.backend.stored_playlists self.assert_(self.stored.playlists) diff --git a/tests/utils_test.py b/tests/backends/local/translator_test.py similarity index 64% rename from tests/utils_test.py rename to tests/backends/local/translator_test.py index d5c98d86..a9fe58d8 100644 --- a/tests/utils_test.py +++ b/tests/backends/local/translator_test.py @@ -1,77 +1,15 @@ -#encoding: utf-8 +# encoding: utf-8 import os -import sys -import shutil import tempfile import unittest -from mopidy.utils import * +from mopidy.utils.path import path_to_uri +from mopidy.backends.local.translator import parse_m3u, parse_mpd_tag_cache from mopidy.models import Track, Artist, Album from tests import SkipTest, data_folder -class GetOrCreateFolderTest(unittest.TestCase): - def setUp(self): - self.parent = tempfile.mkdtemp() - - def tearDown(self): - if os.path.isdir(self.parent): - shutil.rmtree(self.parent) - - def test_creating_folder(self): - folder = os.path.join(self.parent, 'test') - self.assert_(not os.path.exists(folder)) - self.assert_(not os.path.isdir(folder)) - created = get_or_create_folder(folder) - self.assert_(os.path.exists(folder)) - self.assert_(os.path.isdir(folder)) - self.assertEqual(created, folder) - - def test_creating_existing_folder(self): - created = get_or_create_folder(self.parent) - self.assert_(os.path.exists(self.parent)) - self.assert_(os.path.isdir(self.parent)) - self.assertEqual(created, self.parent) - - def test_that_userfolder_is_expanded(self): - raise SkipTest # Not sure how to safely test this - - -class PathToFileURITest(unittest.TestCase): - def test_simple_path(self): - if sys.platform == 'win32': - result = path_to_uri(u'C:/WINDOWS/clock.avi') - self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') - else: - result = path_to_uri(u'/etc/fstab') - self.assertEqual(result, 'file:///etc/fstab') - - def test_folder_and_path(self): - if sys.platform == 'win32': - result = path_to_uri(u'C:/WINDOWS/', u'clock.avi') - self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') - else: - result = path_to_uri(u'/etc', u'fstab') - self.assertEqual(result, u'file:///etc/fstab') - - def test_space_in_path(self): - if sys.platform == 'win32': - result = path_to_uri(u'C:/test this') - self.assertEqual(result, 'file:///C://test%20this') - else: - result = path_to_uri(u'/tmp/test this') - self.assertEqual(result, u'file:///tmp/test%20this') - - def test_unicode_in_path(self): - if sys.platform == 'win32': - result = path_to_uri(u'C:/æøå') - self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') - else: - result = path_to_uri(u'/tmp/æøå') - self.assertEqual(result, u'file:///tmp/%C3%A6%C3%B8%C3%A5') - - song1_path = data_folder('song1.mp3') song2_path = data_folder('song2.mp3') encoded_path = data_folder(u'æøå.mp3') @@ -79,7 +17,6 @@ song1_uri = path_to_uri(song1_path) song2_uri = path_to_uri(song2_path) encoded_uri = path_to_uri(encoded_path) - class M3UToUriTest(unittest.TestCase): def test_empty_file(self): uris = parse_m3u(data_folder('empty.m3u')) diff --git a/tests/data/blank.flac b/tests/data/blank.flac index b838b98e..ae18d36f 100644 Binary files a/tests/data/blank.flac and b/tests/data/blank.flac differ diff --git a/tests/data/blank.mp3 b/tests/data/blank.mp3 index 3e0b4abb..6aa48cd8 100644 Binary files a/tests/data/blank.mp3 and b/tests/data/blank.mp3 differ diff --git a/tests/data/blank.ogg b/tests/data/blank.ogg index 3b1c57a1..e67e428b 100644 Binary files a/tests/data/blank.ogg and b/tests/data/blank.ogg differ diff --git a/tests/data/blank.wav b/tests/data/blank.wav index 5217ec6f..0041c7ba 100644 Binary files a/tests/data/blank.wav and b/tests/data/blank.wav differ diff --git a/tests/frontends/mpd/audio_output_test.py b/tests/frontends/mpd/audio_output_test.py index c752f40e..24201341 100644 --- a/tests/frontends/mpd/audio_output_test.py +++ b/tests/frontends/mpd/audio_output_test.py @@ -6,8 +6,7 @@ from mopidy.mixers.dummy import DummyMixer class AudioOutputHandlerTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer_class=DummyMixer) self.h = frontend.MpdFrontend(backend=self.b) def test_enableoutput(self): diff --git a/tests/frontends/mpd/command_list_test.py b/tests/frontends/mpd/command_list_test.py index eed92a24..683a1013 100644 --- a/tests/frontends/mpd/command_list_test.py +++ b/tests/frontends/mpd/command_list_test.py @@ -6,8 +6,7 @@ from mopidy.mixers.dummy import DummyMixer class CommandListsTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer_class=DummyMixer) self.h = frontend.MpdFrontend(backend=self.b) def test_command_list_begin(self): diff --git a/tests/frontends/mpd/connection_test.py b/tests/frontends/mpd/connection_test.py index 83133050..341e630c 100644 --- a/tests/frontends/mpd/connection_test.py +++ b/tests/frontends/mpd/connection_test.py @@ -6,8 +6,7 @@ from mopidy.mixers.dummy import DummyMixer class ConnectionHandlerTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer_class=DummyMixer) self.h = frontend.MpdFrontend(backend=self.b) def test_close(self): diff --git a/tests/frontends/mpd/current_playlist_test.py b/tests/frontends/mpd/current_playlist_test.py index 0d639f89..e27e58c5 100644 --- a/tests/frontends/mpd/current_playlist_test.py +++ b/tests/frontends/mpd/current_playlist_test.py @@ -7,14 +7,13 @@ from mopidy.models import Track class CurrentPlaylistHandlerTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer_class=DummyMixer) self.h = frontend.MpdFrontend(backend=self.b) def test_add(self): needle = Track(uri='dummy://foo') self.b.library._library = [Track(), Track(), needle, Track()] - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) result = self.h.handle_request(u'add "dummy://foo"') @@ -23,6 +22,12 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(len(result), 1) self.assert_(u'OK' in result) + def test_add_with_uri_not_found_in_library_should_not_call_lookup(self): + self.b.library.lookup = lambda uri: self.fail("Shouldn't run") + result = self.h.handle_request(u'add "foo"') + self.assertEqual(result[0], + u'ACK [50@0] {add} directory or file not found') + def test_add_with_uri_not_found_in_library_should_ack(self): result = self.h.handle_request(u'add "dummy://foo"') self.assertEqual(result[0], @@ -31,7 +36,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def test_addid_without_songpos(self): needle = Track(uri='dummy://foo') self.b.library._library = [Track(), Track(), needle, Track()] - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) result = self.h.handle_request(u'addid "dummy://foo"') @@ -44,7 +49,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def test_addid_with_songpos(self): needle = Track(uri='dummy://foo') self.b.library._library = [Track(), Track(), needle, Track()] - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) result = self.h.handle_request(u'addid "dummy://foo" "3"') @@ -57,7 +62,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def test_addid_with_songpos_out_of_bounds_should_ack(self): needle = Track(uri='dummy://foo') self.b.library._library = [Track(), Track(), needle, Track()] - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) result = self.h.handle_request(u'addid "dummy://foo" "6"') @@ -68,7 +73,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(result[0], u'ACK [50@0] {addid} No such song') def test_clear(self): - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) result = self.h.handle_request(u'clear') @@ -77,7 +82,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_delete_songpos(self): - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) result = self.h.handle_request(u'delete "%d"' % @@ -86,7 +91,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_delete_songpos_out_of_bounds(self): - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) result = self.h.handle_request(u'delete "5"') @@ -94,7 +99,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(result[0], u'ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) result = self.h.handle_request(u'delete "1:"') @@ -102,7 +107,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_delete_closed_range(self): - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) result = self.h.handle_request(u'delete "1:3"') @@ -110,7 +115,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_delete_range_out_of_bounds(self): - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) result = self.h.handle_request(u'delete "5:7"') @@ -118,21 +123,21 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(result[0], u'ACK [2@0] {delete} Bad song index') def test_deleteid(self): - self.b.current_playlist.load([Track(), Track()]) + self.b.current_playlist.append([Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 2) result = self.h.handle_request(u'deleteid "2"') self.assertEqual(len(self.b.current_playlist.tracks), 1) self.assert_(u'OK' in result) def test_deleteid_does_not_exist(self): - self.b.current_playlist.load([Track(), Track()]) + self.b.current_playlist.append([Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 2) result = self.h.handle_request(u'deleteid "12345"') self.assertEqual(len(self.b.current_playlist.tracks), 2) self.assertEqual(result[0], u'ACK [50@0] {deleteid} No such song') def test_move_songpos(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -146,7 +151,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_move_open_range(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -160,7 +165,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_move_closed_range(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -174,7 +179,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_moveid(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -196,33 +201,37 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): result = self.h.handle_request(u'playlistfind "tag" "needle"') self.assert_(u'ACK [0@0] {} Not implemented' in result) - def test_playlistfind_by_filename(self): + def test_playlistfind_by_filename_not_in_current_playlist(self): result = self.h.handle_request( u'playlistfind "filename" "file:///dev/null"') + self.assertEqual(len(result), 1) self.assert_(u'OK' in result) def test_playlistfind_by_filename_without_quotes(self): result = self.h.handle_request( u'playlistfind filename "file:///dev/null"') + self.assertEqual(len(result), 1) self.assert_(u'OK' in result) def test_playlistfind_by_filename_in_current_playlist(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(uri='file:///exists')]) result = self.h.handle_request( u'playlistfind filename "file:///exists"') self.assert_(u'file: file:///exists' in result) + self.assert_(u'Id: 1' in result) + self.assert_(u'Pos: 0' in result) self.assert_(u'OK' in result) def test_playlistid_without_songid(self): - self.b.current_playlist.load([Track(name='a'), Track(name='b')]) + self.b.current_playlist.append([Track(name='a'), Track(name='b')]) result = self.h.handle_request(u'playlistid') self.assert_(u'Title: a' in result) self.assert_(u'Title: b' in result) self.assert_(u'OK' in result) def test_playlistid_with_songid(self): - self.b.current_playlist.load([Track(name='a'), Track(name='b')]) + self.b.current_playlist.append([Track(name='a'), Track(name='b')]) result = self.h.handle_request(u'playlistid "2"') self.assert_(u'Title: a' not in result) self.assert_(u'Id: 1' not in result) @@ -231,12 +240,12 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_playlistid_with_not_existing_songid_fails(self): - self.b.current_playlist.load([Track(name='a'), Track(name='b')]) + self.b.current_playlist.append([Track(name='a'), Track(name='b')]) result = self.h.handle_request(u'playlistid "25"') self.assertEqual(result[0], u'ACK [50@0] {playlistid} No such song') def test_playlistinfo_without_songpos_or_range(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -250,7 +259,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_playlistinfo_with_songpos(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -269,7 +278,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(result1, result2) def test_playlistinfo_with_open_range(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -283,7 +292,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_playlistinfo_with_closed_range(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -313,7 +322,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'ACK [0@0] {} Not implemented' in result) def test_plchanges(self): - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) result = self.h.handle_request(u'plchanges "0"') self.assert_(u'Title: a' in result) @@ -322,7 +331,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_plchanges_with_minus_one_returns_entire_playlist(self): - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) result = self.h.handle_request(u'plchanges "-1"') self.assert_(u'Title: a' in result) @@ -331,7 +340,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_plchanges_without_quotes_works(self): - self.b.current_playlist.load( + self.b.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) result = self.h.handle_request(u'plchanges 0') self.assert_(u'Title: a' in result) @@ -340,7 +349,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_plchangesposid(self): - self.b.current_playlist.load([Track(), Track(), Track()]) + self.b.current_playlist.append([Track(), Track(), Track()]) result = self.h.handle_request(u'plchangesposid "0"') self.assert_(u'cpos: 0' in result) self.assert_(u'Id: %d' % self.b.current_playlist.cp_tracks[0][0] @@ -354,7 +363,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_shuffle_without_range(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -364,7 +373,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_shuffle_with_open_range(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -378,7 +387,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_shuffle_with_closed_range(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -392,7 +401,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_swap(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -406,7 +415,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_swapid(self): - self.b.current_playlist.load([ + self.b.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) diff --git a/tests/frontends/mpd/music_db_test.py b/tests/frontends/mpd/music_db_test.py index 62915a58..fc8f980a 100644 --- a/tests/frontends/mpd/music_db_test.py +++ b/tests/frontends/mpd/music_db_test.py @@ -6,8 +6,7 @@ from mopidy.mixers.dummy import DummyMixer class MusicDatabaseHandlerTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer_class=DummyMixer) self.h = frontend.MpdFrontend(backend=self.b) def test_count(self): diff --git a/tests/frontends/mpd/playback_test.py b/tests/frontends/mpd/playback_test.py index 3cf0a11f..17263aef 100644 --- a/tests/frontends/mpd/playback_test.py +++ b/tests/frontends/mpd/playback_test.py @@ -7,8 +7,7 @@ from mopidy.models import Track class PlaybackOptionsHandlerTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer_class=DummyMixer) self.h = frontend.MpdFrontend(backend=self.b) def test_consume_off(self): @@ -167,8 +166,7 @@ class PlaybackOptionsHandlerTest(unittest.TestCase): class PlaybackControlHandlerTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer_class=DummyMixer) self.h = frontend.MpdFrontend(backend=self.b) def test_next(self): @@ -176,7 +174,7 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_pause_off(self): - self.b.current_playlist.load([Track()]) + self.b.current_playlist.append([Track()]) self.h.handle_request(u'play "0"') self.h.handle_request(u'pause "1"') result = self.h.handle_request(u'pause "0"') @@ -184,14 +182,14 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) def test_pause_on(self): - self.b.current_playlist.load([Track()]) + self.b.current_playlist.append([Track()]) self.h.handle_request(u'play "0"') result = self.h.handle_request(u'pause "1"') self.assert_(u'OK' in result) self.assertEqual(self.b.playback.PAUSED, self.b.playback.state) def test_pause_toggle(self): - self.b.current_playlist.load([Track()]) + self.b.current_playlist.append([Track()]) result = self.h.handle_request(u'play "0"') self.assert_(u'OK' in result) self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) @@ -203,37 +201,49 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) def test_play_without_pos(self): - self.b.current_playlist.load([Track()]) + self.b.current_playlist.append([Track()]) self.b.playback.state = self.b.playback.PAUSED result = self.h.handle_request(u'play') self.assert_(u'OK' in result) self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) def test_play_with_pos(self): - self.b.current_playlist.load([Track()]) + self.b.current_playlist.append([Track()]) result = self.h.handle_request(u'play "0"') self.assert_(u'OK' in result) self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) def test_play_with_pos_without_quotes(self): - self.b.current_playlist.load([Track()]) + self.b.current_playlist.append([Track()]) result = self.h.handle_request(u'play 0') self.assert_(u'OK' in result) self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) def test_play_with_pos_out_of_bounds(self): - self.b.current_playlist.load([]) + self.b.current_playlist.append([]) result = self.h.handle_request(u'play "0"') self.assertEqual(result[0], u'ACK [2@0] {play} Bad song index') self.assertEqual(self.b.playback.STOPPED, self.b.playback.state) - def test_play_minus_one_plays_first_in_playlist(self): - track = Track() - self.b.current_playlist.load([track]) + def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): + self.assertEqual(self.b.playback.current_track, None) + self.b.current_playlist.append([Track(uri='a'), Track(uri='b')]) result = self.h.handle_request(u'play "-1"') self.assert_(u'OK' in result) self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) - self.assertEqual(self.b.playback.current_track, track) + self.assertEqual(self.b.playback.current_track.uri, 'a') + + def test_play_minus_one_plays_current_track_if_current_track_is_set(self): + self.b.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.b.playback.current_track, None) + self.b.playback.play() + self.b.playback.next() + self.b.playback.stop() + self.assertNotEqual(self.b.playback.current_track, None) + result = self.h.handle_request(u'play "-1"') + self.assert_(u'OK' in result) + self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) + self.assertEqual(self.b.playback.current_track.uri, 'b') def test_play_minus_one_on_empty_playlist_does_not_ack(self): self.b.current_playlist.clear() @@ -243,18 +253,30 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assertEqual(self.b.playback.current_track, None) def test_playid(self): - self.b.current_playlist.load([Track()]) + self.b.current_playlist.append([Track()]) result = self.h.handle_request(u'playid "1"') self.assert_(u'OK' in result) self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) - def test_playid_minus_one_plays_first_in_playlist(self): - track = Track() - self.b.current_playlist.load([track]) + def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self): + self.assertEqual(self.b.playback.current_track, None) + self.b.current_playlist.append([Track(uri='a'), Track(uri='b')]) result = self.h.handle_request(u'playid "-1"') self.assert_(u'OK' in result) self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) - self.assertEqual(self.b.playback.current_track, track) + self.assertEqual(self.b.playback.current_track.uri, 'a') + + def test_play_minus_one_plays_current_track_if_current_track_is_set(self): + self.b.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.b.playback.current_track, None) + self.b.playback.play() + self.b.playback.next() + self.b.playback.stop() + self.assertNotEqual(self.b.playback.current_track, None) + result = self.h.handle_request(u'playid "-1"') + self.assert_(u'OK' in result) + self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) + self.assertEqual(self.b.playback.current_track.uri, 'b') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): self.b.current_playlist.clear() @@ -264,7 +286,7 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assertEqual(self.b.playback.current_track, None) def test_playid_which_does_not_exist(self): - self.b.current_playlist.load([Track()]) + self.b.current_playlist.append([Track()]) result = self.h.handle_request(u'playid "12345"') self.assertEqual(result[0], u'ACK [50@0] {playid} No such song') @@ -273,12 +295,32 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_seek(self): + self.b.current_playlist.append([Track(length=40000)]) + self.h.handle_request(u'seek "0"') result = self.h.handle_request(u'seek "0" "30"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.assert_(u'OK' in result) + self.assert_(self.b.playback.time_position >= 30000) + + def test_seek_with_songpos(self): + seek_track = Track(uri='2', length=40000) + self.b.current_playlist.append( + [Track(uri='1', length=40000), seek_track]) + result = self.h.handle_request(u'seek "1" "30"') + self.assertEqual(self.b.playback.current_track, seek_track) def test_seekid(self): - result = self.h.handle_request(u'seekid "0" "30"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.b.current_playlist.append([Track(length=40000)]) + result = self.h.handle_request(u'seekid "1" "30"') + self.assert_(u'OK' in result) + self.assert_(self.b.playback.time_position >= 30000) + + def test_seekid_with_cpid(self): + seek_track = Track(uri='2', length=40000) + self.b.current_playlist.append( + [Track(length=40000), seek_track]) + result = self.h.handle_request(u'seekid "2" "30"') + self.assertEqual(self.b.playback.current_cpid, 2) + self.assertEqual(self.b.playback.current_track, seek_track) def test_stop(self): result = self.h.handle_request(u'stop') diff --git a/tests/frontends/mpd/reflection_test.py b/tests/frontends/mpd/reflection_test.py index 11bd5ba9..5491946c 100644 --- a/tests/frontends/mpd/reflection_test.py +++ b/tests/frontends/mpd/reflection_test.py @@ -6,8 +6,7 @@ from mopidy.mixers.dummy import DummyMixer class ReflectionHandlerTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer_class=DummyMixer) self.h = frontend.MpdFrontend(backend=self.b) def test_commands_returns_list_of_all_commands(self): diff --git a/tests/frontends/mpd/request_handler_test.py b/tests/frontends/mpd/request_handler_test.py index beea4bc3..ac8bd7e9 100644 --- a/tests/frontends/mpd/request_handler_test.py +++ b/tests/frontends/mpd/request_handler_test.py @@ -6,8 +6,7 @@ from mopidy.mixers.dummy import DummyMixer class RequestHandlerTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer_class=DummyMixer) self.h = frontend.MpdFrontend(backend=self.b) def test_register_same_pattern_twice_fails(self): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 8be549d6..9839acfe 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -7,8 +7,7 @@ from mopidy.models import Track class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer_class=DummyMixer) self.h = frontend.MpdFrontend(backend=self.b) def test_clearerror(self): @@ -17,7 +16,7 @@ class StatusHandlerTest(unittest.TestCase): def test_currentsong(self): track = Track() - self.b.current_playlist.load([track]) + self.b.current_playlist.append([track]) self.b.playback.play() result = self.h.handle_request(u'currentsong') self.assert_(u'file: ' in result) @@ -156,21 +155,21 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): - self.b.current_playlist.load([Track()]) + self.b.current_playlist.append([Track()]) self.b.playback.play() result = dict(frontend.status.status(self.h)) self.assert_('song' in result) self.assert_(int(result['song']) >= 0) def test_status_method_when_playlist_loaded_contains_cpid_as_songid(self): - self.b.current_playlist.load([Track()]) + self.b.current_playlist.append([Track()]) self.b.playback.play() result = dict(frontend.status.status(self.h)) self.assert_('songid' in result) self.assertEqual(int(result['songid']), 1) def test_status_method_when_playing_contains_time_with_no_length(self): - self.b.current_playlist.load([Track(length=None)]) + self.b.current_playlist.append([Track(length=None)]) self.b.playback.play() result = dict(frontend.status.status(self.h)) self.assert_('time' in result) @@ -180,7 +179,7 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(position <= total) def test_status_method_when_playing_contains_time_with_length(self): - self.b.current_playlist.load([Track(length=10000)]) + self.b.current_playlist.append([Track(length=10000)]) self.b.playback.play() result = dict(frontend.status.status(self.h)) self.assert_('time' in result) @@ -197,7 +196,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['elapsed']), 59123) def test_status_method_when_playing_contains_bitrate(self): - self.b.current_playlist.load([Track(bitrate=320)]) + self.b.current_playlist.append([Track(bitrate=320)]) self.b.playback.play() result = dict(frontend.status.status(self.h)) self.assert_('bitrate' in result) diff --git a/tests/frontends/mpd/stickers_test.py b/tests/frontends/mpd/stickers_test.py index 83bbdd04..401eaf57 100644 --- a/tests/frontends/mpd/stickers_test.py +++ b/tests/frontends/mpd/stickers_test.py @@ -6,8 +6,7 @@ from mopidy.mixers.dummy import DummyMixer class StickersHandlerTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer_class=DummyMixer) self.h = frontend.MpdFrontend(backend=self.b) def test_sticker_get(self): diff --git a/tests/frontends/mpd/stored_playlists_test.py b/tests/frontends/mpd/stored_playlists_test.py index 179e0802..6e5717af 100644 --- a/tests/frontends/mpd/stored_playlists_test.py +++ b/tests/frontends/mpd/stored_playlists_test.py @@ -6,12 +6,9 @@ from mopidy.frontends.mpd import frontend from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track, Playlist -from tests import SkipTest - class StoredPlaylistsHandlerTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer_class=DummyMixer) self.h = frontend.MpdFrontend(backend=self.b) def test_listplaylist(self): @@ -50,12 +47,24 @@ class StoredPlaylistsHandlerTest(unittest.TestCase): self.assert_(u'Last-Modified: 2001-03-17T13:41:17Z' in result) self.assert_(u'OK' in result) - def test_load(self): - result = self.h.handle_request(u'load "name"') + def test_load_known_playlist_appends_to_current_playlist(self): + self.b.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.b.current_playlist.tracks), 2) + self.b.stored_playlists.playlists = [Playlist(name='A-list', + tracks=[Track(uri='c'), Track(uri='d'), Track(uri='e')])] + result = self.h.handle_request(u'load "A-list"') self.assert_(u'OK' in result) + self.assertEqual(len(self.b.current_playlist.tracks), 5) + self.assertEqual(self.b.current_playlist.tracks[0].uri, 'a') + self.assertEqual(self.b.current_playlist.tracks[1].uri, 'b') + self.assertEqual(self.b.current_playlist.tracks[2].uri, 'c') + self.assertEqual(self.b.current_playlist.tracks[3].uri, 'd') + self.assertEqual(self.b.current_playlist.tracks[4].uri, 'e') - def test_load_appends(self): - raise SkipTest + def test_load_unknown_playlist_acks(self): + result = self.h.handle_request(u'load "unknown playlist"') + self.assert_(u'ACK [50@0] {load} No such playlist' in result) + self.assertEqual(len(self.b.current_playlist.tracks), 0) def test_playlistadd(self): result = self.h.handle_request( diff --git a/tests/mixers/base_test.py b/tests/mixers/base_test.py index 0659a12e..d6129ad5 100644 --- a/tests/mixers/base_test.py +++ b/tests/mixers/base_test.py @@ -1,18 +1,16 @@ -import unittest - -from mopidy.mixers.dummy import DummyMixer - -class BaseMixerTest(unittest.TestCase): +class BaseMixerTest(object): MIN = 0 MAX = 100 - ACTUAL_MIN = MIN ACTUAL_MAX = MAX - INITIAL = None + mixer_class = None + def setUp(self): - self.mixer = DummyMixer() + assert self.mixer_class is not None, \ + "mixer_class must be set in subclass" + self.mixer = self.mixer_class(None) def test_initial_volume(self): self.assertEqual(self.mixer.volume, self.INITIAL) diff --git a/tests/mixers/denon_test.py b/tests/mixers/denon_test.py index bf387418..5370f155 100644 --- a/tests/mixers/denon_test.py +++ b/tests/mixers/denon_test.py @@ -1,3 +1,5 @@ +import unittest + from mopidy.mixers.denon import DenonMixer from tests.mixers.base_test import BaseMixerTest @@ -22,14 +24,15 @@ class DenonMixerDeviceMock(object): def open(self): self._open = True -class DenonMixerTest(BaseMixerTest): +class DenonMixerTest(BaseMixerTest, unittest.TestCase): ACTUAL_MAX = 99 - INITIAL = 1 + mixer_class = DenonMixer + def setUp(self): self.device = DenonMixerDeviceMock() - self.mixer = DenonMixer(device=self.device) + self.mixer = DenonMixer(None, device=self.device) def test_reopen_device(self): self.device._open = False diff --git a/tests/mixers/dummy_test.py b/tests/mixers/dummy_test.py new file mode 100644 index 00000000..334dc8a1 --- /dev/null +++ b/tests/mixers/dummy_test.py @@ -0,0 +1,17 @@ +import unittest + +from mopidy.mixers.dummy import DummyMixer +from tests.mixers.base_test import BaseMixerTest + +class DenonMixerTest(BaseMixerTest, unittest.TestCase): + mixer_class = DummyMixer + + def test_set_volume_is_capped(self): + self.mixer.amplification_factor = 0.5 + self.mixer.volume = 100 + self.assertEquals(self.mixer._volume, 50) + + def test_get_volume_does_not_show_that_the_volume_is_capped(self): + self.mixer.amplification_factor = 0.5 + self.mixer._volume = 50 + self.assertEquals(self.mixer.volume, 100) diff --git a/tests/outputs/__init__.py b/tests/outputs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py new file mode 100644 index 00000000..c063aaee --- /dev/null +++ b/tests/outputs/gstreamer_test.py @@ -0,0 +1,58 @@ +import multiprocessing +import unittest + +from mopidy.outputs.gstreamer import GStreamerOutput +from mopidy.process import pickle_connection +from mopidy.utils.path import path_to_uri + +from tests import data_folder, SkipTest + +class GStreamerOutputTest(unittest.TestCase): + def setUp(self): + self.song_uri = path_to_uri(data_folder('song1.wav')) + self.output_queue = multiprocessing.Queue() + self.core_queue = multiprocessing.Queue() + self.output = GStreamerOutput(self.core_queue, self.output_queue) + + def tearDown(self): + self.output.destroy() + + def send_recv(self, message): + (my_end, other_end) = multiprocessing.Pipe() + message.update({'reply_to': pickle_connection(other_end)}) + self.output_queue.put(message) + my_end.poll(None) + return my_end.recv() + + def send(self, message): + self.output_queue.put(message) + + @SkipTest + def test_play_uri_existing_file(self): + message = {'command': 'play_uri', 'uri': self.song_uri} + self.assertEqual(True, self.send_recv(message)) + + @SkipTest + def test_play_uri_non_existing_file(self): + message = {'command': 'play_uri', 'uri': self.song_uri + 'bogus'} + self.assertEqual(False, self.send_recv(message)) + + def test_default_get_volume_result(self): + message = {'command': 'get_volume'} + self.assertEqual(100, self.send_recv(message)) + + def test_set_volume(self): + self.send({'command': 'set_volume', 'volume': 50}) + self.assertEqual(50, self.send_recv({'command': 'get_volume'})) + + def test_set_volume_to_zero(self): + self.send({'command': 'set_volume', 'volume': 0}) + self.assertEqual(0, self.send_recv({'command': 'get_volume'})) + + def test_set_volume_to_one_hundred(self): + self.send({'command': 'set_volume', 'volume': 100}) + self.assertEqual(100, self.send_recv({'command': 'get_volume'})) + + @SkipTest + def test_set_state(self): + raise NotImplementedError diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/init_test.py b/tests/utils/init_test.py new file mode 100644 index 00000000..fb38e2ea --- /dev/null +++ b/tests/utils/init_test.py @@ -0,0 +1,22 @@ +import unittest + +from mopidy.utils import get_class + +class GetClassTest(unittest.TestCase): + def test_loading_module_that_does_not_exist(self): + test = lambda: get_class('foo.bar.Baz') + self.assertRaises(ImportError, test) + + def test_loading_class_that_does_not_exist(self): + test = lambda: get_class('unittest.FooBarBaz') + self.assertRaises(ImportError, test) + + def test_import_error_message_contains_complete_class_path(self): + try: + get_class('foo.bar.Baz') + except ImportError as e: + self.assert_('foo.bar.Baz' in str(e)) + + def test_loading_existing_class(self): + cls = get_class('unittest.TestCase') + self.assertEqual(cls.__name__, 'TestCase') diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py new file mode 100644 index 00000000..ae63d5c0 --- /dev/null +++ b/tests/utils/path_test.py @@ -0,0 +1,71 @@ +# encoding: utf-8 + +import os +import shutil +import sys +import tempfile +import unittest + +from mopidy.utils.path import get_or_create_folder, path_to_uri + +from tests import SkipTest + +class GetOrCreateFolderTest(unittest.TestCase): + def setUp(self): + self.parent = tempfile.mkdtemp() + + def tearDown(self): + if os.path.isdir(self.parent): + shutil.rmtree(self.parent) + + def test_creating_folder(self): + folder = os.path.join(self.parent, 'test') + self.assert_(not os.path.exists(folder)) + self.assert_(not os.path.isdir(folder)) + created = get_or_create_folder(folder) + self.assert_(os.path.exists(folder)) + self.assert_(os.path.isdir(folder)) + self.assertEqual(created, folder) + + def test_creating_existing_folder(self): + created = get_or_create_folder(self.parent) + self.assert_(os.path.exists(self.parent)) + self.assert_(os.path.isdir(self.parent)) + self.assertEqual(created, self.parent) + + def test_that_userfolder_is_expanded(self): + raise SkipTest # Not sure how to safely test this + + +class PathToFileURITest(unittest.TestCase): + def test_simple_path(self): + if sys.platform == 'win32': + result = path_to_uri(u'C:/WINDOWS/clock.avi') + self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') + else: + result = path_to_uri(u'/etc/fstab') + self.assertEqual(result, 'file:///etc/fstab') + + def test_folder_and_path(self): + if sys.platform == 'win32': + result = path_to_uri(u'C:/WINDOWS/', u'clock.avi') + self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') + else: + result = path_to_uri(u'/etc', u'fstab') + self.assertEqual(result, u'file:///etc/fstab') + + def test_space_in_path(self): + if sys.platform == 'win32': + result = path_to_uri(u'C:/test this') + self.assertEqual(result, 'file:///C://test%20this') + else: + result = path_to_uri(u'/tmp/test this') + self.assertEqual(result, u'file:///tmp/test%20this') + + def test_unicode_in_path(self): + if sys.platform == 'win32': + result = path_to_uri(u'C:/æøå') + self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') + else: + result = path_to_uri(u'/tmp/æøå') + self.assertEqual(result, u'file:///tmp/%C3%A6%C3%B8%C3%A5') diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py new file mode 100644 index 00000000..5bf0f9b4 --- /dev/null +++ b/tests/utils/settings_test.py @@ -0,0 +1,45 @@ +import unittest + +from mopidy.utils.settings import validate_settings + +class ValidateSettingsTest(unittest.TestCase): + def setUp(self): + self.defaults = { + 'MPD_SERVER_HOSTNAME': '::', + 'MPD_SERVER_PORT': 6600, + } + + def test_no_errors_yields_empty_dict(self): + result = validate_settings(self.defaults, {}) + self.assertEqual(result, {}) + + def test_unknown_setting_returns_error(self): + result = validate_settings(self.defaults, + {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) + self.assertEqual(result['MPD_SERVER_HOSTNMAE'], + u'Unknown setting. Is it misspelled?') + + def test_not_renamed_setting_returns_error(self): + result = validate_settings(self.defaults, + {'SERVER_HOSTNAME': '127.0.0.1'}) + self.assertEqual(result['SERVER_HOSTNAME'], + u'Deprecated setting. Use MPD_SERVER_HOSTNAME.') + + def test_unneeded_settings_returns_error(self): + result = validate_settings(self.defaults, + {'SPOTIFY_LIB_APPKEY': '/tmp/foo'}) + self.assertEqual(result['SPOTIFY_LIB_APPKEY'], + u'Deprecated setting. It may be removed.') + + def test_deprecated_setting_value_returns_error(self): + result = validate_settings(self.defaults, + {'BACKENDS': ('mopidy.backends.despotify.DespotifyBackend',)}) + self.assertEqual(result['BACKENDS'], + u'Deprecated setting value. ' + + '"mopidy.backends.despotify.DespotifyBackend" is no longer ' + + 'available.') + + def test_two_errors_are_both_reported(self): + result = validate_settings(self.defaults, + {'FOO': '', 'BAR': ''}) + self.assertEquals(len(result), 2)