diff --git a/data/mopidy.desktop b/data/mopidy.desktop index 70257d58..88dd5ae4 100644 --- a/data/mopidy.desktop +++ b/data/mopidy.desktop @@ -8,3 +8,4 @@ TryExec=mopidy Exec=mopidy Terminal=true Categories=AudioVideo;Audio;Player;ConsoleOnly; +StartupNotify=true diff --git a/docs/api/backends/controllers.rst b/docs/api/backends/controllers.rst index 28112cf7..20dc2d61 100644 --- a/docs/api/backends/controllers.rst +++ b/docs/api/backends/controllers.rst @@ -15,7 +15,6 @@ The backend .. autoclass:: mopidy.backends.base.Backend :members: - :undoc-members: Playback controller @@ -26,7 +25,6 @@ seek. .. autoclass:: mopidy.backends.base.PlaybackController :members: - :undoc-members: Mixer controller @@ -42,7 +40,6 @@ Manages everything related to the currently loaded playlist. .. autoclass:: mopidy.backends.base.CurrentPlaylistController :members: - :undoc-members: Stored playlists controller @@ -52,7 +49,6 @@ Manages stored playlist. .. autoclass:: mopidy.backends.base.StoredPlaylistsController :members: - :undoc-members: Library controller @@ -62,4 +58,3 @@ Manages the music library, e.g. searching for tracks to be added to a playlist. .. autoclass:: mopidy.backends.base.LibraryController :members: - :undoc-members: diff --git a/docs/api/backends/providers.rst b/docs/api/backends/providers.rst index 903e220b..61e5f68a 100644 --- a/docs/api/backends/providers.rst +++ b/docs/api/backends/providers.rst @@ -14,7 +14,6 @@ Playback provider .. autoclass:: mopidy.backends.base.BasePlaybackProvider :members: - :undoc-members: Stored playlists provider @@ -22,7 +21,6 @@ Stored playlists provider .. autoclass:: mopidy.backends.base.BaseStoredPlaylistsProvider :members: - :undoc-members: Library provider @@ -30,7 +28,6 @@ Library provider .. autoclass:: mopidy.backends.base.BaseLibraryProvider :members: - :undoc-members: Backend provider implementations diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 0c1e32a3..dc53cca2 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -2,25 +2,30 @@ Frontend API ************ -A frontend may do whatever it wants to, including creating threads, opening TCP -ports and exposing Mopidy for a type of clients. - -Frontends got one main limitation: they are restricted to passing messages -through the ``core_queue`` for all communication with the rest of Mopidy. Thus, -the frontend API is very small and reveals little of what a frontend may do. - -.. warning:: - - A stable frontend API is not available yet, as we've only implemented a - couple of frontend modules. - -.. automodule:: mopidy.frontends.base - :synopsis: Base class for frontends - :members: +The following requirements applies to any frontend implementation: +- A frontend MAY do mostly whatever it wants to, including creating threads, + opening TCP ports and exposing Mopidy for a group of clients. +- A frontend MUST implement at least one `Pykka + `_ actor, called the "main actor" from here + on. +- It MAY use additional actors to implement whatever it does, and using actors + in frontend implementations is encouraged. +- The frontend is activated by including its main actor in the + :attr:`mopidy.settings.FRONTENDS` setting. +- The main actor MUST be able to start and stop the frontend when the main + actor is started and stopped. +- The frontend MAY require additional settings to be set for it to + work. +- Such settings MUST be documented. +- The main actor MUST stop itself if the defined settings are not adequate for + the frontend to work properly. +- Any actor which is part of the frontend MAY implement any listener interface + from :mod:`mopidy.listeners` to receive notification of the specified events. Frontend implementations ======================== * :mod:`mopidy.frontends.lastfm` * :mod:`mopidy.frontends.mpd` +* :mod:`mopidy.frontends.mpris` diff --git a/docs/api/listeners.rst b/docs/api/listeners.rst new file mode 100644 index 00000000..609dc3c7 --- /dev/null +++ b/docs/api/listeners.rst @@ -0,0 +1,7 @@ +************ +Listener API +************ + +.. automodule:: mopidy.listeners + :synopsis: Listener API + :members: diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst index 6daa7a4e..2459db8c 100644 --- a/docs/api/mixers.rst +++ b/docs/api/mixers.rst @@ -30,7 +30,6 @@ methods as described below. .. automodule:: mopidy.mixers.base :synopsis: Mixer API :members: - :undoc-members: Mixer implementations diff --git a/docs/api/models.rst b/docs/api/models.rst index ef11547e..5833e58c 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -25,4 +25,3 @@ Data model API .. automodule:: mopidy.models :synopsis: Data model API :members: - :undoc-members: diff --git a/docs/changes.rst b/docs/changes.rst index 4ccf62c9..445e7984 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,6 +5,78 @@ Changes This change log is used to track all major changes to Mopidy. +v0.6.0 (2011-10-09) +=================== + +The development of Mopidy have been quite slow for the last couple of months, +but we do have some goodies to release which have been idling in the +develop branch since the warmer days of the summer. This release brings support +for the MPD ``idle`` command, which makes it possible for a client wait for +updates from the server instead of polling every second. Also, we've added +support for the MPRIS standard, so that Mopidy can be controlled over D-Bus +from e.g. the Ubuntu Sound Menu. + +Please note that 0.6.0 requires some updated dependencies, as listed under +*Important changes* below. + +**Important changes** + +- Pykka 0.12.3 or greater is required. + +- pyspotify 1.4 or greater is required. + +- All config, data, and cache locations are now based on the XDG spec. + + - This means that your settings file will need to be moved from + ``~/.mopidy/settings.py`` to ``~/.config/mopidy/settings.py``. + - Your Spotify cache will now be stored in ``~/.cache/mopidy`` instead of + ``~/.mopidy/spotify_cache``. + - The local backend's ``tag_cache`` should now be in + ``~/.local/share/mopidy/tag_cache``, likewise your playlists will be in + ``~/.local/share/mopidy/playlists``. + - The local client now tries to lookup where your music is via XDG, it will + fall-back to ``~/music`` or use whatever setting you set manually. + +- The MPD command ``idle`` is now supported by Mopidy for the following + subsystems: player, playlist, options, and mixer. (Fixes: :issue:`32`) + +- A new frontend :mod:`mopidy.frontends.mpris` have been added. It exposes + Mopidy through the `MPRIS interface `_ over D-Bus. In + practice, this makes it possible to control Mopidy through the `Ubuntu Sound + Menu `_. + +**Changes** + +- Replace :attr:`mopidy.backends.base.Backend.uri_handlers` with + :attr:`mopidy.backends.base.Backend.uri_schemes`, which just takes the part + up to the colon of an URI, and not any prefix. + +- Add Listener API, :mod:`mopidy.listeners`, to be implemented by actors + wanting to receive events from the backend. This is a formalization of the + ad hoc events the Last.fm scrobbler has already been using for some time. + +- Replaced all of the MPD network code that was provided by asyncore with + custom stack. This change was made to facilitate support for the ``idle`` + command, and to reduce the number of event loops being used. + +- Fix metadata update in Shoutcast streaming. (Fixes: :issue:`122`) + +- Unescape all incoming MPD requests. (Fixes: :issue:`113`) + +- Increase the maximum number of results returned by Spotify searches from 32 + to 100. + +- Send Spotify search queries to pyspotify as unicode objects, as required by + pyspotify 1.4. (Fixes: :issue:`129`) + +- Add setting :attr:`mopidy.settings.MPD_SERVER_MAX_CONNECTIONS`. (Fixes: + :issue:`134`) + +- Remove `destroy()` methods from backend controller and provider APIs, as it + was not in use and actually not called by any code. Will reintroduce when + needed. + + v0.5.0 (2011-06-15) =================== @@ -87,6 +159,18 @@ Please note that 0.5.0 requires some updated dependencies, as listed under - Found and worked around strange WMA metadata behaviour. +- Backend API: + + - Calling on :meth:`mopidy.backends.base.playback.PlaybackController.next` + and :meth:`mopidy.backends.base.playback.PlaybackController.previous` no + longer implies that playback should be started. The playback state--whether + playing, paused or stopped--will now be kept. + + - The method + :meth:`mopidy.backends.base.playback.PlaybackController.change_track` + has been added. Like ``next()``, and ``prev()``, it changes the current + track without changing the playback state. + v0.4.1 (2011-05-06) =================== @@ -217,7 +301,7 @@ loading from Mopidy 0.3.0 is still present. the debug log, to ease debugging of issues with attached debug logs. -v0.3.1 (2010-01-22) +v0.3.1 (2011-01-22) =================== A couple of fixes to the 0.3.0 release is needed to get a smooth installation. @@ -231,7 +315,7 @@ A couple of fixes to the 0.3.0 release is needed to get a smooth installation. installed if the installation is executed as the root user. -v0.3.0 (2010-01-22) +v0.3.0 (2011-01-22) =================== Mopidy 0.3.0 brings a bunch of small changes all over the place, but no large diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index f5066210..4c789eba 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -20,9 +20,8 @@ A command line client. Version 0.14 had some issues with Mopidy (see ncmpc ----- -A console client. Uses the ``idle`` command heavily, which Mopidy doesn't -support yet (see :issue:`32`). If you want a console client, use ncmpcpp -instead. +A console client. Works with Mopidy 0.6 and upwards. Uses the ``idle`` MPD +command, but in a resource inefficient way. ncmpcpp @@ -48,15 +47,15 @@ from `Launchpad `_. Communication mode ^^^^^^^^^^^^^^^^^^ -In newer versions of ncmpcpp, like 0.5.5 shipped with Ubuntu 11.04, ncmcpp -defaults to "notifications" mode for MPD communications, which Mopidy currently -does not support. To workaround this limitation in Mopidy, edit the ncmpcpp -configuration file at ``~/.ncmpcpp/config`` and add the following setting:: +In newer versions of ncmpcpp, like ncmpcpp 0.5.5 shipped with Ubuntu 11.04, +ncmcpp defaults to "notifications" mode for MPD communications, which Mopidy +did not support before Mopidy 0.6. To workaround this limitation in earlier +versions of Mopidy, edit the ncmpcpp configuration file at +``~/.ncmpcpp/config`` and add the following setting:: mpd_communication_mode = "polling" -You can track the development of "notifications" mode support in Mopidy in -:issue:`32`. +If you use Mopidy 0.6 or newer, you don't need to change anything. Graphical clients diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 5101cc84..198ac9e8 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -25,7 +25,7 @@ Otherwise, make sure you got the required dependencies installed. - Python >= 2.6, < 3 -- `Pykka `_ >= 0.12 +- `Pykka `_ >= 0.12.3 - GStreamer >= 0.10, with Python bindings. See :doc:`gstreamer`. diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/frontends/mpd.rst index 6120c2a6..b0c7e3c5 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/frontends/mpd.rst @@ -5,31 +5,8 @@ .. inheritance-diagram:: mopidy.frontends.mpd .. automodule:: mopidy.frontends.mpd - :synopsis: MPD frontend + :synopsis: MPD server frontend :members: - :undoc-members: - - -MPD server -========== - -.. inheritance-diagram:: mopidy.frontends.mpd.server - -.. automodule:: mopidy.frontends.mpd.server - :synopsis: MPD server - :members: - :undoc-members: - - -MPD session -=========== - -.. inheritance-diagram:: mopidy.frontends.mpd.session - -.. automodule:: mopidy.frontends.mpd.session - :synopsis: MPD client session - :members: - :undoc-members: MPD dispatcher @@ -40,7 +17,6 @@ MPD dispatcher .. automodule:: mopidy.frontends.mpd.dispatcher :synopsis: MPD request dispatcher :members: - :undoc-members: MPD protocol diff --git a/docs/modules/frontends/mpris.rst b/docs/modules/frontends/mpris.rst new file mode 100644 index 00000000..05a6e287 --- /dev/null +++ b/docs/modules/frontends/mpris.rst @@ -0,0 +1,7 @@ +*********************************************** +:mod:`mopidy.frontends.mpris` -- MPRIS frontend +*********************************************** + +.. automodule:: mopidy.frontends.mpris + :synopsis: MPRIS frontend + :members: diff --git a/docs/settings.rst b/docs/settings.rst index f0888670..76eb6315 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -92,7 +92,6 @@ To make a ``tag_cache`` of your local music available for Mopidy: .. _use_mpd_on_a_network: - Connecting from other machines on the network ============================================= @@ -120,6 +119,33 @@ file:: LASTFM_PASSWORD = u'mysecret' +.. _install_desktop_file: + +Controlling Mopidy through the Ubuntu Sound Menu +================================================ + +If you are running Ubuntu and installed Mopidy using the Debian package from +APT you should be able to control Mopidy through the `Ubuntu Sound Menu +`_ without any changes. + +If you installed Mopidy in any other way and want to control Mopidy through the +Ubuntu Sound Menu, you must install the ``mopidy.desktop`` file which can be +found in the ``data/`` dir of the Mopidy source into the +``/usr/share/applications`` dir by hand:: + + cd /path/to/mopidy/source + sudo cp data/mopidy.desktop /usr/share/applications/ + +After you have installed the file, start Mopidy in any way, and Mopidy should +appear in the Ubuntu Sound Menu. When you quit Mopidy, it will still be listed +in the Ubuntu Sound Menu, and may be restarted by selecting it there. + +The Ubuntu Sound Menu interacts with Mopidy's MPRIS frontend, +:mod:`mopidy.frontends.mpris`. The MPRIS frontend supports the minimum +requirements of the `MPRIS specification `_. The +``TrackList`` and the ``Playlists`` interfaces of the spec are not supported. + + Streaming audio through a SHOUTcast/Icecast server ================================================== @@ -151,4 +177,3 @@ Available settings .. automodule:: mopidy.settings :synopsis: Available settings and their default values :members: - :undoc-members: diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 79a0aa29..1d820fd0 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -3,9 +3,17 @@ import sys if not (2, 6) <= sys.version_info < (3,): sys.exit(u'Mopidy requires Python >= 2.6, < 3') +import glib +import os + from subprocess import PIPE, Popen -VERSION = (0, 5, 0) +VERSION = (0, 6, 0) + +DATA_PATH = os.path.join(glib.get_user_data_dir(), 'mopidy') +CACHE_PATH = os.path.join(glib.get_user_cache_dir(), 'mopidy') +SETTINGS_PATH = os.path.join(glib.get_user_config_dir(), 'mopidy') +SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') def get_version(): try: diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 038e2d7b..76c7f078 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -25,5 +25,5 @@ class Backend(object): #: :class:`mopidy.backends.base.StoredPlaylistsController`. stored_playlists = None - #: List of URI prefixes this backend can handle. - uri_handlers = [] + #: List of URI schemes this backend can handle. + uri_schemes = [] diff --git a/mopidy/backends/base/current_playlist.py b/mopidy/backends/base/current_playlist.py index 2633f166..17125ac0 100644 --- a/mopidy/backends/base/current_playlist.py +++ b/mopidy/backends/base/current_playlist.py @@ -2,6 +2,7 @@ from copy import copy import logging import random +from mopidy.listeners import BackendListener from mopidy.models import CpTrack logger = logging.getLogger('mopidy.backends.base') @@ -16,13 +17,10 @@ class CurrentPlaylistController(object): def __init__(self, backend): self.backend = backend + self.cp_id = 0 self._cp_tracks = [] self._version = 0 - def destroy(self): - """Cleanup after component.""" - pass - @property def cp_tracks(self): """ @@ -53,8 +51,9 @@ class CurrentPlaylistController(object): def version(self, version): self._version = version self.backend.playback.on_current_playlist_change() + self._trigger_playlist_changed() - def add(self, track, at_position=None): + def add(self, track, at_position=None, increase_version=True): """ Add the track to the end of, or at the given position in the current playlist. @@ -68,12 +67,14 @@ class CurrentPlaylistController(object): """ assert at_position <= len(self._cp_tracks), \ u'at_position can not be greater than playlist length' - cp_track = CpTrack(self.version, track) + cp_track = CpTrack(self.cp_id, track) if at_position is not None: self._cp_tracks.insert(at_position, cp_track) else: self._cp_tracks.append(cp_track) - self.version += 1 + if increase_version: + self.version += 1 + self.cp_id += 1 return cp_track def append(self, tracks): @@ -84,7 +85,10 @@ class CurrentPlaylistController(object): :type tracks: list of :class:`mopidy.models.Track` """ for track in tracks: - self.add(track) + self.add(track, increase_version=False) + + if tracks: + self.version += 1 def clear(self): """Clear the current playlist.""" @@ -199,3 +203,7 @@ class CurrentPlaylistController(object): random.shuffle(shuffled) self._cp_tracks = before + shuffled + after self.version += 1 + + def _trigger_playlist_changed(self): + logger.debug(u'Triggering playlist changed event') + BackendListener.send('playlist_changed') diff --git a/mopidy/backends/base/library.py b/mopidy/backends/base/library.py index a30ed412..9e3afe9a 100644 --- a/mopidy/backends/base/library.py +++ b/mopidy/backends/base/library.py @@ -16,10 +16,6 @@ class LibraryController(object): self.backend = backend self.provider = provider - def destroy(self): - """Cleanup after component.""" - self.provider.destroy() - def find_exact(self, **query): """ Search the library for tracks where ``field`` is ``values``. @@ -89,14 +85,6 @@ class BaseLibraryProvider(object): def __init__(self, backend): self.backend = backend - def destroy(self): - """ - Cleanup after component. - - *MAY be implemented by subclasses.* - """ - pass - def find_exact(self, **query): """ See :meth:`mopidy.backends.base.LibraryController.find_exact`. diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 530c4840..51fe0d3b 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -4,10 +4,21 @@ import time from pykka.registry import ActorRegistry -from mopidy.frontends.base import BaseFrontend +from mopidy.listeners import BackendListener logger = logging.getLogger('mopidy.backends.base') + +def option_wrapper(name, default): + def get_option(self): + return getattr(self, name, default) + def set_option(self, value): + if getattr(self, name, default) != value: + self._trigger_options_changed() + return setattr(self, name, value) + return property(get_option, set_option) + + class PlaybackController(object): """ :param backend: the backend @@ -34,7 +45,7 @@ class PlaybackController(object): #: Tracks are removed from the playlist when they have been played. #: :class:`False` #: Tracks are not removed from the playlist. - consume = False + consume = option_wrapper('_consume', False) #: The currently playing or selected track. #: @@ -46,21 +57,21 @@ class PlaybackController(object): #: Tracks are selected at random from the playlist. #: :class:`False` #: Tracks are played in the order of the playlist. - random = False + random = option_wrapper('_random', False) #: :class:`True` #: The current playlist is played repeatedly. To repeat a single track, #: select both :attr:`repeat` and :attr:`single`. #: :class:`False` #: The current playlist is played once. - repeat = False + repeat = option_wrapper('_repeat', False) #: :class:`True` #: Playback is stopped after current song, unless in :attr:`repeat` #: mode. #: :class:`False` #: Playback continues after current song. - single = False + single = option_wrapper('_single', False) def __init__(self, backend, provider): self.backend = backend @@ -71,12 +82,6 @@ class PlaybackController(object): self.play_time_accumulated = 0 self.play_time_started = None - def destroy(self): - """ - Cleanup after component. - """ - self.provider.destroy() - def _get_cpid(self, cp_track): if cp_track is None: return None @@ -276,6 +281,9 @@ class PlaybackController(object): def state(self, new_state): (old_state, self._state) = (self.state, new_state) logger.debug(u'Changing state: %s -> %s', old_state, new_state) + + self._trigger_playback_state_changed() + # FIXME play_time stuff assumes backend does not have a better way of # handeling this stuff :/ if (old_state in (self.PLAYING, self.STOPPED) @@ -313,6 +321,26 @@ class PlaybackController(object): def _current_wall_time(self): return int(time.time() * 1000) + def change_track(self, cp_track, on_error_step=1): + """ + Change to the given track, keeping the current playback state. + + :param cp_track: track to change to + :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 + + """ + old_state = self.state + self.stop() + self.current_cp_track = cp_track + if old_state == self.PLAYING: + self.play(on_error_step=on_error_step) + elif old_state == self.PAUSED: + self.pause() + def on_end_of_track(self): """ Tell the playback controller that end of track is reached. @@ -326,7 +354,7 @@ class PlaybackController(object): original_cp_track = self.current_cp_track if self.cp_track_at_eot: - self._trigger_stopped_playing_event() + self._trigger_track_playback_ended() self.play(self.cp_track_at_eot) else: self.stop(clear_current_track=True) @@ -349,20 +377,23 @@ class PlaybackController(object): self.stop(clear_current_track=True) def next(self): - """Play the next track.""" - if self.state == self.STOPPED: - return + """ + Change to the next track. + The current playback state will be kept. If it was playing, playing + will continue. If it was paused, it will still be paused, etc. + """ if self.cp_track_at_next: - self._trigger_stopped_playing_event() - self.play(self.cp_track_at_next) + self._trigger_track_playback_ended() + self.change_track(self.cp_track_at_next) else: self.stop(clear_current_track=True) def pause(self): """Pause playback.""" - if self.state == self.PLAYING and self.provider.pause(): + if self.provider.pause(): self.state = self.PAUSED + self._trigger_track_playback_paused() def play(self, cp_track=None, on_error_step=1): """ @@ -379,15 +410,17 @@ class PlaybackController(object): if cp_track is not None: assert cp_track in self.backend.current_playlist.cp_tracks - - if cp_track is None and self.current_cp_track is None: - cp_track = self.cp_track_at_next - - if cp_track is None and self.state == self.PAUSED: - self.resume() + elif cp_track is None: + if self.state == self.PAUSED: + return self.resume() + elif self.current_cp_track is not None: + cp_track = self.current_cp_track + elif self.current_cp_track is None and on_error_step == 1: + cp_track = self.cp_track_at_next + elif self.current_cp_track is None and on_error_step == -1: + cp_track = self.cp_track_at_previous if cp_track is not None: - self.state = self.STOPPED self.current_cp_track = cp_track self.state = self.PLAYING if not self.provider.play(cp_track.track): @@ -402,21 +435,23 @@ class PlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) - self._trigger_started_playing_event() + self._trigger_track_playback_started() def previous(self): - """Play the previous track.""" - if self.cp_track_at_previous is None: - return - if self.state == self.STOPPED: - return - self._trigger_stopped_playing_event() - self.play(self.cp_track_at_previous, on_error_step=-1) + """ + Change to the previous track. + + The current playback state will be kept. If it was playing, playing + will continue. If it was paused, it will still be paused, etc. + """ + self._trigger_track_playback_ended() + self.change_track(self.cp_track_at_previous, on_error_step=-1) def resume(self): """If paused, resume playing the current track.""" if self.state == self.PAUSED and self.provider.resume(): self.state = self.PLAYING + self._trigger_track_playback_resumed() def seek(self, time_position): """ @@ -443,7 +478,10 @@ class PlaybackController(object): self.play_time_started = self._current_wall_time self.play_time_accumulated = time_position - return self.provider.seek(time_position) + success = self.provider.seek(time_position) + if success: + self._trigger_seeked() + return success def stop(self, clear_current_track=False): """ @@ -454,45 +492,54 @@ class PlaybackController(object): :type clear_current_track: boolean """ if self.state != self.STOPPED: - self._trigger_stopped_playing_event() if self.provider.stop(): + self._trigger_track_playback_ended() self.state = self.STOPPED if clear_current_track: self.current_cp_track = None - def _trigger_started_playing_event(self): - """ - Notifies frontends that a track has started playing. - - For internal use only. Should be called by the backend directly after a - track has started playing. - """ + def _trigger_track_playback_paused(self): + logger.debug(u'Triggering track playback paused event') if self.current_track is None: return - frontend_refs = ActorRegistry.get_by_class(BaseFrontend) - for frontend_ref in frontend_refs: - frontend_ref.send_one_way({ - 'command': 'started_playing', - 'track': self.current_track, - }) + BackendListener.send('track_playback_paused', + track=self.current_track, + time_position=self.time_position) - def _trigger_stopped_playing_event(self): - """ - Notifies frontends that a track has stopped playing. - - For internal use only. Should be called by the backend before a track - is stopped playing, e.g. at the next, previous, and stop actions and at - end-of-track. - """ + def _trigger_track_playback_resumed(self): + logger.debug(u'Triggering track playback resumed event') if self.current_track is None: return - frontend_refs = ActorRegistry.get_by_class(BaseFrontend) - for frontend_ref in frontend_refs: - frontend_ref.send_one_way({ - 'command': 'stopped_playing', - 'track': self.current_track, - 'stop_position': self.time_position, - }) + BackendListener.send('track_playback_resumed', + track=self.current_track, + time_position=self.time_position) + + def _trigger_track_playback_started(self): + logger.debug(u'Triggering track playback started event') + if self.current_track is None: + return + BackendListener.send('track_playback_started', + track=self.current_track) + + def _trigger_track_playback_ended(self): + logger.debug(u'Triggering track playback ended event') + if self.current_track is None: + return + BackendListener.send('track_playback_ended', + track=self.current_track, + time_position=self.time_position) + + def _trigger_playback_state_changed(self): + logger.debug(u'Triggering playback state change event') + BackendListener.send('playback_state_changed') + + def _trigger_options_changed(self): + logger.debug(u'Triggering options changed event') + BackendListener.send('options_changed') + + def _trigger_seeked(self): + logger.debug(u'Triggering seeked event') + BackendListener.send('seeked') class BasePlaybackProvider(object): @@ -506,14 +553,6 @@ class BasePlaybackProvider(object): def __init__(self, backend): self.backend = backend - def destroy(self): - """ - Cleanup after component. - - *MAY be implemented by subclasses.* - """ - pass - def pause(self): """ Pause playback. diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py index aca78a8c..0ce2e196 100644 --- a/mopidy/backends/base/stored_playlists.py +++ b/mopidy/backends/base/stored_playlists.py @@ -17,10 +17,6 @@ class StoredPlaylistsController(object): self.backend = backend self.provider = provider - def destroy(self): - """Cleanup after component.""" - self.provider.destroy() - @property def playlists(self): """ @@ -133,14 +129,6 @@ class BaseStoredPlaylistsProvider(object): self.backend = backend self._playlists = [] - def destroy(self): - """ - Cleanup after component. - - *MAY be implemented by subclass.* - """ - pass - @property def playlists(self): """ @@ -201,4 +189,3 @@ class BaseStoredPlaylistsProvider(object): *MUST be implemented by subclass.* """ raise NotImplementedError - diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 90c87dac..70efb028 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -32,7 +32,7 @@ class DummyBackend(ThreadingActor, Backend): self.stored_playlists = StoredPlaylistsController(backend=self, provider=stored_playlists_provider) - self.uri_handlers = [u'dummy:'] + self.uri_schemes = [u'dummy'] class DummyLibraryProvider(BaseLibraryProvider): diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 93cf3534..e1d11bcb 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,4 +1,5 @@ import glob +import glib import logging import os import shutil @@ -6,7 +7,7 @@ import shutil from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings +from mopidy import settings, DATA_PATH from mopidy.backends.base import (Backend, CurrentPlaylistController, LibraryController, BaseLibraryProvider, PlaybackController, BasePlaybackProvider, StoredPlaylistsController, @@ -18,6 +19,14 @@ from .translator import parse_m3u, parse_mpd_tag_cache logger = logging.getLogger(u'mopidy.backends.local') +DEFAULT_PLAYLIST_PATH = os.path.join(DATA_PATH, 'playlists') +DEFAULT_TAG_CACHE_FILE = os.path.join(DATA_PATH, 'tag_cache') +DEFAULT_MUSIC_PATH = glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC) + +if not DEFAULT_MUSIC_PATH or DEFAULT_MUSIC_PATH == os.path.expanduser(u'~'): + DEFAULT_MUSIC_PATH = os.path.expanduser(u'~/music') + + class LocalBackend(ThreadingActor, Backend): """ A backend for playing music from a local music archive. @@ -52,13 +61,14 @@ class LocalBackend(ThreadingActor, Backend): self.stored_playlists = StoredPlaylistsController(backend=self, provider=stored_playlists_provider) - self.uri_handlers = [u'file://'] + self.uri_schemes = [u'file'] self.gstreamer = None def on_start(self): gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.' + assert len(gstreamer_refs) == 1, \ + 'Expected exactly one running GStreamer.' self.gstreamer = gstreamer_refs[0].proxy() @@ -96,7 +106,7 @@ class LocalPlaybackProvider(BasePlaybackProvider): class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) - self._folder = settings.LOCAL_PLAYLIST_PATH + self._folder = settings.LOCAL_PLAYLIST_PATH or DEFAULT_PLAYLIST_PATH self.refresh() def lookup(self, uri): @@ -173,8 +183,8 @@ class LocalLibraryProvider(BaseLibraryProvider): self.refresh() def refresh(self, uri=None): - tag_cache = settings.LOCAL_TAG_CACHE_FILE - music_folder = settings.LOCAL_MUSIC_PATH + tag_cache = settings.LOCAL_TAG_CACHE_FILE or DEFAULT_TAG_CACHE_FILE + music_folder = settings.LOCAL_MUSIC_PATH or DEFAULT_MUSIC_PATH tracks = parse_mpd_tag_cache(tag_cache, music_folder) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 66bcffd4..a50f1724 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -67,7 +67,7 @@ class SpotifyBackend(ThreadingActor, Backend): self.stored_playlists = StoredPlaylistsController(backend=self, provider=stored_playlists_provider) - self.uri_handlers = [u'spotify:', u'http://open.spotify.com/'] + self.uri_schemes = [u'spotify'] self.gstreamer = None self.spotify = None @@ -78,12 +78,16 @@ class SpotifyBackend(ThreadingActor, Backend): def on_start(self): gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.' + assert len(gstreamer_refs) == 1, \ + 'Expected exactly one running GStreamer.' self.gstreamer = gstreamer_refs[0].proxy() logger.info(u'Mopidy uses SPOTIFY(R) CORE') self.spotify = self._connect() + def on_stop(self): + self.spotify.logout() + def _connect(self): from .session_manager import SpotifySessionManager diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 40d4a099..59aa9a2c 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -55,7 +55,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider): spotify_query = u' '.join(spotify_query) logger.debug(u'Spotify search query: %s' % spotify_query) queue = Queue.Queue() - self.backend.spotify.search(spotify_query.encode(ENCODING), queue) + self.backend.spotify.search(spotify_query, queue) try: return queue.get(timeout=3) # XXX What is an reasonable timeout? except Queue.Empty: diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index fd71d861..5261f0cf 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -1,3 +1,4 @@ +import glib import logging import os import threading @@ -6,7 +7,7 @@ from spotify.manager import SpotifySessionManager as PyspotifySessionManager from pykka.registry import ActorRegistry -from mopidy import get_version, settings +from mopidy import get_version, settings, CACHE_PATH from mopidy.backends.base import Backend from mopidy.backends.spotify import BITRATES from mopidy.backends.spotify.container_manager import SpotifyContainerManager @@ -21,9 +22,10 @@ logger = logging.getLogger('mopidy.backends.spotify.session_manager') # pylint: disable = R0901 # SpotifySessionManager: Too many ancestors (9/7) + class SpotifySessionManager(BaseThread, PyspotifySessionManager): - cache_location = settings.SPOTIFY_CACHE_PATH - settings_location = settings.SPOTIFY_CACHE_PATH + cache_location = settings.SPOTIFY_CACHE_PATH or CACHE_PATH + settings_location = settings.SPOTIFY_CACHE_PATH or CACHE_PATH appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') user_agent = 'Mopidy %s' % get_version() @@ -118,6 +120,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): 'channels': channels, } self.gstreamer.emit_data(capabilites, bytes(frames)) + return num_frames def play_token_lost(self, session): """Callback used by pyspotify""" @@ -148,9 +151,17 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): """Search method used by Mopidy backend""" def callback(results, userdata=None): # TODO Include results from results.albums(), etc. too + # TODO Consider launching a second search if results.total_tracks() + # is larger than len(results.tracks()) playlist = Playlist(tracks=[ SpotifyTranslator.to_mopidy_track(t) for t in results.tracks()]) queue.put(playlist) self.connected.wait() - self.session.search(query, callback) + self.session.search(query, callback, track_count=100, + album_count=0, artist_count=0) + + def logout(self): + """Log out from spotify""" + logger.debug(u'Logging out from spotify') + self.session.logout() diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 1bf7e5aa..95287d77 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -4,7 +4,7 @@ import logging from spotify import Link, SpotifyError from mopidy import settings -from mopidy.backends.spotify import ENCODING, BITRATES +from mopidy.backends.spotify import ENCODING from mopidy.models import Artist, Album, Track, Playlist logger = logging.getLogger('mopidy.backends.spotify.translator') @@ -44,7 +44,7 @@ class SpotifyTranslator(object): track_no=spotify_track.index(), date=date, length=spotify_track.duration(), - bitrate=BITRATES[settings.SPOTIFY_BITRATE], + bitrate=settings.SPOTIFY_BITRATE, ) @classmethod diff --git a/mopidy/core.py b/mopidy/core.py index 65472a29..08c5e0d7 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -1,8 +1,11 @@ import logging import optparse +import os import signal import sys -import time + +import gobject +gobject.threads_init() # Extract any non-GStreamer arguments, and leave the GStreamer arguments for # processing by GStreamer. This needs to be done before GStreamer is imported, @@ -18,30 +21,30 @@ sys.argv[1:] = gstreamer_args from pykka.registry import ActorRegistry from mopidy import (get_version, settings, OptionalDependencyError, - SettingsError) + SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) from mopidy.gstreamer import GStreamer from mopidy.utils import get_class from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file -from mopidy.utils.process import (GObjectEventThread, exit_handler, - stop_all_actors) +from mopidy.utils.process import (exit_handler, stop_remaining_actors, + stop_actors_by_class) from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.core') def main(): signal.signal(signal.SIGTERM, exit_handler) + loop = gobject.MainLoop() try: options = parse_options() setup_logging(options.verbosity_level, options.save_debug_log) + check_old_folders() setup_settings(options.interactive) - setup_gobject_loop() setup_gstreamer() setup_mixer() setup_backend() setup_frontends() - while True: - time.sleep(1) + loop.run() except SettingsError as e: logger.error(e.message) except KeyboardInterrupt: @@ -49,7 +52,12 @@ def main(): except Exception as e: logger.exception(e) finally: - stop_all_actors() + loop.quit() + stop_frontends() + stop_backend() + stop_mixer() + stop_gstreamer() + stop_remaining_actors() def parse_options(): parser = optparse.OptionParser(version=u'Mopidy %s' % get_version()) @@ -58,12 +66,12 @@ def parse_options(): help='show GStreamer help options') parser.add_option('-i', '--interactive', action='store_true', dest='interactive', - help='ask interactively for required settings which is missing') + help='ask interactively for required settings which are missing') parser.add_option('-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') parser.add_option('-v', '--verbose', - action='store_const', const=2, dest='verbosity_level', + action='count', default=1, dest='verbosity_level', help='more output (debug level)') parser.add_option('--save-debug-log', action='store_true', dest='save_debug_log', @@ -73,30 +81,54 @@ def parse_options(): help='list current settings') return parser.parse_args(args=mopidy_args)[0] +def check_old_folders(): + old_settings_folder = os.path.expanduser(u'~/.mopidy') + + if not os.path.isdir(old_settings_folder): + return + + logger.warning(u'Old settings folder found at %s, settings.py should be ' + 'moved to %s, any cache data should be deleted. See release notes ' + 'for further instructions.', old_settings_folder, SETTINGS_PATH) + def setup_settings(interactive): - get_or_create_folder('~/.mopidy/') - get_or_create_file('~/.mopidy/settings.py') + get_or_create_folder(SETTINGS_PATH) + get_or_create_folder(DATA_PATH) + get_or_create_file(SETTINGS_FILE) try: settings.validate(interactive) except SettingsError, e: logger.error(e.message) sys.exit(1) -def setup_gobject_loop(): - GObjectEventThread().start() - def setup_gstreamer(): GStreamer.start() +def stop_gstreamer(): + stop_actors_by_class(GStreamer) + def setup_mixer(): get_class(settings.MIXER).start() +def stop_mixer(): + stop_actors_by_class(get_class(settings.MIXER)) + def setup_backend(): get_class(settings.BACKENDS[0]).start() +def stop_backend(): + stop_actors_by_class(get_class(settings.BACKENDS[0])) + def setup_frontends(): for frontend_class_name in settings.FRONTENDS: try: get_class(frontend_class_name).start() except OptionalDependencyError as e: logger.info(u'Disabled: %s (%s)', frontend_class_name, e) + +def stop_frontends(): + for frontend_class_name in settings.FRONTENDS: + try: + stop_actors_by_class(get_class(frontend_class_name)) + except OptionalDependencyError: + pass diff --git a/mopidy/frontends/base.py b/mopidy/frontends/base.py deleted file mode 100644 index 811644b1..00000000 --- a/mopidy/frontends/base.py +++ /dev/null @@ -1,5 +0,0 @@ -class BaseFrontend(object): - """ - Base class for frontends. - """ - pass diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 04716c61..125457cd 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -10,14 +10,14 @@ except ImportError as import_error: from pykka.actor import ThreadingActor from mopidy import settings, SettingsError -from mopidy.frontends.base import BaseFrontend +from mopidy.listeners import BackendListener logger = logging.getLogger('mopidy.frontends.lastfm') API_KEY = '2236babefa8ebb3d93ea467560d00d04' API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' -class LastfmFrontend(ThreadingActor, BaseFrontend): +class LastfmFrontend(ThreadingActor, BackendListener): """ Frontend which scrobbles the music you play to your `Last.fm `_ profile. @@ -57,15 +57,7 @@ class LastfmFrontend(ThreadingActor, BaseFrontend): logger.error(u'Error during Last.fm setup: %s', e) self.stop() - def on_receive(self, message): - if message.get('command') == 'started_playing': - self.started_playing(message['track']) - elif message.get('command') == 'stopped_playing': - self.stopped_playing(message['track'], message['stop_position']) - else: - pass # Ignore any other messages - - def started_playing(self, track): + def track_playback_started(self, track): artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 self.last_start_time = int(time.time()) @@ -82,14 +74,14 @@ class LastfmFrontend(ThreadingActor, BaseFrontend): pylast.MalformedResponseError, pylast.WSError) as e: logger.warning(u'Error submitting playing track to Last.fm: %s', e) - def stopped_playing(self, track, stop_position): + def track_playback_ended(self, track, time_position): artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 - stop_position = stop_position // 1000 + time_position = time_position // 1000 if duration < 30: logger.debug(u'Track too short to scrobble. (30s)') return - if stop_position < duration // 2 and stop_position < 240: + if time_position < duration // 2 and time_position < 240: logger.debug( u'Track not played long enough to scrobble. (50% or 240s)') return diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 175aa0ee..b6adc09d 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,15 +1,15 @@ -import asyncore import logging +import sys -from pykka.actor import ThreadingActor +from pykka import registry, actor -from mopidy.frontends.base import BaseFrontend -from mopidy.frontends.mpd.server import MpdServer -from mopidy.utils.process import BaseThread +from mopidy import listeners, settings +from mopidy.frontends.mpd import dispatcher, protocol +from mopidy.utils import network, process, log logger = logging.getLogger('mopidy.frontends.mpd') -class MpdFrontend(ThreadingActor, BaseFrontend): +class MpdFrontend(actor.ThreadingActor, listeners.BackendListener): """ The MPD frontend. @@ -25,23 +25,85 @@ class MpdFrontend(ThreadingActor, BaseFrontend): """ def __init__(self): - self._thread = None + hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) + port = settings.MPD_SERVER_PORT + + try: + network.Server(hostname, port, protocol=MpdSession, + max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) + except IOError, e: + logger.error(u'MPD server startup failed: %s', e) + sys.exit(1) + + logger.info(u'MPD server running at [%s]:%s', hostname, port) + + def on_stop(self): + process.stop_actors_by_class(MpdSession) + + def send_idle(self, subsystem): + # FIXME this should be updated once pykka supports non-blocking calls + # on proxies or some similar solution + registry.ActorRegistry.broadcast({ + 'command': 'pykka_call', + 'attr_path': ('on_idle',), + 'args': [subsystem], + 'kwargs': {}, + }, target_class=MpdSession) + + def playback_state_changed(self): + self.send_idle('player') + + def playlist_changed(self): + self.send_idle('playlist') + + def options_changed(self): + self.send_idle('options') + + def volume_changed(self): + self.send_idle('mixer') + + +class MpdSession(network.LineProtocol): + """ + The MPD client session. Keeps track of a single client session. Any + requests from the client is passed on to the MPD request dispatcher. + """ + + terminator = protocol.LINE_TERMINATOR + encoding = protocol.ENCODING + delimeter = r'\r?\n' + + def __init__(self, connection): + super(MpdSession, self).__init__(connection) + self.dispatcher = dispatcher.MpdDispatcher(self) def on_start(self): - self._thread = MpdThread() - self._thread.start() + logger.info(u'New MPD connection from [%s]:%s', self.host, self.port) + self.send_lines([u'OK MPD %s' % protocol.VERSION]) - def on_receive(self, message): - pass # Ignore any messages + def on_line_received(self, line): + logger.debug(u'Request from [%s]:%s to %s: %s', self.host, self.port, + self.actor_urn, line) + response = self.dispatcher.handle_request(line) + if not response: + return -class MpdThread(BaseThread): - def __init__(self): - super(MpdThread, self).__init__() - self.name = u'MpdThread' + logger.debug(u'Response to [%s]:%s from %s: %s', self.host, self.port, + self.actor_urn, log.indent(self.terminator.join(response))) - def run_inside_try(self): - logger.debug(u'Starting MPD server thread') - server = MpdServer() - server.start() - asyncore.loop() + self.send_lines(response) + + def on_idle(self, subsystem): + self.dispatcher.handle_idle(subsystem) + + def decode(self, line): + try: + return super(MpdSession, self).decode(line.decode('string_escape')) + except ValueError: + logger.warning(u'Stopping actor due to unescaping error, data ' + 'supplied by client was not valid.') + self.stop() + + def close(self): + self.stop() diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 91cdc5e7..5ee70a5b 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -27,6 +27,8 @@ class MpdDispatcher(object): back to the MPD session. """ + _noidle = re.compile(r'^noidle$') + def __init__(self, session=None): self.authenticated = False self.command_list = False @@ -42,11 +44,28 @@ class MpdDispatcher(object): self._catch_mpd_ack_errors_filter, self._authenticate_filter, self._command_list_filter, + self._idle_filter, self._add_ok_filter, self._call_handler_filter, ] return self._call_next_filter(request, response, filter_chain) + def handle_idle(self, subsystem): + self.context.events.add(subsystem) + + subsystems = self.context.subscriptions.intersection( + self.context.events) + if not subsystems: + return + + response = [] + for subsystem in subsystems: + response.append(u'changed: %s' % subsystem) + response.append(u'OK') + self.context.subscriptions = set() + self.context.events = set() + self.context.session.send_lines(response) + def _call_next_filter(self, request, response, filter_chain): if filter_chain: next_filter = filter_chain.pop(0) @@ -71,7 +90,7 @@ class MpdDispatcher(object): def _authenticate_filter(self, request, response, filter_chain): if self.authenticated: return self._call_next_filter(request, response, filter_chain) - elif settings.MPD_SERVER_PASSWORD is None: + elif settings.MPD_SERVER_PASSWORD is None: self.authenticated = True return self._call_next_filter(request, response, filter_chain) else: @@ -108,6 +127,29 @@ class MpdDispatcher(object): and request != u'command_list_end') + ### Filter: idle + + def _idle_filter(self, request, response, filter_chain): + if self._is_currently_idle() and not self._noidle.match(request): + logger.debug(u'Client sent us %s, only %s is allowed while in ' + 'the idle state', repr(request), repr(u'noidle')) + self.context.session.close() + return [] + + if not self._is_currently_idle() and self._noidle.match(request): + return [] # noidle was called before idle + + response = self._call_next_filter(request, response, filter_chain) + + if self._is_currently_idle(): + return [] + else: + return response + + def _is_currently_idle(self): + return bool(self.context.subscriptions) + + ### Filter: add OK def _add_ok_filter(self, request, response, filter_chain): @@ -128,7 +170,7 @@ class MpdDispatcher(object): return self._call_next_filter(request, response, filter_chain) except ActorDeadError as e: logger.warning(u'Tried to communicate with dead actor.') - raise exceptions.MpdSystemError(e.message) + raise exceptions.MpdSystemError(e) def _call_handler(self, request): (handler, kwargs) = self._find_handler(request) @@ -178,12 +220,20 @@ class MpdContext(object): #: The current :class:`MpdDispatcher`. dispatcher = None - #: The current :class:`mopidy.frontends.mpd.session.MpdSession`. + #: The current :class:`mopidy.frontends.mpd.MpdSession`. session = None + #: The active subsystems that have pending events. + events = None + + #: The subsytems that we want to be notified about in idle mode. + subscriptions = None + def __init__(self, dispatcher, session=None): self.dispatcher = dispatcher self.session = session + self.events = set() + self.subscriptions = set() self._backend = None self._mixer = None @@ -192,11 +242,10 @@ class MpdContext(object): """ The backend. An instance of :class:`mopidy.backends.base.Backend`. """ - if self._backend is not None: - return self._backend - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, 'Expected exactly one running backend.' - self._backend = backend_refs[0].proxy() + if self._backend is None: + backend_refs = ActorRegistry.get_by_class(Backend) + assert len(backend_refs) == 1, 'Expected exactly one running backend.' + self._backend = backend_refs[0].proxy() return self._backend @property @@ -204,9 +253,8 @@ class MpdContext(object): """ The mixer. An instance of :class:`mopidy.mixers.base.BaseMixer`. """ - if self._mixer is not None: - return self._mixer - mixer_refs = ActorRegistry.get_by_class(BaseMixer) - assert len(mixer_refs) == 1, 'Expected exactly one running mixer.' - self._mixer = mixer_refs[0].proxy() + if self._mixer is None: + mixer_refs = ActorRegistry.get_by_class(BaseMixer) + assert len(mixer_refs) == 1, 'Expected exactly one running mixer.' + self._mixer = mixer_refs[0].proxy() return self._mixer diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 8e26013d..c7136804 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -19,8 +19,8 @@ def add(context, uri): """ if not uri: return - for handler_prefix in context.backend.uri_handlers.get(): - if uri.startswith(handler_prefix): + for uri_scheme in context.backend.uri_schemes.get(): + if uri.startswith(uri_scheme): track = context.backend.library.lookup(uri).get() if track is not None: context.backend.current_playlist.add(track) diff --git a/mopidy/frontends/mpd/protocol/empty.py b/mopidy/frontends/mpd/protocol/empty.py index 0e418551..4cdafd87 100644 --- a/mopidy/frontends/mpd/protocol/empty.py +++ b/mopidy/frontends/mpd/protocol/empty.py @@ -1,6 +1,6 @@ from mopidy.frontends.mpd.protocol import handle_request -@handle_request(r'^$') +@handle_request(r'^[ ]*$') def empty(context): """The original MPD server returns ``OK`` on an empty request.""" pass diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index 920f48a5..df13b4b4 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -11,28 +11,16 @@ def commands(context): Shows which commands the current user has access to. """ if context.dispatcher.authenticated: - command_names = [command.name for command in mpd_commands] + command_names = set([command.name for command in mpd_commands]) else: - command_names = [command.name for command in mpd_commands - if not command.auth_required] + command_names = set([command.name for command in mpd_commands + if not command.auth_required]) - # No permission to use - if 'kill' in command_names: - command_names.remove('kill') - - # Not shown by MPD in its command list - if 'command_list_begin' in command_names: - command_names.remove('command_list_begin') - if 'command_list_ok_begin' in command_names: - command_names.remove('command_list_ok_begin') - if 'command_list_end' in command_names: - command_names.remove('command_list_end') - if 'idle' in command_names: - command_names.remove('idle') - if 'noidle' in command_names: - command_names.remove('noidle') - if 'sticker' in command_names: - command_names.remove('sticker') + # No one is permited to use kill, rest of commands are not listed by MPD, + # so we shouldn't either. + command_names = command_names - set(['kill', 'command_list_begin', + 'command_list_ok_begin', 'command_list_ok_begin', 'command_list_end', + 'idle', 'noidle', 'sticker']) return [('command', command_name) for command_name in sorted(command_names)] @@ -95,4 +83,5 @@ def urlhandlers(context): Gets a list of available URL handlers. """ - return [(u'handler', uri) for uri in context.backend.uri_handlers.get()] + return [(u'handler', uri_scheme) + for uri_scheme in context.backend.uri_schemes.get()] diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index abbb8d7f..20a66775 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -4,6 +4,10 @@ from mopidy.backends.base import PlaybackController from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented +#: Subsystems that can be registered with idle command. +SUBSYSTEMS = ['database', 'mixer', 'options', 'output', + 'player', 'playlist', 'stored_playlist', 'update', ] + @handle_request(r'^clearerror$') def clearerror(context): """ @@ -67,12 +71,36 @@ def idle(context, subsystems=None): notifications when something changed in one of the specified subsystems. """ - pass # TODO + + if subsystems: + subsystems = subsystems.split() + else: + subsystems = SUBSYSTEMS + + for subsystem in subsystems: + context.subscriptions.add(subsystem) + + active = context.subscriptions.intersection(context.events) + if not active: + context.session.prevent_timeout = True + return + + response = [] + context.events = set() + context.subscriptions = set() + + for subsystem in active: + response.append(u'changed: %s' % subsystem) + return response @handle_request(r'^noidle$') def noidle(context): """See :meth:`_status_idle`.""" - pass # TODO + if not context.subscriptions: + return + context.subscriptions = set() + context.events = set() + context.session.prevent_timeout = False @handle_request(r'^stats$') def stats(context): @@ -125,12 +153,17 @@ def status(context): - ``nextsongid``: playlist songid of the next song to be played - ``time``: total time elapsed (of current playing/paused song) - ``elapsed``: Total time elapsed within the current song, but with - higher resolution. + higher resolution. - ``bitrate``: instantaneous bitrate in kbps - ``xfade``: crossfade in seconds - ``audio``: sampleRate``:bits``:channels - ``updatings_db``: job id - ``error``: if there is an error, returns message here + + *Clarifications based on experience implementing* + - ``volume``: can also be -1 if no output is set. + - ``elapsed``: Higher resolution means time in seconds with three + decimal places for millisecond precision. """ futures = { 'current_playlist.tracks': context.backend.current_playlist.tracks, @@ -214,11 +247,11 @@ def _status_state(futures): return u'pause' def _status_time(futures): - return u'%s:%s' % (_status_time_elapsed(futures) // 1000, + return u'%d:%d' % (futures['playback.time_position'].get() // 1000, _status_time_total(futures) // 1000) def _status_time_elapsed(futures): - return futures['playback.time_position'].get() + return u'%.3f' % (futures['playback.time_position'].get() / 1000.0) def _status_time_total(futures): current_cp_track = futures['playback.current_cp_track'].get() diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py deleted file mode 100644 index 62e443fb..00000000 --- a/mopidy/frontends/mpd/server.py +++ /dev/null @@ -1,38 +0,0 @@ -import asyncore -import logging -import sys - -from mopidy import settings -from mopidy.utils import network -from .session import MpdSession - -logger = logging.getLogger('mopidy.frontends.mpd.server') - -class MpdServer(asyncore.dispatcher): - """ - The MPD server. Creates a :class:`mopidy.frontends.mpd.session.MpdSession` - for each client connection. - """ - - def start(self): - """Start MPD server.""" - try: - self.set_socket(network.create_socket()) - self.set_reuse_addr() - hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) - port = settings.MPD_SERVER_PORT - logger.debug(u'MPD server is binding to [%s]:%s', hostname, port) - self.bind((hostname, port)) - self.listen(1) - logger.info(u'MPD server running at [%s]:%s', hostname, port) - except IOError, e: - logger.error(u'MPD server startup failed: %s' % - str(e).decode('utf-8')) - sys.exit(1) - - def handle_accept(self): - """Called by asyncore when a new client connects.""" - (client_socket, client_socket_address) = self.accept() - logger.info(u'MPD client connection from [%s]:%s', - client_socket_address[0], client_socket_address[1]) - MpdSession(self, client_socket, client_socket_address) diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py deleted file mode 100644 index ce5d3be7..00000000 --- a/mopidy/frontends/mpd/session.py +++ /dev/null @@ -1,58 +0,0 @@ -import asynchat -import logging - -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.frontends.mpd.protocol import ENCODING, LINE_TERMINATOR, VERSION -from mopidy.utils.log import indent - -logger = logging.getLogger('mopidy.frontends.mpd.session') - -class MpdSession(asynchat.async_chat): - """ - The MPD client session. Keeps track of a single client session. Any - requests from the client is passed on to the MPD request dispatcher. - """ - - def __init__(self, server, client_socket, client_socket_address): - asynchat.async_chat.__init__(self, sock=client_socket) - self.server = server - self.client_address = client_socket_address[0] - self.client_port = client_socket_address[1] - self.input_buffer = [] - self.authenticated = False - self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) - self.dispatcher = MpdDispatcher(session=self) - self.send_response([u'OK MPD %s' % VERSION]) - - def collect_incoming_data(self, data): - """Called by asynchat when new data arrives.""" - self.input_buffer.append(data) - - def found_terminator(self): - """Called by asynchat when a terminator is found in incoming data.""" - data = ''.join(self.input_buffer).strip() - self.input_buffer = [] - try: - self.send_response(self.handle_request(data)) - except UnicodeDecodeError as e: - logger.warning(u'Received invalid data: %s', e) - - def handle_request(self, request): - """Handle the request using the MPD command handlers.""" - request = request.decode(ENCODING) - logger.debug(u'Request from [%s]:%s: %s', self.client_address, - self.client_port, indent(request)) - return self.dispatcher.handle_request(request) - - def send_response(self, response): - """ - Format a response from the MPD command handlers and send it to the - client. - """ - if response: - response = LINE_TERMINATOR.join(response) - logger.debug(u'Response to [%s]:%s: %s', self.client_address, - self.client_port, indent(response)) - response = u'%s%s' % (response, LINE_TERMINATOR) - data = response.encode(ENCODING) - self.push(data) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py new file mode 100644 index 00000000..579038ca --- /dev/null +++ b/mopidy/frontends/mpris/__init__.py @@ -0,0 +1,130 @@ +import logging + +logger = logging.getLogger('mopidy.frontends.mpris') + +try: + import indicate +except ImportError as import_error: + indicate = None + logger.debug(u'Startup notification will not be sent (%s)', import_error) + +from pykka.actor import ThreadingActor + +from mopidy import settings +from mopidy.frontends.mpris import objects +from mopidy.listeners import BackendListener + + +class MprisFrontend(ThreadingActor, BackendListener): + """ + Frontend which lets you control Mopidy through the Media Player Remote + Interfacing Specification (`MPRIS `_) D-Bus + interface. + + An example of an MPRIS client is the `Ubuntu Sound Menu + `_. + + **Dependencies:** + + - D-Bus Python bindings. The package is named ``python-dbus`` in + Ubuntu/Debian. + - ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the + Ubuntu Sound Menu. The package is named ``python-indicate`` in + Ubuntu/Debian. + - An ``.desktop`` file for Mopidy installed at the path set in + :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for + details. + + **Testing the frontend** + + To test, start Mopidy, and then run the following in a Python shell:: + + import dbus + bus = dbus.SessionBus() + player = bus.get_object('org.mpris.MediaPlayer2.mopidy', + '/org/mpris/MediaPlayer2') + + Now you can control Mopidy through the player object. Examples: + + - To get some properties from Mopidy, run:: + + props = player.GetAll('org.mpris.MediaPlayer2', + dbus_interface='org.freedesktop.DBus.Properties') + + - To quit Mopidy through D-Bus, run:: + + player.Quit(dbus_interface='org.mpris.MediaPlayer2') + """ + + def __init__(self): + self.indicate_server = None + self.mpris_object = None + + def on_start(self): + try: + self.mpris_object = objects.MprisObject() + self._send_startup_notification() + except Exception as e: + logger.error(u'MPRIS frontend setup failed (%s)', e) + self.stop() + + def on_stop(self): + logger.debug(u'Removing MPRIS object from D-Bus connection...') + if self.mpris_object: + self.mpris_object.remove_from_connection() + self.mpris_object = None + logger.debug(u'Removed MPRIS object from D-Bus connection') + + def _send_startup_notification(self): + """ + Send startup notification using libindicate to make Mopidy appear in + e.g. `Ubuntu's sound menu `_. + + A reference to the libindicate server is kept for as long as Mopidy is + running. When Mopidy exits, the server will be unreferenced and Mopidy + will automatically be unregistered from e.g. the sound menu. + """ + if not indicate: + return + logger.debug(u'Sending startup notification...') + self.indicate_server = indicate.Server() + self.indicate_server.set_type('music.mopidy') + self.indicate_server.set_desktop_file(settings.DESKTOP_FILE) + self.indicate_server.show() + logger.debug(u'Startup notification sent') + + def _emit_properties_changed(self, *changed_properties): + if self.mpris_object is None: + return + props_with_new_values = [ + (p, self.mpris_object.Get(objects.PLAYER_IFACE, p)) + for p in changed_properties] + self.mpris_object.PropertiesChanged(objects.PLAYER_IFACE, + dict(props_with_new_values), []) + + def track_playback_paused(self, track, time_position): + logger.debug(u'Received track playback paused event') + self._emit_properties_changed('PlaybackStatus') + + def track_playback_resumed(self, track, time_position): + logger.debug(u'Received track playback resumed event') + self._emit_properties_changed('PlaybackStatus') + + def track_playback_started(self, track): + logger.debug(u'Received track playback started event') + self._emit_properties_changed('PlaybackStatus', 'Metadata') + + def track_playback_ended(self, track, time_position): + logger.debug(u'Received track playback ended event') + self._emit_properties_changed('PlaybackStatus', 'Metadata') + + def volume_changed(self): + logger.debug(u'Received volume changed event') + self._emit_properties_changed('Volume') + + def seeked(self): + logger.debug(u'Received seeked event') + if self.mpris_object is None: + return + self.mpris_object.Seeked( + self.mpris_object.Get(objects.PLAYER_IFACE, 'Position')) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py new file mode 100644 index 00000000..77278778 --- /dev/null +++ b/mopidy/frontends/mpris/objects.py @@ -0,0 +1,436 @@ +import logging +import os + +logger = logging.getLogger('mopidy.frontends.mpris') + +try: + import dbus + import dbus.mainloop.glib + import dbus.service + import gobject +except ImportError as import_error: + from mopidy import OptionalDependencyError + raise OptionalDependencyError(import_error) + +from pykka.registry import ActorRegistry + +from mopidy import settings +from mopidy.backends.base import Backend +from mopidy.backends.base.playback import PlaybackController +from mopidy.mixers.base import BaseMixer +from mopidy.utils.process import exit_process + +# Must be done before dbus.SessionBus() is called +gobject.threads_init() +dbus.mainloop.glib.threads_init() +dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + +BUS_NAME = 'org.mpris.MediaPlayer2.mopidy' +OBJECT_PATH = '/org/mpris/MediaPlayer2' +ROOT_IFACE = 'org.mpris.MediaPlayer2' +PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player' + + +class MprisObject(dbus.service.Object): + """Implements http://www.mpris.org/2.1/spec/""" + + properties = None + + def __init__(self): + self._backend = None + self._mixer = None + self.properties = { + ROOT_IFACE: self._get_root_iface_properties(), + PLAYER_IFACE: self._get_player_iface_properties(), + } + bus_name = self._connect_to_dbus() + super(MprisObject, self).__init__(bus_name, OBJECT_PATH) + + def _get_root_iface_properties(self): + return { + 'CanQuit': (True, None), + 'CanRaise': (False, None), + # NOTE Change if adding optional track list support + 'HasTrackList': (False, None), + 'Identity': ('Mopidy', None), + 'DesktopEntry': (self.get_DesktopEntry, None), + 'SupportedUriSchemes': (self.get_SupportedUriSchemes, None), + # NOTE Return MIME types supported by local backend if support for + # reporting supported MIME types is added + 'SupportedMimeTypes': (dbus.Array([], signature='s'), None), + } + + def _get_player_iface_properties(self): + return { + 'PlaybackStatus': (self.get_PlaybackStatus, None), + 'LoopStatus': (self.get_LoopStatus, self.set_LoopStatus), + 'Rate': (1.0, self.set_Rate), + 'Shuffle': (self.get_Shuffle, self.set_Shuffle), + 'Metadata': (self.get_Metadata, None), + 'Volume': (self.get_Volume, self.set_Volume), + 'Position': (self.get_Position, None), + 'MinimumRate': (1.0, None), + 'MaximumRate': (1.0, None), + 'CanGoNext': (self.get_CanGoNext, None), + 'CanGoPrevious': (self.get_CanGoPrevious, None), + 'CanPlay': (self.get_CanPlay, None), + 'CanPause': (self.get_CanPause, None), + 'CanSeek': (self.get_CanSeek, None), + 'CanControl': (self.get_CanControl, None), + } + + def _connect_to_dbus(self): + logger.debug(u'Connecting to D-Bus...') + bus_name = dbus.service.BusName(BUS_NAME, dbus.SessionBus()) + logger.info(u'Connected to D-Bus') + return bus_name + + @property + def backend(self): + if self._backend is None: + backend_refs = ActorRegistry.get_by_class(Backend) + assert len(backend_refs) == 1, \ + 'Expected exactly one running backend.' + self._backend = backend_refs[0].proxy() + return self._backend + + @property + def mixer(self): + if self._mixer is None: + mixer_refs = ActorRegistry.get_by_class(BaseMixer) + assert len(mixer_refs) == 1, 'Expected exactly one running mixer.' + self._mixer = mixer_refs[0].proxy() + return self._mixer + + def _get_track_id(self, cp_track): + return '/com/mopidy/track/%d' % cp_track.cpid + + def _get_cpid(self, track_id): + assert track_id.startswith('/com/mopidy/track/') + return track_id.split('/')[-1] + + ### Properties interface + + @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, + in_signature='ss', out_signature='v') + def Get(self, interface, prop): + logger.debug(u'%s.Get(%s, %s) called', + dbus.PROPERTIES_IFACE, repr(interface), repr(prop)) + (getter, setter) = self.properties[interface][prop] + if callable(getter): + return getter() + else: + return getter + + @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, + in_signature='s', out_signature='a{sv}') + def GetAll(self, interface): + logger.debug(u'%s.GetAll(%s) called', + dbus.PROPERTIES_IFACE, repr(interface)) + getters = {} + for key, (getter, setter) in self.properties[interface].iteritems(): + getters[key] = getter() if callable(getter) else getter + return getters + + @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, + in_signature='ssv', out_signature='') + def Set(self, interface, prop, value): + logger.debug(u'%s.Set(%s, %s, %s) called', + dbus.PROPERTIES_IFACE, repr(interface), repr(prop), repr(value)) + getter, setter = self.properties[interface][prop] + if setter is not None: + setter(value) + self.PropertiesChanged(interface, + {prop: self.Get(interface, prop)}, []) + + @dbus.service.signal(dbus_interface=dbus.PROPERTIES_IFACE, + signature='sa{sv}as') + def PropertiesChanged(self, interface, changed_properties, + invalidated_properties): + logger.debug(u'%s.PropertiesChanged(%s, %s, %s) signaled', + dbus.PROPERTIES_IFACE, interface, changed_properties, + invalidated_properties) + + + ### Root interface methods + + @dbus.service.method(dbus_interface=ROOT_IFACE) + def Raise(self): + logger.debug(u'%s.Raise called', ROOT_IFACE) + # Do nothing, as we do not have a GUI + + @dbus.service.method(dbus_interface=ROOT_IFACE) + def Quit(self): + logger.debug(u'%s.Quit called', ROOT_IFACE) + exit_process() + + + ### Root interface properties + + def get_DesktopEntry(self): + return os.path.splitext(os.path.basename(settings.DESKTOP_FILE))[0] + + def get_SupportedUriSchemes(self): + return dbus.Array(self.backend.uri_schemes.get(), signature='s') + + + ### Player interface methods + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def Next(self): + logger.debug(u'%s.Next called', PLAYER_IFACE) + if not self.get_CanGoNext(): + logger.debug(u'%s.Next not allowed', PLAYER_IFACE) + return + self.backend.playback.next().get() + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def Previous(self): + logger.debug(u'%s.Previous called', PLAYER_IFACE) + if not self.get_CanGoPrevious(): + logger.debug(u'%s.Previous not allowed', PLAYER_IFACE) + return + self.backend.playback.previous().get() + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def Pause(self): + logger.debug(u'%s.Pause called', PLAYER_IFACE) + if not self.get_CanPause(): + logger.debug(u'%s.Pause not allowed', PLAYER_IFACE) + return + self.backend.playback.pause().get() + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def PlayPause(self): + logger.debug(u'%s.PlayPause called', PLAYER_IFACE) + if not self.get_CanPause(): + logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE) + return + state = self.backend.playback.state.get() + if state == PlaybackController.PLAYING: + self.backend.playback.pause().get() + elif state == PlaybackController.PAUSED: + self.backend.playback.resume().get() + elif state == PlaybackController.STOPPED: + self.backend.playback.play().get() + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def Stop(self): + logger.debug(u'%s.Stop called', PLAYER_IFACE) + if not self.get_CanControl(): + logger.debug(u'%s.Stop not allowed', PLAYER_IFACE) + return + self.backend.playback.stop().get() + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def Play(self): + logger.debug(u'%s.Play called', PLAYER_IFACE) + if not self.get_CanPlay(): + logger.debug(u'%s.Play not allowed', PLAYER_IFACE) + return + state = self.backend.playback.state.get() + if state == PlaybackController.PAUSED: + self.backend.playback.resume().get() + else: + self.backend.playback.play().get() + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def Seek(self, offset): + logger.debug(u'%s.Seek called', PLAYER_IFACE) + if not self.get_CanSeek(): + logger.debug(u'%s.Seek not allowed', PLAYER_IFACE) + return + offset_in_milliseconds = offset // 1000 + current_position = self.backend.playback.time_position.get() + new_position = current_position + offset_in_milliseconds + self.backend.playback.seek(new_position) + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def SetPosition(self, track_id, position): + logger.debug(u'%s.SetPosition called', PLAYER_IFACE) + if not self.get_CanSeek(): + logger.debug(u'%s.SetPosition not allowed', PLAYER_IFACE) + return + position = position // 1000 + current_cp_track = self.backend.playback.current_cp_track.get() + if current_cp_track is None: + return + if track_id != self._get_track_id(current_cp_track): + return + if position < 0: + return + if current_cp_track.track.length < position: + return + self.backend.playback.seek(position) + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def OpenUri(self, uri): + logger.debug(u'%s.OpenUri called', PLAYER_IFACE) + if not self.get_CanPlay(): + # NOTE The spec does not explictly require this check, but guarding + # the other methods doesn't help much if OpenUri is open for use. + logger.debug(u'%s.Play not allowed', PLAYER_IFACE) + return + # NOTE Check if URI has MIME type known to the backend, if MIME support + # is added to the backend. + uri_schemes = self.backend.uri_schemes.get() + if not any([uri.startswith(uri_scheme) for uri_scheme in uri_schemes]): + return + track = self.backend.library.lookup(uri).get() + if track is not None: + cp_track = self.backend.current_playlist.add(track).get() + self.backend.playback.play(cp_track) + else: + logger.debug(u'Track with URI "%s" not found in library.', uri) + + + ### Player interface signals + + @dbus.service.signal(dbus_interface=PLAYER_IFACE, signature='x') + def Seeked(self, position): + logger.debug(u'%s.Seeked signaled', PLAYER_IFACE) + # Do nothing, as just calling the method is enough to emit the signal. + + + ### Player interface properties + + def get_PlaybackStatus(self): + state = self.backend.playback.state.get() + if state == PlaybackController.PLAYING: + return 'Playing' + elif state == PlaybackController.PAUSED: + return 'Paused' + elif state == PlaybackController.STOPPED: + return 'Stopped' + + def get_LoopStatus(self): + repeat = self.backend.playback.repeat.get() + single = self.backend.playback.single.get() + if not repeat: + return 'None' + else: + if single: + return 'Track' + else: + return 'Playlist' + + def set_LoopStatus(self, value): + if not self.get_CanControl(): + logger.debug(u'Setting %s.LoopStatus not allowed', PLAYER_IFACE) + return + if value == 'None': + self.backend.playback.repeat = False + self.backend.playback.single = False + elif value == 'Track': + self.backend.playback.repeat = True + self.backend.playback.single = True + elif value == 'Playlist': + self.backend.playback.repeat = True + self.backend.playback.single = False + + def set_Rate(self, value): + if not self.get_CanControl(): + # NOTE The spec does not explictly require this check, but it was + # added to be consistent with all the other property setters. + logger.debug(u'Setting %s.Rate not allowed', PLAYER_IFACE) + return + if value == 0: + self.Pause() + + def get_Shuffle(self): + return self.backend.playback.random.get() + + def set_Shuffle(self, value): + if not self.get_CanControl(): + logger.debug(u'Setting %s.Shuffle not allowed', PLAYER_IFACE) + return + if value: + self.backend.playback.random = True + else: + self.backend.playback.random = False + + def get_Metadata(self): + current_cp_track = self.backend.playback.current_cp_track.get() + if current_cp_track is None: + return {'mpris:trackid': ''} + else: + (cpid, track) = current_cp_track + metadata = {'mpris:trackid': self._get_track_id(current_cp_track)} + if track.length: + metadata['mpris:length'] = track.length * 1000 + if track.uri: + metadata['xesam:url'] = track.uri + if track.name: + metadata['xesam:title'] = track.name + if track.artists: + artists = list(track.artists) + artists.sort(key=lambda a: a.name) + metadata['xesam:artist'] = dbus.Array( + [a.name for a in artists if a.name], signature='s') + if track.album and track.album.name: + metadata['xesam:album'] = track.album.name + if track.album and track.album.artists: + artists = list(track.album.artists) + artists.sort(key=lambda a: a.name) + metadata['xesam:albumArtist'] = dbus.Array( + [a.name for a in artists if a.name], signature='s') + if track.track_no: + metadata['xesam:trackNumber'] = track.track_no + return dbus.Dictionary(metadata, signature='sv') + + def get_Volume(self): + volume = self.mixer.volume.get() + if volume is not None: + return volume / 100.0 + + def set_Volume(self, value): + if not self.get_CanControl(): + logger.debug(u'Setting %s.Volume not allowed', PLAYER_IFACE) + return + if value is None: + return + elif value < 0: + self.mixer.volume = 0 + elif value > 1: + self.mixer.volume = 100 + elif 0 <= value <= 1: + self.mixer.volume = int(value * 100) + + def get_Position(self): + return self.backend.playback.time_position.get() * 1000 + + def get_CanGoNext(self): + if not self.get_CanControl(): + return False + return (self.backend.playback.cp_track_at_next.get() != + self.backend.playback.current_cp_track.get()) + + def get_CanGoPrevious(self): + if not self.get_CanControl(): + return False + return (self.backend.playback.cp_track_at_previous.get() != + self.backend.playback.current_cp_track.get()) + + def get_CanPlay(self): + if not self.get_CanControl(): + return False + return (self.backend.playback.current_track.get() is not None + or self.backend.playback.track_at_next.get() is not None) + + def get_CanPause(self): + if not self.get_CanControl(): + return False + # NOTE Should be changed to vary based on capabilities of the current + # track if Mopidy starts supporting non-seekable media, like streams. + return True + + def get_CanSeek(self): + if not self.get_CanControl(): + return False + # NOTE Should be changed to vary based on capabilities of the current + # track if Mopidy starts supporting non-seekable media, like streams. + return True + + def get_CanControl(self): + # NOTE This could be a setting for the end user to change. + return True diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index 166c487e..edcb3084 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -43,9 +43,6 @@ class GStreamer(ThreadingActor): self._handlers = {} def on_start(self): - # **Warning:** :class:`GStreamer` requires - # :class:`mopidy.utils.process.GObjectEventThread` to be running. This - # is not enforced by :class:`GStreamer` itself. self._setup_pipeline() self._setup_outputs() self._setup_message_processor() @@ -277,10 +274,18 @@ class GStreamer(ThreadingActor): taglist = gst.TagList() artists = [a for a in (track.artists or []) if a.name] + # Default to blank data to trick shoutcast into clearing any previous + # values it might have. + taglist[gst.TAG_ARTIST] = u' ' + taglist[gst.TAG_TITLE] = u' ' + taglist[gst.TAG_ALBUM] = u' ' + if artists: taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists]) + if track.name: taglist[gst.TAG_TITLE] = track.name + if track.album and track.album.name: taglist[gst.TAG_ALBUM] = track.album.name diff --git a/mopidy/listeners.py b/mopidy/listeners.py new file mode 100644 index 00000000..ee360bf3 --- /dev/null +++ b/mopidy/listeners.py @@ -0,0 +1,116 @@ +from pykka import registry + +class BackendListener(object): + """ + Marker interface for recipients of events sent by the backend. + + Any Pykka actor that mixes in this class will receive calls to the methods + defined here when the corresponding events happen in the backend. This + interface is used both for looking up what actors to notify of the events, + and for providing default implementations for those listeners that are not + interested in all events. + """ + + @staticmethod + def send(event, **kwargs): + """Helper to allow calling of backend listener events""" + # FIXME this should be updated once Pykka supports non-blocking calls + # on proxies or some similar solution. + registry.ActorRegistry.broadcast({ + 'command': 'pykka_call', + 'attr_path': (event,), + 'args': [], + 'kwargs': kwargs, + }, target_class=BackendListener) + + def track_playback_paused(self, track, time_position): + """ + Called whenever track playback is paused. + + *MAY* be implemented by actor. + + :param track: the track that was playing when playback paused + :type track: :class:`mopidy.models.Track` + :param time_position: the time position in milliseconds + :type time_position: int + """ + pass + + def track_playback_resumed(self, track, time_position): + """ + Called whenever track playback is resumed. + + *MAY* be implemented by actor. + + :param track: the track that was playing when playback resumed + :type track: :class:`mopidy.models.Track` + :param time_position: the time position in milliseconds + :type time_position: int + """ + pass + + + def track_playback_started(self, track): + """ + Called whenever a new track starts playing. + + *MAY* be implemented by actor. + + :param track: the track that just started playing + :type track: :class:`mopidy.models.Track` + """ + pass + + def track_playback_ended(self, track, time_position): + """ + Called whenever playback of a track ends. + + *MAY* be implemented by actor. + + :param track: the track that was played before playback stopped + :type track: :class:`mopidy.models.Track` + :param time_position: the time position in milliseconds + :type time_position: int + """ + pass + + def playback_state_changed(self): + """ + Called whenever playback state is changed. + + *MAY* be implemented by actor. + """ + pass + + def playlist_changed(self): + """ + Called whenever a playlist is changed. + + *MAY* be implemented by actor. + """ + pass + + def options_changed(self): + """ + Called whenever an option is changed. + + *MAY* be implemented by actor. + """ + pass + + def volume_changed(self): + """ + Called whenever the volume is changed. + + *MAY* be implemented by actor. + """ + pass + + def seeked(self): + """ + Called whenever the time position changes by an unexpected amount, e.g. + at seek to a new time position. + + *MAY* be implemented by actor. + """ + pass diff --git a/mopidy/mixers/base.py b/mopidy/mixers/base.py index ec3d8ae5..8798076a 100644 --- a/mopidy/mixers/base.py +++ b/mopidy/mixers/base.py @@ -1,4 +1,8 @@ -from mopidy import settings +import logging + +from mopidy import listeners, settings + +logger = logging.getLogger('mopdy.mixers') class BaseMixer(object): """ @@ -30,6 +34,7 @@ class BaseMixer(object): elif volume > 100: volume = 100 self.set_volume(volume) + self._trigger_volume_changed() def get_volume(self): """ @@ -46,3 +51,7 @@ class BaseMixer(object): *MUST be implemented by subclass.* """ raise NotImplementedError + + def _trigger_volume_changed(self): + logger.debug(u'Triggering volume changed event') + listeners.BackendListener.send('volume_changed') diff --git a/mopidy/settings.py b/mopidy/settings.py index 9ac63719..ccbf8457 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -4,7 +4,7 @@ Available settings and their default values. .. warning:: Do *not* change settings directly in :mod:`mopidy.settings`. Instead, add a - file called ``~/.mopidy/settings.py`` and redefine settings there. + file called ``~/.config/mopidy/settings.py`` and redefine settings there. """ #: List of playback backends to use. See :mod:`mopidy.backends` for all @@ -26,7 +26,8 @@ BACKENDS = ( #: details on the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s' -#: Which GStreamer bin description to use in :class:`mopidy.outputs.CustomOutput`. +#: Which GStreamer bin description to use in +#: :class:`mopidy.outputs.custom.CustomOutput`. #: #: Default:: #: @@ -48,6 +49,15 @@ DEBUG_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \ #: DEBUG_LOG_FILENAME = u'mopidy.log' DEBUG_LOG_FILENAME = u'mopidy.log' +#: Location of the Mopidy .desktop file. +#: +#: Used by :mod:`mopidy.frontends.mpris`. +#: +#: Default:: +#: +#: DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' +DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' + #: List of server frontends to use. #: #: Default:: @@ -55,10 +65,12 @@ DEBUG_LOG_FILENAME = u'mopidy.log' #: FRONTENDS = ( #: u'mopidy.frontends.mpd.MpdFrontend', #: u'mopidy.frontends.lastfm.LastfmFrontend', +#: u'mopidy.frontends.mpris.MprisFrontend', #: ) FRONTENDS = ( u'mopidy.frontends.mpd.MpdFrontend', u'mopidy.frontends.lastfm.LastfmFrontend', + u'mopidy.frontends.mpris.MprisFrontend', ) #: Your `Last.fm `_ username. @@ -77,8 +89,9 @@ LASTFM_PASSWORD = u'' #: #: Default:: #: -#: LOCAL_MUSIC_PATH = u'~/music' -LOCAL_MUSIC_PATH = u'~/music' +#: # Defaults to asking glib where music is stored, fallback is ~/music +#: LOCAL_MUSIC_PATH = None +LOCAL_MUSIC_PATH = None #: Path to playlist folder with m3u files for local music. #: @@ -86,8 +99,8 @@ LOCAL_MUSIC_PATH = u'~/music' #: #: Default:: #: -#: LOCAL_PLAYLIST_PATH = u'~/.mopidy/playlists' -LOCAL_PLAYLIST_PATH = u'~/.mopidy/playlists' +#: LOCAL_PLAYLIST_PATH = None # Implies $XDG_DATA_DIR/mopidy/playlists +LOCAL_PLAYLIST_PATH = None #: Path to tag cache for local music. #: @@ -95,8 +108,8 @@ LOCAL_PLAYLIST_PATH = u'~/.mopidy/playlists' #: #: Default:: #: -#: LOCAL_TAG_CACHE_FILE = u'~/.mopidy/tag_cache' -LOCAL_TAG_CACHE_FILE = u'~/.mopidy/tag_cache' +#: LOCAL_TAG_CACHE_FILE = None # Implies $XDG_DATA_DIR/mopidy/tag_cache +LOCAL_TAG_CACHE_FILE = None #: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers. #: @@ -167,6 +180,11 @@ MPD_SERVER_PORT = 6600 #: Default: :class:`None`, which means no password required. MPD_SERVER_PASSWORD = None +#: The maximum number of concurrent connections the MPD server will accept. +#: +#: Default: 20 +MPD_SERVER_MAX_CONNECTIONS = 20 + #: List of outputs to use. See :mod:`mopidy.outputs` for all available #: backends #: @@ -236,7 +254,7 @@ SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320' #: Path to the Spotify cache. #: #: Used by :mod:`mopidy.backends.spotify`. -SPOTIFY_CACHE_PATH = u'~/.mopidy/spotify_cache' +SPOTIFY_CACHE_PATH = None #: Your Spotify Premium username. #: diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index acbb4664..9d7532a0 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -18,9 +18,11 @@ def import_module(name): return sys.modules[name] def get_class(name): + logger.debug('Loading: %s', name) + if '.' not in name: + raise ImportError("Couldn't load: %s" % 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) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 03b85b48..0e5dfc29 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -20,7 +20,7 @@ def setup_console_logging(verbosity_level): if verbosity_level == 0: log_level = logging.WARNING log_format = settings.CONSOLE_LOG_FORMAT - elif verbosity_level == 2: + elif verbosity_level >= 2: log_level = logging.DEBUG log_format = settings.DEBUG_LOG_FORMAT else: @@ -33,6 +33,9 @@ def setup_console_logging(verbosity_level): root = logging.getLogger('') root.addHandler(handler) + if verbosity_level < 3: + logging.getLogger('pykka').setLevel(logging.INFO) + def setup_debug_logging_to_file(): formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT) handler = logging.handlers.RotatingFileHandler( diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 1dedf7d7..5079fe7c 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -1,10 +1,20 @@ +import errno +import gobject import logging import re import socket +import threading + +from pykka import ActorDeadError +from pykka.actor import ThreadingActor +from pykka.registry import ActorRegistry logger = logging.getLogger('mopidy.utils.server') -def _try_ipv6_socket(): +class ShouldRetrySocketCall(Exception): + """Indicate that attempted socket call should be retried""" + +def try_ipv6_socket(): """Determine if system really supports IPv6""" if not socket.has_ipv6: return False @@ -17,7 +27,7 @@ def _try_ipv6_socket(): return False #: Boolean value that indicates if creating an IPv6 socket will succeed. -has_ipv6 = _try_ipv6_socket() +has_ipv6 = try_ipv6_socket() def create_socket(): """Create a TCP socket with or without IPv6 depending on system support""" @@ -27,6 +37,7 @@ def create_socket(): sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) else: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) return sock def format_hostname(hostname): @@ -34,3 +45,350 @@ def format_hostname(hostname): if (has_ipv6 and re.match('\d+.\d+.\d+.\d+', hostname) is not None): hostname = '::ffff:%s' % hostname return hostname + +class Server(object): + """Setup listener and register it with gobject's event loop.""" + + def __init__(self, host, port, protocol, max_connections=5, timeout=30): + self.protocol = protocol + self.max_connections = max_connections + self.timeout = timeout + self.server_socket = self.create_server_socket(host, port) + + self.register_server_socket(self.server_socket.fileno()) + + def create_server_socket(self, host, port): + sock = create_socket() + sock.setblocking(False) + sock.bind((host, port)) + sock.listen(1) + return sock + + def register_server_socket(self, fileno): + gobject.io_add_watch(fileno, gobject.IO_IN, self.handle_connection) + + def handle_connection(self, fd, flags): + try: + sock, addr = self.accept_connection() + except ShouldRetrySocketCall: + return True + + if self.maximum_connections_exceeded(): + self.reject_connection(sock, addr) + else: + self.init_connection(sock, addr) + return True + + def accept_connection(self): + try: + return self.server_socket.accept() + except socket.error as e: + if e.errno in (errno.EAGAIN, errno.EINTR): + raise ShouldRetrySocketCall + raise + + def maximum_connections_exceeded(self): + return (self.max_connections is not None and + self.number_of_connections() >= self.max_connections) + + def number_of_connections(self): + return len(ActorRegistry.get_by_class(self.protocol)) + + def reject_connection(self, sock, addr): + # FIXME provide more context in logging? + logger.warning(u'Rejected connection from [%s]:%s', addr[0], addr[1]) + try: + sock.close() + except socket.error: + pass + + def init_connection(self, sock, addr): + Connection(self.protocol, sock, addr, self.timeout) + + +class Connection(object): + # NOTE: the callback code is _not_ run in the actor's thread, but in the + # same one as the event loop. If code in the callbacks blocks, the rest of + # gobject code will likely be blocked as well... + # + # Also note that source_remove() return values are ignored on purpose, a + # false return value would only tell us that what we thought was registered + # is already gone, there is really nothing more we can do. + + def __init__(self, protocol, sock, addr, timeout): + sock.setblocking(False) + + self.host, self.port = addr[:2] # IPv6 has larger addr + + self.sock = sock + self.protocol = protocol + self.timeout = timeout + + self.send_lock = threading.Lock() + self.send_buffer = '' + + self.stopping = False + + self.recv_id = None + self.send_id = None + self.timeout_id = None + + self.actor_ref = self.protocol.start(self) + + self.enable_recv() + self.enable_timeout() + + def stop(self, reason, level=logging.DEBUG): + if self.stopping: + logger.log(level, 'Already stopping: %s' % reason) + return + else: + self.stopping = True + + logger.log(level, reason) + + try: + self.actor_ref.stop() + except ActorDeadError: + pass + + self.disable_timeout() + self.disable_recv() + self.disable_send() + + try: + self.sock.close() + except socket.error: + pass + + def queue_send(self, data): + """Try to send data to client exactly as is and queue rest.""" + self.send_lock.acquire(True) + self.send_buffer = self.send(self.send_buffer + data) + self.send_lock.release() + if self.send_buffer: + self.enable_send() + + def send(self, data): + """Send data to client, return any unsent data.""" + try: + sent = self.sock.send(data) + return data[sent:] + except socket.error as e: + if e.errno in (errno.EWOULDBLOCK, errno.EINTR): + return data + self.stop(u'Unexpected client error: %s' % e) + return '' + + def enable_timeout(self): + """Reactivate timeout mechanism.""" + if self.timeout <= 0: + return + + self.disable_timeout() + self.timeout_id = gobject.timeout_add_seconds( + self.timeout, self.timeout_callback) + + def disable_timeout(self): + """Deactivate timeout mechanism.""" + if self.timeout_id is None: + return + gobject.source_remove(self.timeout_id) + self.timeout_id = None + + def enable_recv(self): + if self.recv_id is not None: + return + + try: + self.recv_id = gobject.io_add_watch(self.sock.fileno(), + gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, + self.recv_callback) + except socket.error as e: + self.stop(u'Problem with connection: %s' % e) + + def disable_recv(self): + if self.recv_id is None: + return + gobject.source_remove(self.recv_id) + self.recv_id = None + + def enable_send(self): + if self.send_id is not None: + return + + try: + self.send_id = gobject.io_add_watch(self.sock.fileno(), + gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, + self.send_callback) + except socket.error as e: + self.stop(u'Problem with connection: %s' % e) + + def disable_send(self): + if self.send_id is None: + return + + gobject.source_remove(self.send_id) + self.send_id = None + + def recv_callback(self, fd, flags): + if flags & (gobject.IO_ERR | gobject.IO_HUP): + self.stop(u'Bad client flags: %s' % flags) + return True + + try: + data = self.sock.recv(4096) + except socket.error as e: + if e.errno not in (errno.EWOULDBLOCK, errno.EINTR): + self.stop(u'Unexpected client error: %s' % e) + return True + + if not data: + self.stop(u'Client most likely disconnected.') + return True + + try: + self.actor_ref.send_one_way({'received': data}) + except ActorDeadError: + self.stop(u'Actor is dead.') + + return True + + def send_callback(self, fd, flags): + if flags & (gobject.IO_ERR | gobject.IO_HUP): + self.stop(u'Bad client flags: %s' % flags) + return True + + # If with can't get the lock, simply try again next time socket is + # ready for sending. + if not self.send_lock.acquire(False): + return True + + try: + self.send_buffer = self.send(self.send_buffer) + if not self.send_buffer: + self.disable_send() + finally: + self.send_lock.release() + + return True + + def timeout_callback(self): + self.stop(u'Client timeout out after %s seconds' % self.timeout) + return False + + +class LineProtocol(ThreadingActor): + """ + Base class for handling line based protocols. + + Takes care of receiving new data from server's client code, decoding and + then splitting data along line boundaries. + """ + + #: Line terminator to use for outputed lines. + terminator = '\n' + + #: Regex to use for spliting lines, will be set compiled version of its + #: own value, or to ``terminator``s value if it is not set itself. + delimeter = None + + #: What encoding to expect incomming data to be in, can be :class:`None`. + encoding = 'utf-8' + + def __init__(self, connection): + self.connection = connection + self.prevent_timeout = False + self.recv_buffer = '' + + if self.delimeter: + self.delimeter = re.compile(self.delimeter) + else: + self.delimeter = re.compile(self.terminator) + + @property + def host(self): + return self.connection.host + + @property + def port(self): + return self.connection.port + + def on_line_received(self, line): + """ + Called whenever a new line is found. + + Should be implemented by subclasses. + """ + raise NotImplementedError + + def on_receive(self, message): + """Handle messages with new data from server.""" + if 'received' not in message: + return + + self.connection.disable_timeout() + self.recv_buffer += message['received'] + + for line in self.parse_lines(): + line = self.decode(line) + if line is not None: + self.on_line_received(line) + + if not self.prevent_timeout: + self.connection.enable_timeout() + + def on_stop(self): + """Ensure that cleanup when actor stops.""" + self.connection.stop(u'Actor is shutting down.') + + def parse_lines(self): + """Consume new data and yield any lines found.""" + while re.search(self.terminator, self.recv_buffer): + line, self.recv_buffer = self.delimeter.split( + self.recv_buffer, 1) + yield line + + def encode(self, line): + """ + Handle encoding of line. + + Can be overridden by subclasses to change encoding behaviour. + """ + try: + return line.encode(self.encoding) + except UnicodeError: + logger.warning(u'Stopping actor due to encode problem, data ' + 'supplied by client was not valid %s', self.encoding) + self.stop() + + def decode(self, line): + """ + Handle decoding of line. + + Can be overridden by subclasses to change decoding behaviour. + """ + try: + return line.decode(self.encoding) + except UnicodeError: + logger.warning(u'Stopping actor due to decode problem, data ' + 'supplied by client was not valid %s', self.encoding) + self.stop() + + def join_lines(self, lines): + if not lines: + return u'' + return self.terminator.join(lines) + self.terminator + + def send_lines(self, lines): + """ + Send array of lines to client via connection. + + Join lines using the terminator that is set for this class, encode it + and send it to the client. + """ + if not lines: + return + + data = self.join_lines(lines) + self.connection.queue_send(self.encode(data)) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 540cb4fa..8bd39f06 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -60,6 +60,7 @@ def find_files(path): yield filename # pylint: enable = W0612 +# FIXME replace with mock usage in tests. class Mtime(object): def __init__(self): self.fake = None diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index c1d1c9f5..80d850fe 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -3,9 +3,6 @@ import signal import thread import threading -import gobject -gobject.threads_init() - from pykka import ActorDeadError from pykka.registry import ActorRegistry @@ -25,9 +22,17 @@ def exit_handler(signum, frame): logger.info(u'Got %s signal', signals[signum]) exit_process() -def stop_all_actors(): +def stop_actors_by_class(klass): + actors = ActorRegistry.get_by_class(klass) + logger.debug(u'Stopping %d instance(s) of %s', len(actors), klass.__name__) + for actor in actors: + actor.stop() + +def stop_remaining_actors(): num_actors = len(ActorRegistry.get_all()) while num_actors: + logger.error( + u'There are actor threads still running, this is probably a bug') logger.debug(u'Seeing %d actor and %d non-actor thread(s): %s', num_actors, threading.active_count() - num_actors, ', '.join([t.name for t in threading.enumerate()])) @@ -60,25 +65,3 @@ class BaseThread(threading.Thread): def run_inside_try(self): raise NotImplementedError - - -class GObjectEventThread(BaseThread): - """ - A GObject event loop which is shared by all Mopidy components that uses - libraries that need a GObject event loop, like GStreamer and D-Bus. - - Should be started by Mopidy's core and used by - :mod:`mopidy.output.gstreamer`, :mod:`mopidy.frontend.mpris`, etc. - """ - - def __init__(self): - super(GObjectEventThread, self).__init__() - self.name = u'GObjectEventThread' - self.loop = None - - def run_inside_try(self): - self.loop = gobject.MainLoop().run() - - def destroy(self): - self.loop.quit() - super(GObjectEventThread, self).destroy() diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index cab94089..fca4f337 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -2,12 +2,13 @@ from __future__ import absolute_import from copy import copy import getpass +import glib import logging import os from pprint import pformat import sys -from mopidy import SettingsError +from mopidy import SettingsError, SETTINGS_PATH, SETTINGS_FILE from mopidy.utils.log import indent logger = logging.getLogger('mopidy.utils.settings') @@ -20,11 +21,9 @@ class SettingsProxy(object): self.runtime = {} 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): + if not os.path.isfile(SETTINGS_FILE): return {} - sys.path.insert(0, dotdir) + sys.path.insert(0, SETTINGS_PATH) # pylint: disable = F0401 import settings as local_settings_module # pylint: enable = F0401 @@ -53,6 +52,8 @@ class SettingsProxy(object): value = self.current[attr] if isinstance(value, basestring) and len(value) == 0: raise SettingsError(u'Setting "%s" is empty.' % attr) + if not value: + return value if attr.endswith('_PATH') or attr.endswith('_FILE'): value = os.path.expanduser(value) value = os.path.abspath(value) diff --git a/requirements/core.txt b/requirements/core.txt index aaae84f8..8f9da622 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1 +1 @@ -Pykka >= 0.12 +Pykka >= 0.12.3 diff --git a/requirements/tests.txt b/requirements/tests.txt index f8cf2eb3..922ef6dc 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,4 +1,5 @@ coverage -mock +mock >= 0.7 nose tox +yappi diff --git a/tests/__init__.py b/tests/__init__.py index 1d4d2e3d..833ff239 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,24 +1,41 @@ import os +import sys -try: # 2.7 - # pylint: disable = E0611,F0401 - from unittest.case import SkipTest - # pylint: enable = E0611,F0401 -except ImportError: - try: # Nose - from nose.plugins.skip import SkipTest - except ImportError: # Failsafe - class SkipTest(Exception): - pass +if sys.version_info < (2, 7): + import unittest2 as unittest +else: + import unittest from mopidy import settings # Nuke any local settings to ensure same test env all over settings.local.clear() + def path_to_data_dir(name): path = os.path.dirname(__file__) path = os.path.join(path, 'data') path = os.path.abspath(path) return os.path.join(path, name) + +class IsA(object): + def __init__(self, klass): + self.klass = klass + + def __eq__(self, rhs): + try: + return isinstance(rhs, self.klass) + except TypeError: + return type(rhs) == type(self.klass) + + def __ne__(self, rhs): + return not self.__eq__(rhs) + + def __repr__(self): + return str(self.klass) + + +any_int = IsA(int) +any_str = IsA(str) +any_unicode = IsA(unicode) diff --git a/tests/__main__.py b/tests/__main__.py index e2bb3e72..69113580 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -1,4 +1,8 @@ import nose +import yappi -if __name__ == '__main__': +try: + yappi.start() nose.main() +finally: + yappi.print_stats() diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index b84391af..c81f4a0d 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -1,5 +1,4 @@ import mock -import multiprocessing import random from mopidy.models import Playlist, Track @@ -7,6 +6,7 @@ from mopidy.gstreamer import GStreamer from tests.backends.base import populate_playlist + class CurrentPlaylistControllerTest(object): tracks = [] diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 2a3de730..4b3ef5c0 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -1,6 +1,7 @@ from mopidy.models import Playlist, Track, Album, Artist -from tests import SkipTest, path_to_data_dir +from tests import unittest, path_to_data_dir + class LibraryControllerTest(object): artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] @@ -20,11 +21,13 @@ class LibraryControllerTest(object): def test_refresh(self): self.library.refresh() + @unittest.SkipTest def test_refresh_uri(self): - raise SkipTest + pass + @unittest.SkipTest def test_refresh_missing_uri(self): - raise SkipTest + pass def test_lookup(self): track = self.library.lookup(self.tracks[0].uri) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 2d455225..40c49709 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -1,16 +1,16 @@ import mock -import multiprocessing import random import time from mopidy.models import Track from mopidy.gstreamer import GStreamer -from tests import SkipTest +from tests import unittest from tests.backends.base import populate_playlist # TODO Test 'playlist repeat', e.g. repeat=1,single=0 + class PlaybackControllerTest(object): tracks = [] @@ -520,7 +520,7 @@ class PlaybackControllerTest(object): self.assert_(wrapper.called) - @SkipTest # Blocks for 10ms + @unittest.SkipTest # Blocks for 10ms @populate_playlist def test_end_of_track_callback_gets_called(self): self.playback.play() @@ -555,7 +555,7 @@ class PlaybackControllerTest(object): @populate_playlist def test_pause_when_stopped(self): self.playback.pause() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, self.playback.PAUSED) @populate_playlist def test_pause_when_playing(self): @@ -599,7 +599,7 @@ class PlaybackControllerTest(object): self.playback.pause() self.assertEqual(self.playback.resume(), None) - @SkipTest # Uses sleep and might not work with LocalBackend + @unittest.SkipTest # Uses sleep and might not work with LocalBackend @populate_playlist def test_resume_continues_from_right_position(self): self.playback.play() @@ -668,7 +668,7 @@ class PlaybackControllerTest(object): self.playback.seek(0) self.assertEqual(self.playback.state, self.playback.PLAYING) - @SkipTest + @unittest.SkipTest @populate_playlist def test_seek_beyond_end_of_song(self): # FIXME need to decide return value @@ -688,7 +688,7 @@ class PlaybackControllerTest(object): self.playback.seek(self.current_playlist.tracks[-1].length * 100) self.assertEqual(self.playback.state, self.playback.STOPPED) - @SkipTest + @unittest.SkipTest @populate_playlist def test_seek_beyond_start_of_song(self): # FIXME need to decide return value @@ -741,7 +741,7 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.time_position, 0) - @SkipTest # Uses sleep and does might not work with LocalBackend + @unittest.SkipTest # Uses sleep and does might not work with LocalBackend @populate_playlist def test_time_position_when_playing(self): self.playback.play() @@ -750,7 +750,7 @@ class PlaybackControllerTest(object): second = self.playback.time_position self.assert_(second > first, '%s - %s' % (first, second)) - @SkipTest # Uses sleep + @unittest.SkipTest # Uses sleep @populate_playlist def test_time_position_when_paused(self): self.playback.play() diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 839d5bed..54315e62 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -5,7 +5,8 @@ import tempfile from mopidy import settings from mopidy.models import Playlist -from tests import SkipTest, path_to_data_dir +from tests import unittest, path_to_data_dir + class StoredPlaylistsControllerTest(object): def setUp(self): @@ -78,11 +79,13 @@ class StoredPlaylistsControllerTest(object): except LookupError as e: self.assertEqual(u'"name=c" match no playlists', e[0]) + @unittest.SkipTest def test_lookup(self): - raise SkipTest + pass + @unittest.SkipTest def test_refresh(self): - raise SkipTest + pass def test_rename(self): playlist = self.stored.create('test') @@ -100,5 +103,6 @@ class StoredPlaylistsControllerTest(object): self.stored.save(playlist) self.assert_(playlist in self.stored.playlists) + @unittest.SkipTest def test_playlist_with_unknown_track(self): - raise SkipTest + pass diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py new file mode 100644 index 00000000..d761676d --- /dev/null +++ b/tests/backends/events_test.py @@ -0,0 +1,53 @@ +import mock + +from pykka.registry import ActorRegistry + +from mopidy.backends.dummy import DummyBackend +from mopidy.listeners import BackendListener +from mopidy.models import Track + +from tests import unittest + + +@mock.patch.object(BackendListener, 'send') +class BackendEventsTest(unittest.TestCase): + def setUp(self): + self.backend = DummyBackend.start().proxy() + + def tearDown(self): + ActorRegistry.stop_all() + + def test_pause_sends_track_playback_paused_event(self, send): + self.backend.current_playlist.add(Track(uri='a')) + self.backend.playback.play().get() + send.reset_mock() + self.backend.playback.pause().get() + self.assertEqual(send.call_args[0][0], 'track_playback_paused') + + def test_resume_sends_track_playback_resumed(self, send): + self.backend.current_playlist.add(Track(uri='a')) + self.backend.playback.play() + self.backend.playback.pause().get() + send.reset_mock() + self.backend.playback.resume().get() + self.assertEqual(send.call_args[0][0], 'track_playback_resumed') + + def test_play_sends_track_playback_started_event(self, send): + self.backend.current_playlist.add(Track(uri='a')) + send.reset_mock() + self.backend.playback.play().get() + self.assertEqual(send.call_args[0][0], 'track_playback_started') + + def test_stop_sends_track_playback_ended_event(self, send): + self.backend.current_playlist.add(Track(uri='a')) + self.backend.playback.play().get() + send.reset_mock() + self.backend.playback.stop().get() + self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended') + + def test_seek_sends_seeked_event(self, send): + self.backend.current_playlist.add(Track(uri='a', length=40000)) + self.backend.playback.play().get() + send.reset_mock() + self.backend.playback.seek(1000).get() + self.assertEqual(send.call_args[0][0], 'seeked') diff --git a/tests/backends/local/current_playlist_test.py b/tests/backends/local/current_playlist_test.py index 6f72d7d5..a475a6fd 100644 --- a/tests/backends/local/current_playlist_test.py +++ b/tests/backends/local/current_playlist_test.py @@ -1,18 +1,16 @@ -import unittest - -# FIXME Our Windows build server does not support GStreamer yet import sys -if sys.platform == 'win32': - from tests import SkipTest - raise SkipTest from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Track +from tests import unittest from tests.backends.base.current_playlist import CurrentPlaylistControllerTest from tests.backends.local import generate_song + +@unittest.skipIf(sys.platform == 'win32', + 'Our Windows build server does not support GStreamer yet') class LocalCurrentPlaylistControllerTest(CurrentPlaylistControllerTest, unittest.TestCase): diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 68ab22e9..046e747a 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,17 +1,14 @@ -import unittest - -# FIXME Our Windows build server does not support GStreamer yet import sys -if sys.platform == 'win32': - from tests import SkipTest - raise SkipTest from mopidy import settings from mopidy.backends.local import LocalBackend -from tests import path_to_data_dir +from tests import unittest, path_to_data_dir from tests.backends.base.library import LibraryControllerTest + +@unittest.skipIf(sys.platform == 'win32', + 'Our Windows build server does not support GStreamer yet') class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): backend_class = LocalBackend diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 2cdeadb9..788fe33c 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1,20 +1,17 @@ -import unittest - -# FIXME Our Windows build server does not support GStreamer yet import sys -if sys.platform == 'win32': - from tests import SkipTest - raise SkipTest from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Track from mopidy.utils.path import path_to_uri -from tests import path_to_data_dir +from tests import unittest, path_to_data_dir from tests.backends.base.playback import PlaybackControllerTest from tests.backends.local import generate_song + +@unittest.skipIf(sys.platform == 'win32', + 'Our Windows build server does not support GStreamer yet') class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): backend_class = LocalBackend tracks = [Track(uri=generate_song(i), length=4464) @@ -36,8 +33,8 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): track = Track(uri=uri, length=4464) self.backend.current_playlist.add(track) - def test_uri_handler(self): - self.assert_('file://' in self.backend.uri_handlers) + def test_uri_scheme(self): + self.assert_('file' in self.backend.uri_schemes) def test_play_mp3(self): self.add_track('blank.mp3') diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index b426e9ce..56be92c4 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -1,24 +1,19 @@ -import unittest import os - -from tests import SkipTest - -# FIXME Our Windows build server does not support GStreamer yet import sys -if sys.platform == 'win32': - raise SkipTest 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.path import path_to_uri -from tests import path_to_data_dir -from tests.backends.base.stored_playlists import \ - StoredPlaylistsControllerTest +from tests import unittest, path_to_data_dir +from tests.backends.base.stored_playlists import ( + StoredPlaylistsControllerTest) from tests.backends.local import generate_song + +@unittest.skipIf(sys.platform == 'win32', + 'Our Windows build server does not support GStreamer yet') class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, unittest.TestCase): @@ -77,14 +72,18 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, self.assertEqual('test', self.stored.playlists[0].name) self.assertEqual(track.uri, self.stored.playlists[0].tracks[0].uri) + @unittest.SkipTest def test_santitising_of_playlist_filenames(self): - raise SkipTest + pass + @unittest.SkipTest def test_playlist_folder_is_createad(self): - raise SkipTest + pass + @unittest.SkipTest def test_create_sets_playlist_uri(self): - raise SkipTest + pass + @unittest.SkipTest def test_save_sets_playlist_uri(self): - raise SkipTest + pass diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index a4e9f317..1dceb737 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -2,13 +2,12 @@ import os import tempfile -import unittest 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, path_to_data_dir +from tests import unittest, path_to_data_dir song1_path = path_to_data_dir('song1.mp3') song2_path = path_to_data_dir('song2.mp3') @@ -17,6 +16,9 @@ song1_uri = path_to_uri(song1_path) song2_uri = path_to_uri(song2_path) encoded_uri = path_to_uri(encoded_path) +# FIXME use mock instead of tempfile.NamedTemporaryFile + + class M3UToUriTest(unittest.TestCase): def test_empty_file(self): uris = parse_m3u(path_to_data_dir('empty.m3u')) @@ -127,9 +129,10 @@ class MPDTagCacheToTracksTest(unittest.TestCase): self.assertEqual(track, list(tracks)[0]) + @unittest.SkipTest def test_misencoded_cache(self): # FIXME not sure if this can happen - raise SkipTest + pass def test_cache_with_blank_track_info(self): tracks = parse_mpd_tag_cache(path_to_data_dir('blank_tag_cache'), diff --git a/tests/frontends/mpd/audio_output_test.py b/tests/frontends/mpd/audio_output_test.py deleted file mode 100644 index 82d9e203..00000000 --- a/tests/frontends/mpd/audio_output_test.py +++ /dev/null @@ -1,30 +0,0 @@ -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer - -class AudioOutputHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_enableoutput(self): - result = self.dispatcher.handle_request(u'enableoutput "0"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_disableoutput(self): - result = self.dispatcher.handle_request(u'disableoutput "0"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_outputs(self): - result = self.dispatcher.handle_request(u'outputs') - self.assert_(u'outputid: 0' in result) - self.assert_(u'outputname: None' in result) - self.assert_(u'outputenabled: 1' in result) - self.assert_(u'OK' in result) diff --git a/tests/frontends/mpd/command_list_test.py b/tests/frontends/mpd/command_list_test.py deleted file mode 100644 index 8fd4c828..00000000 --- a/tests/frontends/mpd/command_list_test.py +++ /dev/null @@ -1,63 +0,0 @@ -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher -from mopidy.mixers.dummy import DummyMixer - -class CommandListsTest(unittest.TestCase): - def setUp(self): - self.b = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = dispatcher.MpdDispatcher() - - def tearDown(self): - self.b.stop().get() - self.mixer.stop().get() - - def test_command_list_begin(self): - result = self.dispatcher.handle_request(u'command_list_begin') - self.assertEquals(result, []) - - def test_command_list_end(self): - self.dispatcher.handle_request(u'command_list_begin') - result = self.dispatcher.handle_request(u'command_list_end') - self.assert_(u'OK' in result) - - def test_command_list_end_without_start_first_is_an_unknown_command(self): - result = self.dispatcher.handle_request(u'command_list_end') - self.assertEquals(result[0], - u'ACK [5@0] {} unknown command "command_list_end"') - - def test_command_list_with_ping(self): - self.dispatcher.handle_request(u'command_list_begin') - self.assertEqual([], self.dispatcher.command_list) - self.assertEqual(False, self.dispatcher.command_list_ok) - self.dispatcher.handle_request(u'ping') - self.assert_(u'ping' in self.dispatcher.command_list) - result = self.dispatcher.handle_request(u'command_list_end') - self.assert_(u'OK' in result) - self.assertEqual(False, self.dispatcher.command_list) - - def test_command_list_with_error_returns_ack_with_correct_index(self): - self.dispatcher.handle_request(u'command_list_begin') - self.dispatcher.handle_request(u'play') # Known command - self.dispatcher.handle_request(u'paly') # Unknown command - result = self.dispatcher.handle_request(u'command_list_end') - self.assertEqual(len(result), 1, result) - self.assertEqual(result[0], u'ACK [5@1] {} unknown command "paly"') - - def test_command_list_ok_begin(self): - result = self.dispatcher.handle_request(u'command_list_ok_begin') - self.assertEquals(result, []) - - def test_command_list_ok_with_ping(self): - self.dispatcher.handle_request(u'command_list_ok_begin') - self.assertEqual([], self.dispatcher.command_list) - self.assertEqual(True, self.dispatcher.command_list_ok) - self.dispatcher.handle_request(u'ping') - self.assert_(u'ping' in self.dispatcher.command_list) - result = self.dispatcher.handle_request(u'command_list_end') - self.assert_(u'list_OK' in result) - self.assert_(u'OK' in result) - self.assertEqual(False, self.dispatcher.command_list) - self.assertEqual(False, self.dispatcher.command_list_ok) diff --git a/tests/frontends/mpd/connection_test.py b/tests/frontends/mpd/connection_test.py deleted file mode 100644 index bc995a5e..00000000 --- a/tests/frontends/mpd/connection_test.py +++ /dev/null @@ -1,53 +0,0 @@ -import mock -import unittest - -from mopidy import settings -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.frontends.mpd.session import MpdSession -from mopidy.mixers.dummy import DummyMixer - -class ConnectionHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.session = mock.Mock(spec=MpdSession) - self.dispatcher = MpdDispatcher(session=self.session) - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - settings.runtime.clear() - - def test_close_closes_the_client_connection(self): - result = self.dispatcher.handle_request(u'close') - self.assert_(self.session.close.called, - u'Should call close() on MpdSession') - self.assert_(u'OK' in result) - - def test_empty_request(self): - result = self.dispatcher.handle_request(u'') - self.assert_(u'OK' in result) - - def test_kill(self): - result = self.dispatcher.handle_request(u'kill') - self.assert_(u'ACK [4@0] {kill} you don\'t have permission for "kill"' in result) - - def test_valid_password_is_accepted(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - result = self.dispatcher.handle_request(u'password "topsecret"') - self.assert_(u'OK' in result) - - def test_invalid_password_is_not_accepted(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - result = self.dispatcher.handle_request(u'password "secret"') - self.assert_(u'ACK [3@0] {password} incorrect password' in result) - - def test_any_password_is_not_accepted_when_password_check_turned_off(self): - settings.MPD_SERVER_PASSWORD = None - result = self.dispatcher.handle_request(u'password "secret"') - self.assert_(u'ACK [3@0] {password} incorrect password' in result) - - def test_ping(self): - result = self.dispatcher.handle_request(u'ping') - self.assert_(u'OK' in result) diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 7708ce31..bfa7c548 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -1,11 +1,12 @@ -import unittest - from mopidy.backends.dummy import DummyBackend from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.exceptions import MpdAckError from mopidy.frontends.mpd.protocol import request_handlers, handle_request from mopidy.mixers.dummy import DummyMixer +from tests import unittest + + class MpdDispatcherTest(unittest.TestCase): def setUp(self): self.backend = DummyBackend.start().proxy() diff --git a/tests/frontends/mpd/exception_test.py b/tests/frontends/mpd/exception_test.py index df2cd65e..2ea3fe62 100644 --- a/tests/frontends/mpd/exception_test.py +++ b/tests/frontends/mpd/exception_test.py @@ -1,8 +1,9 @@ -import unittest - from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdSystemError, MpdNotImplemented) +from tests import unittest + + class MpdExceptionsTest(unittest.TestCase): def test_key_error_wrapped_in_mpd_ack_error(self): try: diff --git a/tests/frontends/mpd/music_db_test.py b/tests/frontends/mpd/music_db_test.py deleted file mode 100644 index 3793db9e..00000000 --- a/tests/frontends/mpd/music_db_test.py +++ /dev/null @@ -1,412 +0,0 @@ -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer - -class MusicDatabaseHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_count(self): - result = self.dispatcher.handle_request(u'count "tag" "needle"') - self.assert_(u'songs: 0' in result) - self.assert_(u'playtime: 0' in result) - self.assert_(u'OK' in result) - - def test_findadd(self): - result = self.dispatcher.handle_request(u'findadd "album" "what"') - self.assert_(u'OK' in result) - - def test_listall(self): - result = self.dispatcher.handle_request( - u'listall "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_listallinfo(self): - result = self.dispatcher.handle_request( - u'listallinfo "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_lsinfo_without_path_returns_same_as_listplaylists(self): - lsinfo_result = self.dispatcher.handle_request(u'lsinfo') - listplaylists_result = self.dispatcher.handle_request(u'listplaylists') - self.assertEqual(lsinfo_result, listplaylists_result) - - def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): - lsinfo_result = self.dispatcher.handle_request(u'lsinfo ""') - listplaylists_result = self.dispatcher.handle_request(u'listplaylists') - self.assertEqual(lsinfo_result, listplaylists_result) - - def test_lsinfo_for_root_returns_same_as_listplaylists(self): - lsinfo_result = self.dispatcher.handle_request(u'lsinfo "/"') - listplaylists_result = self.dispatcher.handle_request(u'listplaylists') - self.assertEqual(lsinfo_result, listplaylists_result) - - def test_update_without_uri(self): - result = self.dispatcher.handle_request(u'update') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - def test_update_with_uri(self): - result = self.dispatcher.handle_request(u'update "file:///dev/urandom"') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - def test_rescan_without_uri(self): - result = self.dispatcher.handle_request(u'rescan') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - def test_rescan_with_uri(self): - result = self.dispatcher.handle_request(u'rescan "file:///dev/urandom"') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - -class MusicDatabaseFindTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_find_album(self): - result = self.dispatcher.handle_request(u'find "album" "what"') - self.assert_(u'OK' in result) - - def test_find_album_without_quotes(self): - result = self.dispatcher.handle_request(u'find album "what"') - self.assert_(u'OK' in result) - - def test_find_artist(self): - result = self.dispatcher.handle_request(u'find "artist" "what"') - self.assert_(u'OK' in result) - - def test_find_artist_without_quotes(self): - result = self.dispatcher.handle_request(u'find artist "what"') - self.assert_(u'OK' in result) - - def test_find_title(self): - result = self.dispatcher.handle_request(u'find "title" "what"') - self.assert_(u'OK' in result) - - def test_find_title_without_quotes(self): - result = self.dispatcher.handle_request(u'find title "what"') - self.assert_(u'OK' in result) - - def test_find_date(self): - result = self.dispatcher.handle_request(u'find "date" "2002-01-01"') - self.assert_(u'OK' in result) - - def test_find_date_without_quotes(self): - result = self.dispatcher.handle_request(u'find date "2002-01-01"') - self.assert_(u'OK' in result) - - def test_find_date_with_capital_d_and_incomplete_date(self): - result = self.dispatcher.handle_request(u'find Date "2005"') - self.assert_(u'OK' in result) - - def test_find_else_should_fail(self): - - result = self.dispatcher.handle_request(u'find "somethingelse" "what"') - self.assertEqual(result[0], u'ACK [2@0] {find} incorrect arguments') - - def test_find_album_and_artist(self): - result = self.dispatcher.handle_request( - u'find album "album_what" artist "artist_what"') - self.assert_(u'OK' in result) - - -class MusicDatabaseListTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_list_foo_returns_ack(self): - result = self.dispatcher.handle_request(u'list "foo"') - self.assertEqual(result[0], - u'ACK [2@0] {list} incorrect arguments') - - ### Artist - - def test_list_artist_with_quotes(self): - result = self.dispatcher.handle_request(u'list "artist"') - self.assert_(u'OK' in result) - - def test_list_artist_without_quotes(self): - result = self.dispatcher.handle_request(u'list artist') - self.assert_(u'OK' in result) - - def test_list_artist_without_quotes_and_capitalized(self): - result = self.dispatcher.handle_request(u'list Artist') - self.assert_(u'OK' in result) - - def test_list_artist_with_query_of_one_token(self): - result = self.dispatcher.handle_request(u'list "artist" "anartist"') - self.assertEqual(result[0], - u'ACK [2@0] {list} should be "Album" for 3 arguments') - - def test_list_artist_with_unknown_field_in_query_returns_ack(self): - result = self.dispatcher.handle_request(u'list "artist" "foo" "bar"') - self.assertEqual(result[0], - u'ACK [2@0] {list} not able to parse args') - - def test_list_artist_by_artist(self): - result = self.dispatcher.handle_request( - u'list "artist" "artist" "anartist"') - self.assert_(u'OK' in result) - - def test_list_artist_by_album(self): - result = self.dispatcher.handle_request( - u'list "artist" "album" "analbum"') - self.assert_(u'OK' in result) - - def test_list_artist_by_full_date(self): - result = self.dispatcher.handle_request( - u'list "artist" "date" "2001-01-01"') - self.assert_(u'OK' in result) - - def test_list_artist_by_year(self): - result = self.dispatcher.handle_request( - u'list "artist" "date" "2001"') - self.assert_(u'OK' in result) - - def test_list_artist_by_genre(self): - result = self.dispatcher.handle_request( - u'list "artist" "genre" "agenre"') - self.assert_(u'OK' in result) - - def test_list_artist_by_artist_and_album(self): - result = self.dispatcher.handle_request( - u'list "artist" "artist" "anartist" "album" "analbum"') - self.assert_(u'OK' in result) - - ### Album - - def test_list_album_with_quotes(self): - result = self.dispatcher.handle_request(u'list "album"') - self.assert_(u'OK' in result) - - def test_list_album_without_quotes(self): - result = self.dispatcher.handle_request(u'list album') - self.assert_(u'OK' in result) - - def test_list_album_without_quotes_and_capitalized(self): - result = self.dispatcher.handle_request(u'list Album') - self.assert_(u'OK' in result) - - def test_list_album_with_artist_name(self): - result = self.dispatcher.handle_request(u'list "album" "anartist"') - self.assert_(u'OK' in result) - - def test_list_album_by_artist(self): - result = self.dispatcher.handle_request( - u'list "album" "artist" "anartist"') - self.assert_(u'OK' in result) - - def test_list_album_by_album(self): - result = self.dispatcher.handle_request( - u'list "album" "album" "analbum"') - self.assert_(u'OK' in result) - - def test_list_album_by_full_date(self): - result = self.dispatcher.handle_request( - u'list "album" "date" "2001-01-01"') - self.assert_(u'OK' in result) - - def test_list_album_by_year(self): - result = self.dispatcher.handle_request( - u'list "album" "date" "2001"') - self.assert_(u'OK' in result) - - def test_list_album_by_genre(self): - result = self.dispatcher.handle_request( - u'list "album" "genre" "agenre"') - self.assert_(u'OK' in result) - - def test_list_album_by_artist_and_album(self): - result = self.dispatcher.handle_request( - u'list "album" "artist" "anartist" "album" "analbum"') - self.assert_(u'OK' in result) - - ### Date - - def test_list_date_with_quotes(self): - result = self.dispatcher.handle_request(u'list "date"') - self.assert_(u'OK' in result) - - def test_list_date_without_quotes(self): - result = self.dispatcher.handle_request(u'list date') - self.assert_(u'OK' in result) - - def test_list_date_without_quotes_and_capitalized(self): - result = self.dispatcher.handle_request(u'list Date') - self.assert_(u'OK' in result) - - def test_list_date_with_query_of_one_token(self): - result = self.dispatcher.handle_request(u'list "date" "anartist"') - self.assertEqual(result[0], - u'ACK [2@0] {list} should be "Album" for 3 arguments') - - def test_list_date_by_artist(self): - result = self.dispatcher.handle_request( - u'list "date" "artist" "anartist"') - self.assert_(u'OK' in result) - - def test_list_date_by_album(self): - result = self.dispatcher.handle_request( - u'list "date" "album" "analbum"') - self.assert_(u'OK' in result) - - def test_list_date_by_full_date(self): - result = self.dispatcher.handle_request( - u'list "date" "date" "2001-01-01"') - self.assert_(u'OK' in result) - - def test_list_date_by_year(self): - result = self.dispatcher.handle_request(u'list "date" "date" "2001"') - self.assert_(u'OK' in result) - - def test_list_date_by_genre(self): - result = self.dispatcher.handle_request(u'list "date" "genre" "agenre"') - self.assert_(u'OK' in result) - - def test_list_date_by_artist_and_album(self): - result = self.dispatcher.handle_request( - u'list "date" "artist" "anartist" "album" "analbum"') - self.assert_(u'OK' in result) - - ### Genre - - def test_list_genre_with_quotes(self): - result = self.dispatcher.handle_request(u'list "genre"') - self.assert_(u'OK' in result) - - def test_list_genre_without_quotes(self): - result = self.dispatcher.handle_request(u'list genre') - self.assert_(u'OK' in result) - - def test_list_genre_without_quotes_and_capitalized(self): - result = self.dispatcher.handle_request(u'list Genre') - self.assert_(u'OK' in result) - - def test_list_genre_with_query_of_one_token(self): - result = self.dispatcher.handle_request(u'list "genre" "anartist"') - self.assertEqual(result[0], - u'ACK [2@0] {list} should be "Album" for 3 arguments') - - def test_list_genre_by_artist(self): - result = self.dispatcher.handle_request( - u'list "genre" "artist" "anartist"') - self.assert_(u'OK' in result) - - def test_list_genre_by_album(self): - result = self.dispatcher.handle_request( - u'list "genre" "album" "analbum"') - self.assert_(u'OK' in result) - - def test_list_genre_by_full_date(self): - result = self.dispatcher.handle_request( - u'list "genre" "date" "2001-01-01"') - self.assert_(u'OK' in result) - - def test_list_genre_by_year(self): - result = self.dispatcher.handle_request( - u'list "genre" "date" "2001"') - self.assert_(u'OK' in result) - - def test_list_genre_by_genre(self): - result = self.dispatcher.handle_request( - u'list "genre" "genre" "agenre"') - self.assert_(u'OK' in result) - - def test_list_genre_by_artist_and_album(self): - result = self.dispatcher.handle_request( - u'list "genre" "artist" "anartist" "album" "analbum"') - self.assert_(u'OK' in result) - - -class MusicDatabaseSearchTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_search_album(self): - result = self.dispatcher.handle_request(u'search "album" "analbum"') - self.assert_(u'OK' in result) - - def test_search_album_without_quotes(self): - result = self.dispatcher.handle_request(u'search album "analbum"') - self.assert_(u'OK' in result) - - def test_search_artist(self): - result = self.dispatcher.handle_request(u'search "artist" "anartist"') - self.assert_(u'OK' in result) - - def test_search_artist_without_quotes(self): - result = self.dispatcher.handle_request(u'search artist "anartist"') - self.assert_(u'OK' in result) - - def test_search_filename(self): - result = self.dispatcher.handle_request( - u'search "filename" "afilename"') - self.assert_(u'OK' in result) - - def test_search_filename_without_quotes(self): - result = self.dispatcher.handle_request(u'search filename "afilename"') - self.assert_(u'OK' in result) - - def test_search_title(self): - result = self.dispatcher.handle_request(u'search "title" "atitle"') - self.assert_(u'OK' in result) - - def test_search_title_without_quotes(self): - result = self.dispatcher.handle_request(u'search title "atitle"') - self.assert_(u'OK' in result) - - def test_search_any(self): - result = self.dispatcher.handle_request(u'search "any" "anything"') - self.assert_(u'OK' in result) - - def test_search_any_without_quotes(self): - result = self.dispatcher.handle_request(u'search any "anything"') - self.assert_(u'OK' in result) - - def test_search_date(self): - result = self.dispatcher.handle_request(u'search "date" "2002-01-01"') - self.assert_(u'OK' in result) - - def test_search_date_without_quotes(self): - result = self.dispatcher.handle_request(u'search date "2002-01-01"') - self.assert_(u'OK' in result) - - def test_search_date_with_capital_d_and_incomplete_date(self): - result = self.dispatcher.handle_request(u'search Date "2005"') - self.assert_(u'OK' in result) - - def test_search_else_should_fail(self): - result = self.dispatcher.handle_request( - u'search "sometype" "something"') - self.assertEqual(result[0], u'ACK [2@0] {search} incorrect arguments') - - diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py new file mode 100644 index 00000000..b54906be --- /dev/null +++ b/tests/frontends/mpd/protocol/__init__.py @@ -0,0 +1,62 @@ +import mock + +from mopidy import settings +from mopidy.backends import dummy as backend +from mopidy.frontends import mpd +from mopidy.mixers import dummy as mixer + +from tests import unittest + + +class MockConnection(mock.Mock): + def __init__(self, *args, **kwargs): + super(MockConnection, self).__init__(*args, **kwargs) + self.host = mock.sentinel.host + self.port = mock.sentinel.port + self.response = [] + + def queue_send(self, data): + lines = (line for line in data.split('\n') if line) + self.response.extend(lines) + + +class BaseTestCase(unittest.TestCase): + def setUp(self): + self.backend = backend.DummyBackend.start().proxy() + self.mixer = mixer.DummyMixer.start().proxy() + + self.connection = MockConnection() + self.session = mpd.MpdSession(self.connection) + self.dispatcher = self.session.dispatcher + self.context = self.dispatcher.context + + def tearDown(self): + self.backend.stop().get() + self.mixer.stop().get() + settings.runtime.clear() + + def sendRequest(self, request): + self.connection.response = [] + request = '%s\n' % request.encode('utf-8') + self.session.on_receive({'received': request}) + return self.connection.response + + def assertNoResponse(self): + self.assertEqual([], self.connection.response) + + def assertInResponse(self, value): + self.assert_(value in self.connection.response, u'Did not find %s ' + 'in %s' % (repr(value), repr(self.connection.response))) + + def assertOnceInResponse(self, value): + matched = len([r for r in self.connection.response if r == value]) + self.assertEqual(1, matched, 'Expected to find %s once in %s' % + (repr(value), repr(self.connection.response))) + + def assertNotInResponse(self, value): + self.assert_(value not in self.connection.response, u'Found %s in %s' % + (repr(value), repr(self.connection.response))) + + def assertEqualResponse(self, value): + self.assertEqual(1, len(self.connection.response)) + self.assertEqual(value, self.connection.response[0]) diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py new file mode 100644 index 00000000..3bb8dce8 --- /dev/null +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -0,0 +1,18 @@ +from tests.frontends.mpd import protocol + + +class AudioOutputHandlerTest(protocol.BaseTestCase): + def test_enableoutput(self): + self.sendRequest(u'enableoutput "0"') + self.assertInResponse(u'ACK [0@0] {} Not implemented') + + def test_disableoutput(self): + self.sendRequest(u'disableoutput "0"') + self.assertInResponse(u'ACK [0@0] {} Not implemented') + + def test_outputs(self): + self.sendRequest(u'outputs') + self.assertInResponse(u'outputid: 0') + self.assertInResponse(u'outputname: None') + self.assertInResponse(u'outputenabled: 1') + self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/authentication_test.py b/tests/frontends/mpd/protocol/authentication_test.py similarity index 53% rename from tests/frontends/mpd/authentication_test.py rename to tests/frontends/mpd/protocol/authentication_test.py index d795d726..20422f5b 100644 --- a/tests/frontends/mpd/authentication_test.py +++ b/tests/frontends/mpd/protocol/authentication_test.py @@ -1,63 +1,62 @@ -import mock -import unittest - from mopidy import settings -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.frontends.mpd.session import MpdSession -class AuthenticationTest(unittest.TestCase): - def setUp(self): - self.session = mock.Mock(spec=MpdSession) - self.dispatcher = MpdDispatcher(session=self.session) +from tests.frontends.mpd import protocol - def tearDown(self): - settings.runtime.clear() +class AuthenticationTest(protocol.BaseTestCase): def test_authentication_with_valid_password_is_accepted(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'password "topsecret"') + + self.sendRequest(u'password "topsecret"') self.assertTrue(self.dispatcher.authenticated) - self.assert_(u'OK' in response) + self.assertInResponse(u'OK') def test_authentication_with_invalid_password_is_not_accepted(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'password "secret"') + + self.sendRequest(u'password "secret"') self.assertFalse(self.dispatcher.authenticated) - self.assert_(u'ACK [3@0] {password} incorrect password' in response) + self.assertEqualResponse(u'ACK [3@0] {password} incorrect password') def test_authentication_with_anything_when_password_check_turned_off(self): settings.MPD_SERVER_PASSWORD = None - response = self.dispatcher.handle_request(u'any request at all') + + self.sendRequest(u'any request at all') self.assertTrue(self.dispatcher.authenticated) - self.assert_('ACK [5@0] {} unknown command "any"' in response) + self.assertEqualResponse('ACK [5@0] {} unknown command "any"') def test_anything_when_not_authenticated_should_fail(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'any request at all') + + self.sendRequest(u'any request at all') self.assertFalse(self.dispatcher.authenticated) - self.assert_( - u'ACK [4@0] {any} you don\'t have permission for "any"' in response) + self.assertEqualResponse( + u'ACK [4@0] {any} you don\'t have permission for "any"') def test_close_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'close') + + self.sendRequest(u'close') self.assertFalse(self.dispatcher.authenticated) - self.assert_(u'OK' in response) + self.assertInResponse(u'OK') def test_commands_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'commands') + + self.sendRequest(u'commands') self.assertFalse(self.dispatcher.authenticated) - self.assert_(u'OK' in response) + self.assertInResponse(u'OK') def test_notcommands_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'notcommands') + + self.sendRequest(u'notcommands') self.assertFalse(self.dispatcher.authenticated) - self.assert_(u'OK' in response) + self.assertInResponse(u'OK') def test_ping_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'ping') + + self.sendRequest(u'ping') self.assertFalse(self.dispatcher.authenticated) - self.assert_(u'OK' in response) + self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/frontends/mpd/protocol/command_list_test.py new file mode 100644 index 00000000..a81725ad --- /dev/null +++ b/tests/frontends/mpd/protocol/command_list_test.py @@ -0,0 +1,54 @@ +from tests.frontends.mpd import protocol + + +class CommandListsTest(protocol.BaseTestCase): + def test_command_list_begin(self): + response = self.sendRequest(u'command_list_begin') + self.assertEquals([], response) + + def test_command_list_end(self): + self.sendRequest(u'command_list_begin') + self.sendRequest(u'command_list_end') + self.assertInResponse(u'OK') + + def test_command_list_end_without_start_first_is_an_unknown_command(self): + self.sendRequest(u'command_list_end') + self.assertEqualResponse( + u'ACK [5@0] {} unknown command "command_list_end"') + + def test_command_list_with_ping(self): + self.sendRequest(u'command_list_begin') + self.assertEqual([], self.dispatcher.command_list) + self.assertEqual(False, self.dispatcher.command_list_ok) + self.sendRequest(u'ping') + self.assert_(u'ping' in self.dispatcher.command_list) + self.sendRequest(u'command_list_end') + self.assertInResponse(u'OK') + self.assertEqual(False, self.dispatcher.command_list) + + def test_command_list_with_error_returns_ack_with_correct_index(self): + self.sendRequest(u'command_list_begin') + self.sendRequest(u'play') # Known command + self.sendRequest(u'paly') # Unknown command + self.sendRequest(u'command_list_end') + self.assertEqualResponse(u'ACK [5@1] {} unknown command "paly"') + + def test_command_list_ok_begin(self): + response = self.sendRequest(u'command_list_ok_begin') + self.assertEquals([], response) + + def test_command_list_ok_with_ping(self): + self.sendRequest(u'command_list_ok_begin') + self.assertEqual([], self.dispatcher.command_list) + self.assertEqual(True, self.dispatcher.command_list_ok) + self.sendRequest(u'ping') + self.assert_(u'ping' in self.dispatcher.command_list) + self.sendRequest(u'command_list_end') + self.assertInResponse(u'list_OK') + self.assertInResponse(u'OK') + self.assertEqual(False, self.dispatcher.command_list) + self.assertEqual(False, self.dispatcher.command_list_ok) + + # FIXME this should also include the special handling of idle within a + # command list. That is that once a idle/noidle command is found inside a + # commad list, the rest of the list seems to be ignored. diff --git a/tests/frontends/mpd/protocol/connection_test.py b/tests/frontends/mpd/protocol/connection_test.py new file mode 100644 index 00000000..cd08313f --- /dev/null +++ b/tests/frontends/mpd/protocol/connection_test.py @@ -0,0 +1,44 @@ +from mock import patch + +from mopidy import settings + +from tests.frontends.mpd import protocol + + +class ConnectionHandlerTest(protocol.BaseTestCase): + def test_close_closes_the_client_connection(self): + with patch.object(self.session, 'close') as close_mock: + response = self.sendRequest(u'close') + close_mock.assertEqualResponsecalled_once_with() + self.assertEqualResponse(u'OK') + + def test_empty_request(self): + self.sendRequest(u'') + self.assertEqualResponse(u'OK') + + self.sendRequest(u' ') + self.assertEqualResponse(u'OK') + + def test_kill(self): + self.sendRequest(u'kill') + self.assertEqualResponse( + u'ACK [4@0] {kill} you don\'t have permission for "kill"') + + def test_valid_password_is_accepted(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + self.sendRequest(u'password "topsecret"') + self.assertEqualResponse(u'OK') + + def test_invalid_password_is_not_accepted(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + self.sendRequest(u'password "secret"') + self.assertEqualResponse(u'ACK [3@0] {password} incorrect password') + + def test_any_password_is_not_accepted_when_password_check_turned_off(self): + settings.MPD_SERVER_PASSWORD = None + self.sendRequest(u'password "secret"') + self.assertEqualResponse(u'ACK [3@0] {password} incorrect password') + + def test_ping(self): + self.sendRequest(u'ping') + self.assertEqualResponse(u'OK') diff --git a/tests/frontends/mpd/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py similarity index 59% rename from tests/frontends/mpd/current_playlist_test.py rename to tests/frontends/mpd/protocol/current_playlist_test.py index c7f47429..343b230b 100644 --- a/tests/frontends/mpd/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -1,20 +1,9 @@ -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track -class CurrentPlaylistHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() +from tests.frontends.mpd import protocol - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() +class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_add(self): needle = Track(uri='dummy://foo') self.backend.library.provider.dummy_library = [ @@ -22,21 +11,21 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'add "dummy://foo"') - self.assertEqual(len(result), 1) - self.assertEqual(result[0], u'OK') + + self.sendRequest(u'add "dummy://foo"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) + self.assertEqualResponse(u'OK') def test_add_with_uri_not_found_in_library_should_ack(self): - result = self.dispatcher.handle_request(u'add "dummy://foo"') - self.assertEqual(result[0], + self.sendRequest(u'add "dummy://foo"') + self.assertEqualResponse( u'ACK [50@0] {add} directory or file not found') def test_add_with_empty_uri_should_add_all_known_tracks_and_ok(self): - result = self.dispatcher.handle_request(u'add ""') + self.sendRequest(u'add ""') # TODO check that we add all tracks (we currently don't) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_addid_without_songpos(self): needle = Track(uri='dummy://foo') @@ -45,16 +34,17 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'addid "dummy://foo"') + + self.sendRequest(u'addid "dummy://foo"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) - self.assert_(u'Id: %d' % - self.backend.current_playlist.cp_tracks.get()[5][0] in result) - self.assert_(u'OK' in result) + self.assertInResponse(u'Id: %d' % + self.backend.current_playlist.cp_tracks.get()[5][0]) + self.assertInResponse(u'OK') def test_addid_with_empty_uri_acks(self): - result = self.dispatcher.handle_request(u'addid ""') - self.assertEqual(result[0], u'ACK [50@0] {addid} No such song') + self.sendRequest(u'addid ""') + self.assertEqualResponse(u'ACK [50@0] {addid} No such song') def test_addid_with_songpos(self): needle = Track(uri='dummy://foo') @@ -63,12 +53,13 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'addid "dummy://foo" "3"') + + self.sendRequest(u'addid "dummy://foo" "3"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) self.assertEqual(self.backend.current_playlist.tracks.get()[3], needle) - self.assert_(u'Id: %d' % - self.backend.current_playlist.cp_tracks.get()[3][0] in result) - self.assert_(u'OK' in result) + self.assertInResponse(u'Id: %d' % + self.backend.current_playlist.cp_tracks.get()[3][0]) + self.assertInResponse(u'OK') def test_addid_with_songpos_out_of_bounds_should_ack(self): needle = Track(uri='dummy://foo') @@ -77,83 +68,93 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'addid "dummy://foo" "6"') - self.assertEqual(result[0], u'ACK [2@0] {addid} Bad song index') + + self.sendRequest(u'addid "dummy://foo" "6"') + self.assertEqualResponse(u'ACK [2@0] {addid} Bad song index') def test_addid_with_uri_not_found_in_library_should_ack(self): - result = self.dispatcher.handle_request(u'addid "dummy://foo"') - self.assertEqual(result[0], u'ACK [50@0] {addid} No such song') + self.sendRequest(u'addid "dummy://foo"') + self.assertEqualResponse(u'ACK [50@0] {addid} No such song') def test_clear(self): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'clear') + + self.sendRequest(u'clear') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 0) self.assertEqual(self.backend.playback.current_track.get(), None) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_delete_songpos(self): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'delete "%d"' % + + self.sendRequest(u'delete "%d"' % self.backend.current_playlist.cp_tracks.get()[2][0]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 4) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_delete_songpos_out_of_bounds(self): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'delete "5"') + + self.sendRequest(u'delete "5"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - self.assertEqual(result[0], u'ACK [2@0] {delete} Bad song index') + self.assertEqualResponse(u'ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'delete "1:"') + + self.sendRequest(u'delete "1:"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 1) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_delete_closed_range(self): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'delete "1:3"') + + self.sendRequest(u'delete "1:3"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 3) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_delete_range_out_of_bounds(self): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'delete "5:7"') + + self.sendRequest(u'delete "5:7"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - self.assertEqual(result[0], u'ACK [2@0] {delete} Bad song index') + self.assertEqualResponse(u'ACK [2@0] {delete} Bad song index') def test_deleteid(self): self.backend.current_playlist.append([Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) - result = self.dispatcher.handle_request(u'deleteid "1"') + + self.sendRequest(u'deleteid "1"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 1) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_deleteid_does_not_exist(self): self.backend.current_playlist.append([Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) - result = self.dispatcher.handle_request(u'deleteid "12345"') + + self.sendRequest(u'deleteid "12345"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) - self.assertEqual(result[0], u'ACK [50@0] {deleteid} No such song') + self.assertEqualResponse(u'ACK [50@0] {deleteid} No such song') def test_move_songpos(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'move "1" "0"') + + self.sendRequest(u'move "1" "0"') tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'a') @@ -161,14 +162,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') self.assertEqual(tracks[5].name, 'f') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_move_open_range(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'move "2:" "0"') + + self.sendRequest(u'move "2:" "0"') tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'c') self.assertEqual(tracks[1].name, 'd') @@ -176,14 +178,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(tracks[3].name, 'f') self.assertEqual(tracks[4].name, 'a') self.assertEqual(tracks[5].name, 'b') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_move_closed_range(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'move "1:3" "0"') + + self.sendRequest(u'move "1:3" "0"') tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'c') @@ -191,14 +194,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') self.assertEqual(tracks[5].name, 'f') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_moveid(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'moveid "4" "2"') + + self.sendRequest(u'moveid "4" "2"') tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') @@ -206,179 +210,182 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(tracks[3].name, 'c') self.assertEqual(tracks[4].name, 'd') self.assertEqual(tracks[5].name, 'f') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_playlist_returns_same_as_playlistinfo(self): - playlist_result = self.dispatcher.handle_request(u'playlist') - playlistinfo_result = self.dispatcher.handle_request(u'playlistinfo') - self.assertEqual(playlist_result, playlistinfo_result) + playlist_response = self.sendRequest(u'playlist') + playlistinfo_response = self.sendRequest(u'playlistinfo') + self.assertEqual(playlist_response, playlistinfo_response) def test_playlistfind(self): - result = self.dispatcher.handle_request(u'playlistfind "tag" "needle"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest(u'playlistfind "tag" "needle"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') def test_playlistfind_by_filename_not_in_current_playlist(self): - result = self.dispatcher.handle_request( - u'playlistfind "filename" "file:///dev/null"') - self.assertEqual(len(result), 1) - self.assert_(u'OK' in result) + self.sendRequest(u'playlistfind "filename" "file:///dev/null"') + self.assertEqualResponse(u'OK') def test_playlistfind_by_filename_without_quotes(self): - result = self.dispatcher.handle_request( - u'playlistfind filename "file:///dev/null"') - self.assertEqual(len(result), 1) - self.assert_(u'OK' in result) + self.sendRequest(u'playlistfind filename "file:///dev/null"') + self.assertEqualResponse(u'OK') def test_playlistfind_by_filename_in_current_playlist(self): self.backend.current_playlist.append([ Track(uri='file:///exists')]) - result = self.dispatcher.handle_request( - u'playlistfind filename "file:///exists"') - self.assert_(u'file: file:///exists' in result) - self.assert_(u'Id: 0' in result) - self.assert_(u'Pos: 0' in result) - self.assert_(u'OK' in result) + + self.sendRequest( u'playlistfind filename "file:///exists"') + self.assertInResponse(u'file: file:///exists') + self.assertInResponse(u'Id: 0') + self.assertInResponse(u'Pos: 0') + self.assertInResponse(u'OK') def test_playlistid_without_songid(self): self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) - result = self.dispatcher.handle_request(u'playlistid') - self.assert_(u'Title: a' in result) - self.assert_(u'Title: b' in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'playlistid') + self.assertInResponse(u'Title: a') + self.assertInResponse(u'Title: b') + self.assertInResponse(u'OK') def test_playlistid_with_songid(self): self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) - result = self.dispatcher.handle_request(u'playlistid "1"') - self.assert_(u'Title: a' not in result) - self.assert_(u'Id: 0' not in result) - self.assert_(u'Title: b' in result) - self.assert_(u'Id: 1' in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'playlistid "1"') + self.assertNotInResponse(u'Title: a') + self.assertNotInResponse(u'Id: 0') + self.assertInResponse(u'Title: b') + self.assertInResponse(u'Id: 1') + self.assertInResponse(u'OK') def test_playlistid_with_not_existing_songid_fails(self): self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) - result = self.dispatcher.handle_request(u'playlistid "25"') - self.assertEqual(result[0], u'ACK [50@0] {playlistid} No such song') + + self.sendRequest(u'playlistid "25"') + self.assertEqualResponse(u'ACK [50@0] {playlistid} No such song') def test_playlistinfo_without_songpos_or_range(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'playlistinfo') - self.assert_(u'Title: a' in result) - self.assert_(u'Title: b' in result) - self.assert_(u'Title: c' in result) - self.assert_(u'Title: d' in result) - self.assert_(u'Title: e' in result) - self.assert_(u'Title: f' in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'playlistinfo') + self.assertInResponse(u'Title: a') + self.assertInResponse(u'Title: b') + self.assertInResponse(u'Title: c') + self.assertInResponse(u'Title: d') + self.assertInResponse(u'Title: e') + self.assertInResponse(u'Title: f') + self.assertInResponse(u'OK') def test_playlistinfo_with_songpos(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'playlistinfo "4"') - self.assert_(u'Title: a' not in result) - self.assert_(u'Title: b' not in result) - self.assert_(u'Title: c' not in result) - self.assert_(u'Title: d' not in result) - self.assert_(u'Title: e' in result) - self.assert_(u'Title: f' not in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'playlistinfo "4"') + self.assertNotInResponse(u'Title: a') + self.assertNotInResponse(u'Title: b') + self.assertNotInResponse(u'Title: c') + self.assertNotInResponse(u'Title: d') + self.assertInResponse(u'Title: e') + self.assertNotInResponse(u'Title: f') + self.assertInResponse(u'OK') def test_playlistinfo_with_negative_songpos_same_as_playlistinfo(self): - result1 = self.dispatcher.handle_request(u'playlistinfo "-1"') - result2 = self.dispatcher.handle_request(u'playlistinfo') - self.assertEqual(result1, result2) + response1 = self.sendRequest(u'playlistinfo "-1"') + response2 = self.sendRequest(u'playlistinfo') + self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'playlistinfo "2:"') - self.assert_(u'Title: a' not in result) - self.assert_(u'Title: b' not in result) - self.assert_(u'Title: c' in result) - self.assert_(u'Title: d' in result) - self.assert_(u'Title: e' in result) - self.assert_(u'Title: f' in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'playlistinfo "2:"') + self.assertNotInResponse(u'Title: a') + self.assertNotInResponse(u'Title: b') + self.assertInResponse(u'Title: c') + self.assertInResponse(u'Title: d') + self.assertInResponse(u'Title: e') + self.assertInResponse(u'Title: f') + self.assertInResponse(u'OK') def test_playlistinfo_with_closed_range(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'playlistinfo "2:4"') - self.assert_(u'Title: a' not in result) - self.assert_(u'Title: b' not in result) - self.assert_(u'Title: c' in result) - self.assert_(u'Title: d' in result) - self.assert_(u'Title: e' not in result) - self.assert_(u'Title: f' not in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'playlistinfo "2:4"') + self.assertNotInResponse(u'Title: a') + self.assertNotInResponse(u'Title: b') + self.assertInResponse(u'Title: c') + self.assertInResponse(u'Title: d') + self.assertNotInResponse(u'Title: e') + self.assertNotInResponse(u'Title: f') + self.assertInResponse(u'OK') def test_playlistinfo_with_too_high_start_of_range_returns_arg_error(self): - result = self.dispatcher.handle_request(u'playlistinfo "10:20"') - self.assert_(u'ACK [2@0] {playlistinfo} Bad song index' in result) + self.sendRequest(u'playlistinfo "10:20"') + self.assertEqualResponse(u'ACK [2@0] {playlistinfo} Bad song index') def test_playlistinfo_with_too_high_end_of_range_returns_ok(self): - result = self.dispatcher.handle_request(u'playlistinfo "0:20"') - self.assert_(u'OK' in result) + self.sendRequest(u'playlistinfo "0:20"') + self.assertInResponse(u'OK') def test_playlistsearch(self): - result = self.dispatcher.handle_request( - u'playlistsearch "any" "needle"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest( u'playlistsearch "any" "needle"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') def test_playlistsearch_without_quotes(self): - result = self.dispatcher.handle_request(u'playlistsearch any "needle"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest(u'playlistsearch any "needle"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') def test_plchanges(self): self.backend.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - result = self.dispatcher.handle_request(u'plchanges "0"') - self.assert_(u'Title: a' in result) - self.assert_(u'Title: b' in result) - self.assert_(u'Title: c' in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'plchanges "0"') + self.assertInResponse(u'Title: a') + self.assertInResponse(u'Title: b') + self.assertInResponse(u'Title: c') + self.assertInResponse(u'OK') def test_plchanges_with_minus_one_returns_entire_playlist(self): self.backend.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - result = self.dispatcher.handle_request(u'plchanges "-1"') - self.assert_(u'Title: a' in result) - self.assert_(u'Title: b' in result) - self.assert_(u'Title: c' in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'plchanges "-1"') + self.assertInResponse(u'Title: a') + self.assertInResponse(u'Title: b') + self.assertInResponse(u'Title: c') + self.assertInResponse(u'OK') def test_plchanges_without_quotes_works(self): self.backend.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - result = self.dispatcher.handle_request(u'plchanges 0') - self.assert_(u'Title: a' in result) - self.assert_(u'Title: b' in result) - self.assert_(u'Title: c' in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'plchanges 0') + self.assertInResponse(u'Title: a') + self.assertInResponse(u'Title: b') + self.assertInResponse(u'Title: c') + self.assertInResponse(u'OK') def test_plchangesposid(self): self.backend.current_playlist.append([Track(), Track(), Track()]) - result = self.dispatcher.handle_request(u'plchangesposid "0"') + + self.sendRequest(u'plchangesposid "0"') cp_tracks = self.backend.current_playlist.cp_tracks.get() - self.assert_(u'cpos: 0' in result) - self.assert_(u'Id: %d' % cp_tracks[0][0] - in result) - self.assert_(u'cpos: 2' in result) - self.assert_(u'Id: %d' % cp_tracks[1][0] - in result) - self.assert_(u'cpos: 2' in result) - self.assert_(u'Id: %d' % cp_tracks[2][0] - in result) - self.assert_(u'OK' in result) + self.assertInResponse(u'cpos: 0') + self.assertInResponse(u'Id: %d' % cp_tracks[0][0]) + self.assertInResponse(u'cpos: 2') + self.assertInResponse(u'Id: %d' % cp_tracks[1][0]) + self.assertInResponse(u'cpos: 2') + self.assertInResponse(u'Id: %d' % cp_tracks[2][0]) + self.assertInResponse(u'OK') def test_shuffle_without_range(self): self.backend.current_playlist.append([ @@ -386,9 +393,10 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) version = self.backend.current_playlist.version.get() - result = self.dispatcher.handle_request(u'shuffle') + + self.sendRequest(u'shuffle') self.assert_(version < self.backend.current_playlist.version.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_shuffle_with_open_range(self): self.backend.current_playlist.append([ @@ -396,14 +404,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) version = self.backend.current_playlist.version.get() - result = self.dispatcher.handle_request(u'shuffle "4:"') + + self.sendRequest(u'shuffle "4:"') self.assert_(version < self.backend.current_playlist.version.get()) tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'c') self.assertEqual(tracks[3].name, 'd') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_shuffle_with_closed_range(self): self.backend.current_playlist.append([ @@ -411,21 +420,23 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) version = self.backend.current_playlist.version.get() - result = self.dispatcher.handle_request(u'shuffle "1:3"') + + self.sendRequest(u'shuffle "1:3"') self.assert_(version < self.backend.current_playlist.version.get()) tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') self.assertEqual(tracks[5].name, 'f') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_swap(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'swap "1" "4"') + + self.sendRequest(u'swap "1" "4"') tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') @@ -433,14 +444,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'b') self.assertEqual(tracks[5].name, 'f') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_swapid(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'swapid "1" "4"') + + self.sendRequest(u'swapid "1" "4"') tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') @@ -448,4 +460,4 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'b') self.assertEqual(tracks[5].name, 'f') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/idle_test.py b/tests/frontends/mpd/protocol/idle_test.py new file mode 100644 index 00000000..ae23c88e --- /dev/null +++ b/tests/frontends/mpd/protocol/idle_test.py @@ -0,0 +1,206 @@ +from mock import patch + +from mopidy.frontends.mpd.protocol.status import SUBSYSTEMS + +from tests.frontends.mpd import protocol + + +class IdleHandlerTest(protocol.BaseTestCase): + def idleEvent(self, subsystem): + self.session.on_idle(subsystem) + + def assertEqualEvents(self, events): + self.assertEqual(set(events), self.context.events) + + def assertEqualSubscriptions(self, events): + self.assertEqual(set(events), self.context.subscriptions) + + def assertNoEvents(self): + self.assertEqualEvents([]) + + def assertNoSubscriptions(self): + self.assertEqualSubscriptions([]) + + def test_base_state(self): + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertNoResponse() + + def test_idle(self): + self.sendRequest(u'idle') + self.assertEqualSubscriptions(SUBSYSTEMS) + self.assertNoEvents() + self.assertNoResponse() + + def test_idle_disables_timeout(self): + self.sendRequest(u'idle') + self.connection.disable_timeout.assert_called_once_with() + + def test_noidle(self): + self.sendRequest(u'noidle') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertNoResponse() + + def test_idle_player(self): + self.sendRequest(u'idle player') + self.assertEqualSubscriptions(['player']) + self.assertNoEvents() + self.assertNoResponse() + + def test_idle_player_playlist(self): + self.sendRequest(u'idle player playlist') + self.assertEqualSubscriptions(['player', 'playlist']) + self.assertNoEvents() + self.assertNoResponse() + + def test_idle_then_noidle(self): + self.sendRequest(u'idle') + self.sendRequest(u'noidle') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse(u'OK') + + def test_idle_then_noidle_enables_timeout(self): + self.sendRequest(u'idle') + self.sendRequest(u'noidle') + self.connection.enable_timeout.assert_called_once_with() + + def test_idle_then_play(self): + with patch.object(self.session, 'stop') as stop_mock: + self.sendRequest(u'idle') + self.sendRequest(u'play') + stop_mock.assert_called_once_with() + + def test_idle_then_idle(self): + with patch.object(self.session, 'stop') as stop_mock: + self.sendRequest(u'idle') + self.sendRequest(u'idle') + stop_mock.assert_called_once_with() + + def test_idle_player_then_play(self): + with patch.object(self.session, 'stop') as stop_mock: + self.sendRequest(u'idle player') + self.sendRequest(u'play') + stop_mock.assert_called_once_with() + + def test_idle_then_player(self): + self.sendRequest(u'idle') + self.idleEvent(u'player') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse(u'changed: player') + self.assertOnceInResponse(u'OK') + + def test_idle_player_then_event_player(self): + self.sendRequest(u'idle player') + self.idleEvent(u'player') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse(u'changed: player') + self.assertOnceInResponse(u'OK') + + def test_idle_player_then_noidle(self): + self.sendRequest(u'idle player') + self.sendRequest(u'noidle') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse(u'OK') + + def test_idle_player_playlist_then_noidle(self): + self.sendRequest(u'idle player playlist') + self.sendRequest(u'noidle') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'OK') + + def test_idle_player_playlist_then_player(self): + self.sendRequest(u'idle player playlist') + self.idleEvent(u'player') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'changed: player') + self.assertNotInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') + + def test_idle_playlist_then_player(self): + self.sendRequest(u'idle playlist') + self.idleEvent(u'player') + self.assertEqualEvents(['player']) + self.assertEqualSubscriptions(['playlist']) + self.assertNoResponse() + + def test_idle_playlist_then_player_then_playlist(self): + self.sendRequest(u'idle playlist') + self.idleEvent(u'player') + self.idleEvent(u'playlist') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertNotInResponse(u'changed: player') + self.assertOnceInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') + + def test_player(self): + self.idleEvent(u'player') + self.assertEqualEvents(['player']) + self.assertNoSubscriptions() + self.assertNoResponse() + + def test_player_then_idle_player(self): + self.idleEvent(u'player') + self.sendRequest(u'idle player') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'changed: player') + self.assertNotInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') + + def test_player_then_playlist(self): + self.idleEvent(u'player') + self.idleEvent(u'playlist') + self.assertEqualEvents(['player', 'playlist']) + self.assertNoSubscriptions() + self.assertNoResponse() + + def test_player_then_idle(self): + self.idleEvent(u'player') + self.sendRequest(u'idle') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'changed: player') + self.assertOnceInResponse(u'OK') + + def test_player_then_playlist_then_idle(self): + self.idleEvent(u'player') + self.idleEvent(u'playlist') + self.sendRequest(u'idle') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'changed: player') + self.assertOnceInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') + + def test_player_then_idle_playlist(self): + self.idleEvent(u'player') + self.sendRequest(u'idle playlist') + self.assertEqualEvents(['player']) + self.assertEqualSubscriptions(['playlist']) + self.assertNoResponse() + + def test_player_then_idle_playlist_then_noidle(self): + self.idleEvent(u'player') + self.sendRequest(u'idle playlist') + self.sendRequest(u'noidle') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'OK') + + def test_player_then_playlist_then_idle_playlist(self): + self.idleEvent(u'player') + self.idleEvent(u'playlist') + self.sendRequest(u'idle playlist') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertNotInResponse(u'changed: player') + self.assertOnceInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py new file mode 100644 index 00000000..088502c4 --- /dev/null +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -0,0 +1,344 @@ +from tests.frontends.mpd import protocol + + +class MusicDatabaseHandlerTest(protocol.BaseTestCase): + def test_count(self): + self.sendRequest(u'count "tag" "needle"') + self.assertInResponse(u'songs: 0') + self.assertInResponse(u'playtime: 0') + self.assertInResponse(u'OK') + + def test_findadd(self): + self.sendRequest(u'findadd "album" "what"') + self.assertInResponse(u'OK') + + def test_listall(self): + self.sendRequest(u'listall "file:///dev/urandom"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_listallinfo(self): + self.sendRequest(u'listallinfo "file:///dev/urandom"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_lsinfo_without_path_returns_same_as_listplaylists(self): + lsinfo_response = self.sendRequest(u'lsinfo') + listplaylists_response = self.sendRequest(u'listplaylists') + self.assertEqual(lsinfo_response, listplaylists_response) + + def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): + lsinfo_response = self.sendRequest(u'lsinfo ""') + listplaylists_response = self.sendRequest(u'listplaylists') + self.assertEqual(lsinfo_response, listplaylists_response) + + def test_lsinfo_for_root_returns_same_as_listplaylists(self): + lsinfo_response = self.sendRequest(u'lsinfo "/"') + listplaylists_response = self.sendRequest(u'listplaylists') + self.assertEqual(lsinfo_response, listplaylists_response) + + def test_update_without_uri(self): + self.sendRequest(u'update') + self.assertInResponse(u'updating_db: 0') + self.assertInResponse(u'OK') + + def test_update_with_uri(self): + self.sendRequest(u'update "file:///dev/urandom"') + self.assertInResponse(u'updating_db: 0') + self.assertInResponse(u'OK') + + def test_rescan_without_uri(self): + self.sendRequest(u'rescan') + self.assertInResponse(u'updating_db: 0') + self.assertInResponse(u'OK') + + def test_rescan_with_uri(self): + self.sendRequest(u'rescan "file:///dev/urandom"') + self.assertInResponse(u'updating_db: 0') + self.assertInResponse(u'OK') + + +class MusicDatabaseFindTest(protocol.BaseTestCase): + def test_find_album(self): + self.sendRequest(u'find "album" "what"') + self.assertInResponse(u'OK') + + def test_find_album_without_quotes(self): + self.sendRequest(u'find album "what"') + self.assertInResponse(u'OK') + + def test_find_artist(self): + self.sendRequest(u'find "artist" "what"') + self.assertInResponse(u'OK') + + def test_find_artist_without_quotes(self): + self.sendRequest(u'find artist "what"') + self.assertInResponse(u'OK') + + def test_find_title(self): + self.sendRequest(u'find "title" "what"') + self.assertInResponse(u'OK') + + def test_find_title_without_quotes(self): + self.sendRequest(u'find title "what"') + self.assertInResponse(u'OK') + + def test_find_date(self): + self.sendRequest(u'find "date" "2002-01-01"') + self.assertInResponse(u'OK') + + def test_find_date_without_quotes(self): + self.sendRequest(u'find date "2002-01-01"') + self.assertInResponse(u'OK') + + def test_find_date_with_capital_d_and_incomplete_date(self): + self.sendRequest(u'find Date "2005"') + self.assertInResponse(u'OK') + + def test_find_else_should_fail(self): + self.sendRequest(u'find "somethingelse" "what"') + self.assertEqualResponse(u'ACK [2@0] {find} incorrect arguments') + + def test_find_album_and_artist(self): + self.sendRequest(u'find album "album_what" artist "artist_what"') + self.assertInResponse(u'OK') + + +class MusicDatabaseListTest(protocol.BaseTestCase): + def test_list_foo_returns_ack(self): + self.sendRequest(u'list "foo"') + self.assertEqualResponse(u'ACK [2@0] {list} incorrect arguments') + + ### Artist + + def test_list_artist_with_quotes(self): + self.sendRequest(u'list "artist"') + self.assertInResponse(u'OK') + + def test_list_artist_without_quotes(self): + self.sendRequest(u'list artist') + self.assertInResponse(u'OK') + + def test_list_artist_without_quotes_and_capitalized(self): + self.sendRequest(u'list Artist') + self.assertInResponse(u'OK') + + def test_list_artist_with_query_of_one_token(self): + self.sendRequest(u'list "artist" "anartist"') + self.assertEqualResponse( + u'ACK [2@0] {list} should be "Album" for 3 arguments') + + def test_list_artist_with_unknown_field_in_query_returns_ack(self): + self.sendRequest(u'list "artist" "foo" "bar"') + self.assertEqualResponse(u'ACK [2@0] {list} not able to parse args') + + def test_list_artist_by_artist(self): + self.sendRequest(u'list "artist" "artist" "anartist"') + self.assertInResponse(u'OK') + + def test_list_artist_by_album(self): + self.sendRequest(u'list "artist" "album" "analbum"') + self.assertInResponse(u'OK') + + def test_list_artist_by_full_date(self): + self.sendRequest(u'list "artist" "date" "2001-01-01"') + self.assertInResponse(u'OK') + + def test_list_artist_by_year(self): + self.sendRequest(u'list "artist" "date" "2001"') + self.assertInResponse(u'OK') + + def test_list_artist_by_genre(self): + self.sendRequest(u'list "artist" "genre" "agenre"') + self.assertInResponse(u'OK') + + def test_list_artist_by_artist_and_album(self): + self.sendRequest( + u'list "artist" "artist" "anartist" "album" "analbum"') + self.assertInResponse(u'OK') + + ### Album + + def test_list_album_with_quotes(self): + self.sendRequest(u'list "album"') + self.assertInResponse(u'OK') + + def test_list_album_without_quotes(self): + self.sendRequest(u'list album') + self.assertInResponse(u'OK') + + def test_list_album_without_quotes_and_capitalized(self): + self.sendRequest(u'list Album') + self.assertInResponse(u'OK') + + def test_list_album_with_artist_name(self): + self.sendRequest(u'list "album" "anartist"') + self.assertInResponse(u'OK') + + def test_list_album_by_artist(self): + self.sendRequest(u'list "album" "artist" "anartist"') + self.assertInResponse(u'OK') + + def test_list_album_by_album(self): + self.sendRequest(u'list "album" "album" "analbum"') + self.assertInResponse(u'OK') + + def test_list_album_by_full_date(self): + self.sendRequest(u'list "album" "date" "2001-01-01"') + self.assertInResponse(u'OK') + + def test_list_album_by_year(self): + self.sendRequest(u'list "album" "date" "2001"') + self.assertInResponse(u'OK') + + def test_list_album_by_genre(self): + self.sendRequest(u'list "album" "genre" "agenre"') + self.assertInResponse(u'OK') + + def test_list_album_by_artist_and_album(self): + self.sendRequest( + u'list "album" "artist" "anartist" "album" "analbum"') + self.assertInResponse(u'OK') + + ### Date + + def test_list_date_with_quotes(self): + self.sendRequest(u'list "date"') + self.assertInResponse(u'OK') + + def test_list_date_without_quotes(self): + self.sendRequest(u'list date') + self.assertInResponse(u'OK') + + def test_list_date_without_quotes_and_capitalized(self): + self.sendRequest(u'list Date') + self.assertInResponse(u'OK') + + def test_list_date_with_query_of_one_token(self): + self.sendRequest(u'list "date" "anartist"') + self.assertEqualResponse( + u'ACK [2@0] {list} should be "Album" for 3 arguments') + + def test_list_date_by_artist(self): + self.sendRequest(u'list "date" "artist" "anartist"') + self.assertInResponse(u'OK') + + def test_list_date_by_album(self): + self.sendRequest(u'list "date" "album" "analbum"') + self.assertInResponse(u'OK') + + def test_list_date_by_full_date(self): + self.sendRequest(u'list "date" "date" "2001-01-01"') + self.assertInResponse(u'OK') + + def test_list_date_by_year(self): + self.sendRequest(u'list "date" "date" "2001"') + self.assertInResponse(u'OK') + + def test_list_date_by_genre(self): + self.sendRequest(u'list "date" "genre" "agenre"') + self.assertInResponse(u'OK') + + def test_list_date_by_artist_and_album(self): + self.sendRequest(u'list "date" "artist" "anartist" "album" "analbum"') + self.assertInResponse(u'OK') + + ### Genre + + def test_list_genre_with_quotes(self): + self.sendRequest(u'list "genre"') + self.assertInResponse(u'OK') + + def test_list_genre_without_quotes(self): + self.sendRequest(u'list genre') + self.assertInResponse(u'OK') + + def test_list_genre_without_quotes_and_capitalized(self): + self.sendRequest(u'list Genre') + self.assertInResponse(u'OK') + + def test_list_genre_with_query_of_one_token(self): + self.sendRequest(u'list "genre" "anartist"') + self.assertEqualResponse( + u'ACK [2@0] {list} should be "Album" for 3 arguments') + + def test_list_genre_by_artist(self): + self.sendRequest(u'list "genre" "artist" "anartist"') + self.assertInResponse(u'OK') + + def test_list_genre_by_album(self): + self.sendRequest(u'list "genre" "album" "analbum"') + self.assertInResponse(u'OK') + + def test_list_genre_by_full_date(self): + self.sendRequest(u'list "genre" "date" "2001-01-01"') + self.assertInResponse(u'OK') + + def test_list_genre_by_year(self): + self.sendRequest(u'list "genre" "date" "2001"') + self.assertInResponse(u'OK') + + def test_list_genre_by_genre(self): + self.sendRequest(u'list "genre" "genre" "agenre"') + self.assertInResponse(u'OK') + + def test_list_genre_by_artist_and_album(self): + self.sendRequest( + u'list "genre" "artist" "anartist" "album" "analbum"') + self.assertInResponse(u'OK') + + +class MusicDatabaseSearchTest(protocol.BaseTestCase): + def test_search_album(self): + self.sendRequest(u'search "album" "analbum"') + self.assertInResponse(u'OK') + + def test_search_album_without_quotes(self): + self.sendRequest(u'search album "analbum"') + self.assertInResponse(u'OK') + + def test_search_artist(self): + self.sendRequest(u'search "artist" "anartist"') + self.assertInResponse(u'OK') + + def test_search_artist_without_quotes(self): + self.sendRequest(u'search artist "anartist"') + self.assertInResponse(u'OK') + + def test_search_filename(self): + self.sendRequest(u'search "filename" "afilename"') + self.assertInResponse(u'OK') + + def test_search_filename_without_quotes(self): + self.sendRequest(u'search filename "afilename"') + self.assertInResponse(u'OK') + + def test_search_title(self): + self.sendRequest(u'search "title" "atitle"') + self.assertInResponse(u'OK') + + def test_search_title_without_quotes(self): + self.sendRequest(u'search title "atitle"') + self.assertInResponse(u'OK') + + def test_search_any(self): + self.sendRequest(u'search "any" "anything"') + self.assertInResponse(u'OK') + + def test_search_any_without_quotes(self): + self.sendRequest(u'search any "anything"') + self.assertInResponse(u'OK') + + def test_search_date(self): + self.sendRequest(u'search "date" "2002-01-01"') + self.assertInResponse(u'OK') + + def test_search_date_without_quotes(self): + self.sendRequest(u'search date "2002-01-01"') + self.assertInResponse(u'OK') + + def test_search_date_with_capital_d_and_incomplete_date(self): + self.sendRequest(u'search Date "2005"') + self.assertInResponse(u'OK') + + def test_search_else_should_fail(self): + self.sendRequest(u'search "sometype" "something"') + self.assertEqualResponse(u'ACK [2@0] {search} incorrect arguments') diff --git a/tests/frontends/mpd/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py similarity index 53% rename from tests/frontends/mpd/playback_test.py rename to tests/frontends/mpd/protocol/playback_test.py index e80943d6..01658f6d 100644 --- a/tests/frontends/mpd/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -1,247 +1,238 @@ -import unittest - -from mopidy.backends.base import PlaybackController -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer +from mopidy.backends import base as backend from mopidy.models import Track -from tests import SkipTest +from tests import unittest +from tests.frontends.mpd import protocol -PAUSED = PlaybackController.PAUSED -PLAYING = PlaybackController.PLAYING -STOPPED = PlaybackController.STOPPED +PAUSED = backend.PlaybackController.PAUSED +PLAYING = backend.PlaybackController.PLAYING +STOPPED = backend.PlaybackController.STOPPED -class PlaybackOptionsHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() +class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): - result = self.dispatcher.handle_request(u'consume "0"') + self.sendRequest(u'consume "0"') self.assertFalse(self.backend.playback.consume.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_consume_off_without_quotes(self): - result = self.dispatcher.handle_request(u'consume 0') + self.sendRequest(u'consume 0') self.assertFalse(self.backend.playback.consume.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_consume_on(self): - result = self.dispatcher.handle_request(u'consume "1"') + self.sendRequest(u'consume "1"') self.assertTrue(self.backend.playback.consume.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_consume_on_without_quotes(self): - result = self.dispatcher.handle_request(u'consume 1') + self.sendRequest(u'consume 1') self.assertTrue(self.backend.playback.consume.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_crossfade(self): - result = self.dispatcher.handle_request(u'crossfade "10"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest(u'crossfade "10"') + self.assertInResponse(u'ACK [0@0] {} Not implemented') def test_random_off(self): - result = self.dispatcher.handle_request(u'random "0"') + self.sendRequest(u'random "0"') self.assertFalse(self.backend.playback.random.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_random_off_without_quotes(self): - result = self.dispatcher.handle_request(u'random 0') + self.sendRequest(u'random 0') self.assertFalse(self.backend.playback.random.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_random_on(self): - result = self.dispatcher.handle_request(u'random "1"') + self.sendRequest(u'random "1"') self.assertTrue(self.backend.playback.random.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_random_on_without_quotes(self): - result = self.dispatcher.handle_request(u'random 1') + self.sendRequest(u'random 1') self.assertTrue(self.backend.playback.random.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_repeat_off(self): - result = self.dispatcher.handle_request(u'repeat "0"') + self.sendRequest(u'repeat "0"') self.assertFalse(self.backend.playback.repeat.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_repeat_off_without_quotes(self): - result = self.dispatcher.handle_request(u'repeat 0') + self.sendRequest(u'repeat 0') self.assertFalse(self.backend.playback.repeat.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_repeat_on(self): - result = self.dispatcher.handle_request(u'repeat "1"') + self.sendRequest(u'repeat "1"') self.assertTrue(self.backend.playback.repeat.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_repeat_on_without_quotes(self): - result = self.dispatcher.handle_request(u'repeat 1') + self.sendRequest(u'repeat 1') self.assertTrue(self.backend.playback.repeat.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_setvol_below_min(self): - result = self.dispatcher.handle_request(u'setvol "-10"') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol "-10"') self.assertEqual(0, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_setvol_min(self): - result = self.dispatcher.handle_request(u'setvol "0"') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol "0"') self.assertEqual(0, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_setvol_middle(self): - result = self.dispatcher.handle_request(u'setvol "50"') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol "50"') self.assertEqual(50, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_setvol_max(self): - result = self.dispatcher.handle_request(u'setvol "100"') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol "100"') self.assertEqual(100, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_setvol_above_max(self): - result = self.dispatcher.handle_request(u'setvol "110"') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol "110"') self.assertEqual(100, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_setvol_plus_is_ignored(self): - result = self.dispatcher.handle_request(u'setvol "+10"') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol "+10"') self.assertEqual(10, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_setvol_without_quotes(self): - result = self.dispatcher.handle_request(u'setvol 50') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol 50') self.assertEqual(50, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_single_off(self): - result = self.dispatcher.handle_request(u'single "0"') + self.sendRequest(u'single "0"') self.assertFalse(self.backend.playback.single.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_single_off_without_quotes(self): - result = self.dispatcher.handle_request(u'single 0') + self.sendRequest(u'single 0') self.assertFalse(self.backend.playback.single.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_single_on(self): - result = self.dispatcher.handle_request(u'single "1"') + self.sendRequest(u'single "1"') self.assertTrue(self.backend.playback.single.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_single_on_without_quotes(self): - result = self.dispatcher.handle_request(u'single 1') + self.sendRequest(u'single 1') self.assertTrue(self.backend.playback.single.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_replay_gain_mode_off(self): - result = self.dispatcher.handle_request(u'replay_gain_mode "off"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest(u'replay_gain_mode "off"') + self.assertInResponse(u'ACK [0@0] {} Not implemented') def test_replay_gain_mode_track(self): - result = self.dispatcher.handle_request(u'replay_gain_mode "track"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest(u'replay_gain_mode "track"') + self.assertInResponse(u'ACK [0@0] {} Not implemented') def test_replay_gain_mode_album(self): - result = self.dispatcher.handle_request(u'replay_gain_mode "album"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest(u'replay_gain_mode "album"') + self.assertInResponse(u'ACK [0@0] {} Not implemented') def test_replay_gain_status_default(self): - expected = u'off' - result = self.dispatcher.handle_request(u'replay_gain_status') - self.assert_(u'OK' in result) - self.assert_(expected in result) + self.sendRequest(u'replay_gain_status') + self.assertInResponse(u'OK') + self.assertInResponse(u'off') + @unittest.SkipTest def test_replay_gain_status_off(self): - raise SkipTest # TODO + pass + @unittest.SkipTest def test_replay_gain_status_track(self): - raise SkipTest # TODO + pass + @unittest.SkipTest def test_replay_gain_status_album(self): - raise SkipTest # TODO + pass -class PlaybackControlHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - +class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_next(self): - result = self.dispatcher.handle_request(u'next') - self.assert_(u'OK' in result) + self.sendRequest(u'next') + self.assertInResponse(u'OK') def test_pause_off(self): self.backend.current_playlist.append([Track()]) - self.dispatcher.handle_request(u'play "0"') - self.dispatcher.handle_request(u'pause "1"') - result = self.dispatcher.handle_request(u'pause "0"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "0"') + self.sendRequest(u'pause "1"') + self.sendRequest(u'pause "0"') self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_pause_on(self): self.backend.current_playlist.append([Track()]) - self.dispatcher.handle_request(u'play "0"') - result = self.dispatcher.handle_request(u'pause "1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "0"') + self.sendRequest(u'pause "1"') self.assertEqual(PAUSED, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_pause_toggle(self): self.backend.current_playlist.append([Track()]) - result = self.dispatcher.handle_request(u'play "0"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "0"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - result = self.dispatcher.handle_request(u'pause') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') + + self.sendRequest(u'pause') self.assertEqual(PAUSED, self.backend.playback.state.get()) - result = self.dispatcher.handle_request(u'pause') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') + + self.sendRequest(u'pause') self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_play_without_pos(self): self.backend.current_playlist.append([Track()]) self.backend.playback.state = PAUSED - result = self.dispatcher.handle_request(u'play') - self.assert_(u'OK' in result) + + self.sendRequest(u'play') self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_play_with_pos(self): self.backend.current_playlist.append([Track()]) - result = self.dispatcher.handle_request(u'play "0"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "0"') self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_play_with_pos_without_quotes(self): self.backend.current_playlist.append([Track()]) - result = self.dispatcher.handle_request(u'play 0') - self.assert_(u'OK' in result) + + self.sendRequest(u'play 0') self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_play_with_pos_out_of_bounds(self): self.backend.current_playlist.append([]) - result = self.dispatcher.handle_request(u'play "0"') - self.assertEqual(result[0], u'ACK [2@0] {play} Bad song index') + + self.sendRequest(u'play "0"') self.assertEqual(STOPPED, self.backend.playback.state.get()) + self.assertInResponse(u'ACK [2@0] {play} Bad song index') def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.backend.playback.current_track.get(), None) self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - result = self.dispatcher.handle_request(u'play "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual(self.backend.playback.current_track.get().uri, 'a') + self.assertEqual('a', self.backend.playback.current_track.get().uri) + self.assertInResponse(u'OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) @@ -250,27 +241,30 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.backend.playback.next() self.backend.playback.stop() self.assertNotEqual(self.backend.playback.current_track.get(), None) - result = self.dispatcher.handle_request(u'play "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual(self.backend.playback.current_track.get().uri, 'b') + self.assertEqual('b', self.backend.playback.current_track.get().uri) + self.assertInResponse(u'OK') def test_play_minus_one_on_empty_playlist_does_not_ack(self): self.backend.current_playlist.clear() - result = self.dispatcher.handle_request(u'play "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "-1"') self.assertEqual(STOPPED, self.backend.playback.state.get()) - self.assertEqual(self.backend.playback.current_track.get(), None) + self.assertEqual(None, self.backend.playback.current_track.get()) + self.assertInResponse(u'OK') def test_play_minus_is_ignored_if_playing(self): self.backend.current_playlist.append([Track(length=40000)]) self.backend.playback.seek(30000) self.assert_(self.backend.playback.time_position.get() >= 30000) self.assertEquals(PLAYING, self.backend.playback.state.get()) - result = self.dispatcher.handle_request(u'play "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertInResponse(u'OK') def test_play_minus_one_resumes_if_paused(self): self.backend.current_playlist.append([Track(length=40000)]) @@ -279,24 +273,27 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assertEquals(PLAYING, self.backend.playback.state.get()) self.backend.playback.pause() self.assertEquals(PAUSED, self.backend.playback.state.get()) - result = self.dispatcher.handle_request(u'play "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertInResponse(u'OK') def test_playid(self): self.backend.current_playlist.append([Track()]) - result = self.dispatcher.handle_request(u'playid "0"') - self.assert_(u'OK' in result) + + self.sendRequest(u'playid "0"') self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.backend.playback.current_track.get(), None) self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - result = self.dispatcher.handle_request(u'playid "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual(self.backend.playback.current_track.get().uri, 'a') + self.assertEqual('a', self.backend.playback.current_track.get().uri) + self.assertInResponse(u'OK') def test_playid_minus_one_plays_current_track_if_current_track_is_set(self): self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) @@ -304,28 +301,31 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.backend.playback.play() self.backend.playback.next() self.backend.playback.stop() - self.assertNotEqual(self.backend.playback.current_track.get(), None) - result = self.dispatcher.handle_request(u'playid "-1"') - self.assert_(u'OK' in result) + self.assertNotEqual(None, self.backend.playback.current_track.get()) + + self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual(self.backend.playback.current_track.get().uri, 'b') + self.assertEqual('b', self.backend.playback.current_track.get().uri) + self.assertInResponse(u'OK') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): self.backend.current_playlist.clear() - result = self.dispatcher.handle_request(u'playid "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'playid "-1"') self.assertEqual(STOPPED, self.backend.playback.state.get()) - self.assertEqual(self.backend.playback.current_track.get(), None) + self.assertEqual(None, self.backend.playback.current_track.get()) + self.assertInResponse(u'OK') def test_playid_minus_is_ignored_if_playing(self): self.backend.current_playlist.append([Track(length=40000)]) self.backend.playback.seek(30000) self.assert_(self.backend.playback.time_position.get() >= 30000) self.assertEquals(PLAYING, self.backend.playback.state.get()) - result = self.dispatcher.handle_request(u'playid "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertInResponse(u'OK') def test_playid_minus_one_resumes_if_paused(self): self.backend.current_playlist.append([Track(length=40000)]) @@ -334,58 +334,64 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assertEquals(PLAYING, self.backend.playback.state.get()) self.backend.playback.pause() self.assertEquals(PAUSED, self.backend.playback.state.get()) - result = self.dispatcher.handle_request(u'playid "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertInResponse(u'OK') def test_playid_which_does_not_exist(self): self.backend.current_playlist.append([Track()]) - result = self.dispatcher.handle_request(u'playid "12345"') - self.assertEqual(result[0], u'ACK [50@0] {playid} No such song') + + self.sendRequest(u'playid "12345"') + self.assertInResponse(u'ACK [50@0] {playid} No such song') def test_previous(self): - result = self.dispatcher.handle_request(u'previous') - self.assert_(u'OK' in result) + self.sendRequest(u'previous') + self.assertInResponse(u'OK') def test_seek(self): self.backend.current_playlist.append([Track(length=40000)]) - self.dispatcher.handle_request(u'seek "0"') - result = self.dispatcher.handle_request(u'seek "0" "30"') - self.assert_(u'OK' in result) + + self.sendRequest(u'seek "0"') + self.sendRequest(u'seek "0" "30"') self.assert_(self.backend.playback.time_position >= 30000) + self.assertInResponse(u'OK') def test_seek_with_songpos(self): seek_track = Track(uri='2', length=40000) self.backend.current_playlist.append( [Track(uri='1', length=40000), seek_track]) - result = self.dispatcher.handle_request(u'seek "1" "30"') - self.assert_(u'OK' in result) + + self.sendRequest(u'seek "1" "30"') self.assertEqual(self.backend.playback.current_track.get(), seek_track) + self.assertInResponse(u'OK') def test_seek_without_quotes(self): self.backend.current_playlist.append([Track(length=40000)]) - self.dispatcher.handle_request(u'seek 0') - result = self.dispatcher.handle_request(u'seek 0 30') - self.assert_(u'OK' in result) + + self.sendRequest(u'seek 0') + self.sendRequest(u'seek 0 30') self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertInResponse(u'OK') def test_seekid(self): self.backend.current_playlist.append([Track(length=40000)]) - result = self.dispatcher.handle_request(u'seekid "0" "30"') - self.assert_(u'OK' in result) + self.sendRequest(u'seekid "0" "30"') self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertInResponse(u'OK') def test_seekid_with_cpid(self): seek_track = Track(uri='2', length=40000) self.backend.current_playlist.append( [Track(length=40000), seek_track]) - result = self.dispatcher.handle_request(u'seekid "1" "30"') - self.assert_(u'OK' in result) - self.assertEqual(self.backend.playback.current_cpid.get(), 1) - self.assertEqual(self.backend.playback.current_track.get(), seek_track) + + self.sendRequest(u'seekid "1" "30"') + self.assertEqual(1, self.backend.playback.current_cpid.get()) + self.assertEqual(seek_track, self.backend.playback.current_track.get()) + self.assertInResponse(u'OK') def test_stop(self): - result = self.dispatcher.handle_request(u'stop') - self.assert_(u'OK' in result) + self.sendRequest(u'stop') self.assertEqual(STOPPED, self.backend.playback.state.get()) + self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/reflection_test.py b/tests/frontends/mpd/protocol/reflection_test.py new file mode 100644 index 00000000..8bd9b7e0 --- /dev/null +++ b/tests/frontends/mpd/protocol/reflection_test.py @@ -0,0 +1,67 @@ +from mopidy import settings + +from tests.frontends.mpd import protocol + + +class ReflectionHandlerTest(protocol.BaseTestCase): + def test_commands_returns_list_of_all_commands(self): + self.sendRequest(u'commands') + # Check if some random commands are included + self.assertInResponse(u'command: commands') + self.assertInResponse(u'command: play') + self.assertInResponse(u'command: status') + # Check if commands you do not have access to are not present + self.assertNotInResponse(u'command: kill') + # Check if the blacklisted commands are not present + self.assertNotInResponse(u'command: command_list_begin') + self.assertNotInResponse(u'command: command_list_ok_begin') + self.assertNotInResponse(u'command: command_list_end') + self.assertNotInResponse(u'command: idle') + self.assertNotInResponse(u'command: noidle') + self.assertNotInResponse(u'command: sticker') + self.assertInResponse(u'OK') + + def test_commands_show_less_if_auth_required_and_not_authed(self): + settings.MPD_SERVER_PASSWORD = u'secret' + self.sendRequest(u'commands') + # Not requiring auth + self.assertInResponse(u'command: close') + self.assertInResponse(u'command: commands') + self.assertInResponse(u'command: notcommands') + self.assertInResponse(u'command: password') + self.assertInResponse(u'command: ping') + # Requiring auth + self.assertNotInResponse(u'command: play') + self.assertNotInResponse(u'command: status') + + def test_decoders(self): + self.sendRequest(u'decoders') + self.assertInResponse(u'ACK [0@0] {} Not implemented') + + def test_notcommands_returns_only_kill_and_ok(self): + response = self.sendRequest(u'notcommands') + self.assertEqual(2, len(response)) + self.assertInResponse(u'command: kill') + self.assertInResponse(u'OK') + + def test_notcommands_returns_more_if_auth_required_and_not_authed(self): + settings.MPD_SERVER_PASSWORD = u'secret' + self.sendRequest(u'notcommands') + # Not requiring auth + self.assertNotInResponse(u'command: close') + self.assertNotInResponse(u'command: commands') + self.assertNotInResponse(u'command: notcommands') + self.assertNotInResponse(u'command: password') + self.assertNotInResponse(u'command: ping') + # Requiring auth + self.assertInResponse(u'command: play') + self.assertInResponse(u'command: status') + + def test_tagtypes(self): + self.sendRequest(u'tagtypes') + self.assertInResponse(u'OK') + + def test_urlhandlers(self): + self.sendRequest(u'urlhandlers') + self.assertInResponse(u'OK') + self.assertInResponse(u'handler: dummy') diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py new file mode 100644 index 00000000..d4e4b2aa --- /dev/null +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -0,0 +1,148 @@ +import random + +from mopidy.models import Track + +from tests.frontends.mpd import protocol + + +class IssueGH17RegressionTest(protocol.BaseTestCase): + """ + The issue: http://github.com/mopidy/mopidy/issues/17 + + How to reproduce: + + - Play a playlist where one track cannot be played + - Turn on random mode + - Press next until you get to the unplayable track + """ + def test(self): + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), None, + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + random.seed(1) # Playlist order: abcfde + + self.sendRequest(u'play') + self.assertEquals('a', self.backend.playback.current_track.get().uri) + self.sendRequest(u'random "1"') + self.sendRequest(u'next') + self.assertEquals('b', self.backend.playback.current_track.get().uri) + self.sendRequest(u'next') + # Should now be at track 'c', but playback fails and it skips ahead + self.assertEquals('f', self.backend.playback.current_track.get().uri) + self.sendRequest(u'next') + self.assertEquals('d', self.backend.playback.current_track.get().uri) + self.sendRequest(u'next') + self.assertEquals('e', self.backend.playback.current_track.get().uri) + + +class IssueGH18RegressionTest(protocol.BaseTestCase): + """ + The issue: http://github.com/mopidy/mopidy/issues/18 + + How to reproduce: + + Play, random on, next, random off, next, next. + + At this point it gives the same song over and over. + """ + + def test(self): + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), Track(uri='c'), + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + random.seed(1) + + self.sendRequest(u'play') + self.sendRequest(u'random "1"') + self.sendRequest(u'next') + self.sendRequest(u'random "0"') + self.sendRequest(u'next') + + self.sendRequest(u'next') + cp_track_1 = self.backend.playback.current_cp_track.get() + self.sendRequest(u'next') + cp_track_2 = self.backend.playback.current_cp_track.get() + self.sendRequest(u'next') + cp_track_3 = self.backend.playback.current_cp_track.get() + + self.assertNotEqual(cp_track_1, cp_track_2) + self.assertNotEqual(cp_track_2, cp_track_3) + + +class IssueGH22RegressionTest(protocol.BaseTestCase): + """ + The issue: http://github.com/mopidy/mopidy/issues/22 + + How to reproduce: + + Play, random on, remove all tracks from the current playlist (as in + "delete" each one, not "clear"). + + Alternatively: Play, random on, remove a random track from the current + playlist, press next until it crashes. + """ + + def test(self): + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), Track(uri='c'), + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + random.seed(1) + + self.sendRequest(u'play') + self.sendRequest(u'random "1"') + self.sendRequest(u'deleteid "1"') + self.sendRequest(u'deleteid "2"') + self.sendRequest(u'deleteid "3"') + self.sendRequest(u'deleteid "4"') + self.sendRequest(u'deleteid "5"') + self.sendRequest(u'deleteid "6"') + self.sendRequest(u'status') + + +class IssueGH69RegressionTest(protocol.BaseTestCase): + """ + The issue: https://github.com/mopidy/mopidy/issues/69 + + How to reproduce: + + Play track, stop, clear current playlist, load a new playlist, status. + + The status response now contains "song: None". + """ + + def test(self): + self.backend.stored_playlists.create('foo') + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), Track(uri='c'), + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + + self.sendRequest(u'play') + self.sendRequest(u'stop') + self.sendRequest(u'clear') + self.sendRequest(u'load "foo"') + self.assertNotInResponse('song: None') + + +class IssueGH113RegressionTest(protocol.BaseTestCase): + """ + The issue: https://github.com/mopidy/mopidy/issues/113 + + How to reproduce: + + - Have a playlist with a name contining backslashes, like + "all lart spotify:track:\w\{22\} pastes". + - Try to load the playlist with the backslashes in the playlist name + escaped. + """ + + def test(self): + self.backend.stored_playlists.create( + u'all lart spotify:track:\w\{22\} pastes') + + self.sendRequest(u'lsinfo "/"') + self.assertInResponse( + u'playlist: all lart spotify:track:\w\{22\} pastes') + + self.sendRequest( + r'listplaylistinfo "all lart spotify:track:\\w\\{22\\} pastes"') + self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/status_test.py b/tests/frontends/mpd/protocol/status_test.py new file mode 100644 index 00000000..e6572eab --- /dev/null +++ b/tests/frontends/mpd/protocol/status_test.py @@ -0,0 +1,37 @@ +from mopidy.models import Track + +from tests.frontends.mpd import protocol + + +class StatusHandlerTest(protocol.BaseTestCase): + def test_clearerror(self): + self.sendRequest(u'clearerror') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_currentsong(self): + track = Track() + self.backend.current_playlist.append([track]) + self.backend.playback.play() + self.sendRequest(u'currentsong') + self.assertInResponse(u'file: ') + self.assertInResponse(u'Time: 0') + self.assertInResponse(u'Artist: ') + self.assertInResponse(u'Title: ') + self.assertInResponse(u'Album: ') + self.assertInResponse(u'Track: 0') + self.assertInResponse(u'Date: ') + self.assertInResponse(u'Pos: 0') + self.assertInResponse(u'Id: 0') + self.assertInResponse(u'OK') + + def test_currentsong_without_song(self): + self.sendRequest(u'currentsong') + self.assertInResponse(u'OK') + + def test_stats_command(self): + self.sendRequest(u'stats') + self.assertInResponse(u'OK') + + def test_status_command(self): + self.sendRequest(u'status') + self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/stickers_test.py b/tests/frontends/mpd/protocol/stickers_test.py new file mode 100644 index 00000000..3e8b687f --- /dev/null +++ b/tests/frontends/mpd/protocol/stickers_test.py @@ -0,0 +1,33 @@ +from tests.frontends.mpd import protocol + + +class StickersHandlerTest(protocol.BaseTestCase): + def test_sticker_get(self): + self.sendRequest( + u'sticker get "song" "file:///dev/urandom" "a_name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_sticker_set(self): + self.sendRequest( + u'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_sticker_delete_with_name(self): + self.sendRequest( + u'sticker delete "song" "file:///dev/urandom" "a_name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_sticker_delete_without_name(self): + self.sendRequest( + u'sticker delete "song" "file:///dev/urandom"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_sticker_list(self): + self.sendRequest( + u'sticker list "song" "file:///dev/urandom"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_sticker_find(self): + self.sendRequest( + u'sticker find "song" "file:///dev/urandom" "a_name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py new file mode 100644 index 00000000..45d6a09a --- /dev/null +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -0,0 +1,94 @@ +import datetime + +from mopidy.models import Track, Playlist + +from tests.frontends.mpd import protocol + + +class StoredPlaylistsHandlerTest(protocol.BaseTestCase): + def test_listplaylist(self): + self.backend.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + + self.sendRequest(u'listplaylist "name"') + self.assertInResponse(u'file: file:///dev/urandom') + self.assertInResponse(u'OK') + + def test_listplaylist_fails_if_no_playlist_is_found(self): + self.sendRequest(u'listplaylist "name"') + self.assertEqualResponse(u'ACK [50@0] {listplaylist} No such playlist') + + def test_listplaylistinfo(self): + self.backend.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + + self.sendRequest(u'listplaylistinfo "name"') + self.assertInResponse(u'file: file:///dev/urandom') + self.assertInResponse(u'Track: 0') + self.assertNotInResponse(u'Pos: 0') + self.assertInResponse(u'OK') + + def test_listplaylistinfo_fails_if_no_playlist_is_found(self): + self.sendRequest(u'listplaylistinfo "name"') + self.assertEqualResponse( + u'ACK [50@0] {listplaylistinfo} No such playlist') + + def test_listplaylists(self): + last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + self.backend.stored_playlists.playlists = [Playlist(name='a', + last_modified=last_modified)] + + self.sendRequest(u'listplaylists') + self.assertInResponse(u'playlist: a') + # Date without microseconds and with time zone information + self.assertInResponse(u'Last-Modified: 2001-03-17T13:41:17Z') + self.assertInResponse(u'OK') + + def test_load_known_playlist_appends_to_current_playlist(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + self.backend.stored_playlists.playlists = [Playlist(name='A-list', + tracks=[Track(uri='c'), Track(uri='d'), Track(uri='e')])] + + self.sendRequest(u'load "A-list"') + tracks = self.backend.current_playlist.tracks.get() + self.assertEqual(5, len(tracks)) + self.assertEqual('a', tracks[0].uri) + self.assertEqual('b', tracks[1].uri) + self.assertEqual('c', tracks[2].uri) + self.assertEqual('d', tracks[3].uri) + self.assertEqual('e', tracks[4].uri) + self.assertInResponse(u'OK') + + def test_load_unknown_playlist_acks(self): + self.sendRequest(u'load "unknown playlist"') + self.assertEqual(0, len(self.backend.current_playlist.tracks.get())) + self.assertEqualResponse(u'ACK [50@0] {load} No such playlist') + + def test_playlistadd(self): + self.sendRequest(u'playlistadd "name" "file:///dev/urandom"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_playlistclear(self): + self.sendRequest(u'playlistclear "name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_playlistdelete(self): + self.sendRequest(u'playlistdelete "name" "5"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_playlistmove(self): + self.sendRequest(u'playlistmove "name" "5" "10"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_rename(self): + self.sendRequest(u'rename "old_name" "new_name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_rm(self): + self.sendRequest(u'rm "name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_save(self): + self.sendRequest(u'save "name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') diff --git a/tests/frontends/mpd/reflection_test.py b/tests/frontends/mpd/reflection_test.py deleted file mode 100644 index 2abf5acc..00000000 --- a/tests/frontends/mpd/reflection_test.py +++ /dev/null @@ -1,79 +0,0 @@ -import unittest - -from mopidy import settings -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer - -class ReflectionHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - settings.runtime.clear() - self.backend.stop().get() - self.mixer.stop().get() - - def test_commands_returns_list_of_all_commands(self): - result = self.dispatcher.handle_request(u'commands') - # Check if some random commands are included - self.assert_(u'command: commands' in result) - self.assert_(u'command: play' in result) - self.assert_(u'command: status' in result) - # Check if commands you do not have access to are not present - self.assert_(u'command: kill' not in result) - # Check if the blacklisted commands are not present - self.assert_(u'command: command_list_begin' not in result) - self.assert_(u'command: command_list_ok_begin' not in result) - self.assert_(u'command: command_list_end' not in result) - self.assert_(u'command: idle' not in result) - self.assert_(u'command: noidle' not in result) - self.assert_(u'command: sticker' not in result) - self.assert_(u'OK' in result) - - def test_commands_show_less_if_auth_required_and_not_authed(self): - settings.MPD_SERVER_PASSWORD = u'secret' - result = self.dispatcher.handle_request(u'commands') - # Not requiring auth - self.assert_(u'command: close' in result, result) - self.assert_(u'command: commands' in result, result) - self.assert_(u'command: notcommands' in result, result) - self.assert_(u'command: password' in result, result) - self.assert_(u'command: ping' in result, result) - # Requiring auth - self.assert_(u'command: play' not in result, result) - self.assert_(u'command: status' not in result, result) - - def test_decoders(self): - result = self.dispatcher.handle_request(u'decoders') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_notcommands_returns_only_kill_and_ok(self): - result = self.dispatcher.handle_request(u'notcommands') - self.assertEqual(2, len(result)) - self.assert_(u'command: kill' in result) - self.assert_(u'OK' in result) - - def test_notcommands_returns_more_if_auth_required_and_not_authed(self): - settings.MPD_SERVER_PASSWORD = u'secret' - result = self.dispatcher.handle_request(u'notcommands') - # Not requiring auth - self.assert_(u'command: close' not in result, result) - self.assert_(u'command: commands' not in result, result) - self.assert_(u'command: notcommands' not in result, result) - self.assert_(u'command: password' not in result, result) - self.assert_(u'command: ping' not in result, result) - # Requiring auth - self.assert_(u'command: play' in result, result) - self.assert_(u'command: status' in result, result) - - def test_tagtypes(self): - result = self.dispatcher.handle_request(u'tagtypes') - self.assert_(u'OK' in result) - - def test_urlhandlers(self): - result = self.dispatcher.handle_request(u'urlhandlers') - self.assert_(u'OK' in result) - self.assert_(u'handler: dummy:' in result) diff --git a/tests/frontends/mpd/regression_test.py b/tests/frontends/mpd/regression_test.py deleted file mode 100644 index f786cf0a..00000000 --- a/tests/frontends/mpd/regression_test.py +++ /dev/null @@ -1,158 +0,0 @@ -import random -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher -from mopidy.mixers.dummy import DummyMixer -from mopidy.models import Track - -class IssueGH17RegressionTest(unittest.TestCase): - """ - The issue: http://github.com/mopidy/mopidy/issues#issue/17 - - How to reproduce: - - - Play a playlist where one track cannot be played - - Turn on random mode - - Press next until you get to the unplayable track - """ - - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.backend.current_playlist.append([ - Track(uri='a'), Track(uri='b'), None, - Track(uri='d'), Track(uri='e'), Track(uri='f')]) - self.mixer = DummyMixer.start().proxy() - self.mpd = dispatcher.MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test(self): - random.seed(1) # Playlist order: abcfde - self.mpd.handle_request(u'play') - self.assertEquals('a', self.backend.playback.current_track.get().uri) - self.mpd.handle_request(u'random "1"') - self.mpd.handle_request(u'next') - self.assertEquals('b', self.backend.playback.current_track.get().uri) - self.mpd.handle_request(u'next') - # Should now be at track 'c', but playback fails and it skips ahead - self.assertEquals('f', self.backend.playback.current_track.get().uri) - self.mpd.handle_request(u'next') - self.assertEquals('d', self.backend.playback.current_track.get().uri) - self.mpd.handle_request(u'next') - self.assertEquals('e', self.backend.playback.current_track.get().uri) - - -class IssueGH18RegressionTest(unittest.TestCase): - """ - The issue: http://github.com/mopidy/mopidy/issues#issue/18 - - How to reproduce: - - Play, random on, next, random off, next, next. - - At this point it gives the same song over and over. - """ - - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.backend.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) - self.mixer = DummyMixer.start().proxy() - self.mpd = dispatcher.MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test(self): - random.seed(1) - self.mpd.handle_request(u'play') - self.mpd.handle_request(u'random "1"') - self.mpd.handle_request(u'next') - self.mpd.handle_request(u'random "0"') - self.mpd.handle_request(u'next') - - self.mpd.handle_request(u'next') - cp_track_1 = self.backend.playback.current_cp_track.get() - self.mpd.handle_request(u'next') - cp_track_2 = self.backend.playback.current_cp_track.get() - self.mpd.handle_request(u'next') - cp_track_3 = self.backend.playback.current_cp_track.get() - - self.assertNotEqual(cp_track_1, cp_track_2) - self.assertNotEqual(cp_track_2, cp_track_3) - - -class IssueGH22RegressionTest(unittest.TestCase): - """ - The issue: http://github.com/mopidy/mopidy/issues/#issue/22 - - How to reproduce: - - Play, random on, remove all tracks from the current playlist (as in - "delete" each one, not "clear"). - - Alternatively: Play, random on, remove a random track from the current - playlist, press next until it crashes. - """ - - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.backend.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) - self.mixer = DummyMixer.start().proxy() - self.mpd = dispatcher.MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test(self): - random.seed(1) - self.mpd.handle_request(u'play') - self.mpd.handle_request(u'random "1"') - self.mpd.handle_request(u'deleteid "1"') - self.mpd.handle_request(u'deleteid "2"') - self.mpd.handle_request(u'deleteid "3"') - self.mpd.handle_request(u'deleteid "4"') - self.mpd.handle_request(u'deleteid "5"') - self.mpd.handle_request(u'deleteid "6"') - self.mpd.handle_request(u'status') - - -class IssueGH69RegressionTest(unittest.TestCase): - """ - The issue: https://github.com/mopidy/mopidy/issues#issue/69 - - How to reproduce: - - Play track, stop, clear current playlist, load a new playlist, status. - - The status response now contains "song: None". - """ - - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.backend.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) - self.backend.stored_playlists.create('foo') - self.mixer = DummyMixer.start().proxy() - self.mpd = dispatcher.MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test(self): - self.mpd.handle_request(u'play') - self.mpd.handle_request(u'stop') - self.mpd.handle_request(u'clear') - self.mpd.handle_request(u'load "foo"') - response = self.mpd.handle_request(u'status') - self.assert_('song: None' not in response) diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index b0c57588..681ab20f 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -1,12 +1,14 @@ -import datetime as dt +import datetime import os -import unittest from mopidy import settings from mopidy.utils.path import mtime, uri_to_path from mopidy.frontends.mpd import translator, protocol from mopidy.models import Album, Artist, Playlist, Track +from tests import unittest + + class TrackMpdFormatTest(unittest.TestCase): track = Track( uri=u'a uri', @@ -15,7 +17,7 @@ class TrackMpdFormatTest(unittest.TestCase): album=Album(name=u'an album', num_tracks=13, artists=[Artist(name=u'an other artist')]), track_no=7, - date=dt.date(1977, 1, 1), + date=datetime.date(1977, 1, 1), length=137000, ) @@ -61,7 +63,7 @@ class TrackMpdFormatTest(unittest.TestCase): self.assert_(('Album', 'an album') in result) self.assert_(('AlbumArtist', 'an other artist') in result) self.assert_(('Track', '7/13') in result) - self.assert_(('Date', dt.date(1977, 1, 1)) in result) + self.assert_(('Date', datetime.date(1977, 1, 1)) in result) self.assert_(('Pos', 9) in result) self.assert_(('Id', 122) in result) self.assertEqual(len(result), 10) diff --git a/tests/frontends/mpd/server_test.py b/tests/frontends/mpd/server_test.py deleted file mode 100644 index b2e27559..00000000 --- a/tests/frontends/mpd/server_test.py +++ /dev/null @@ -1,23 +0,0 @@ -import unittest - -from mopidy import settings -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import server -from mopidy.mixers.dummy import DummyMixer - -class MpdSessionTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.session = server.MpdSession(None, None, (None, None)) - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - settings.runtime.clear() - - def test_found_terminator_catches_decode_error(self): - # Pressing Ctrl+C in a telnet session sends a 0xff byte to the server. - self.session.input_buffer = ['\xff'] - self.session.found_terminator() - self.assertEqual(len(self.session.input_buffer), 0) diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index a7ed921f..bdd2dab8 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,67 +1,30 @@ -import unittest - -from mopidy.backends.base import PlaybackController -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher +from mopidy.backends import dummy as backend +from mopidy.frontends.mpd import dispatcher from mopidy.frontends.mpd.protocol import status -from mopidy.mixers.dummy import DummyMixer +from mopidy.mixers import dummy as mixer from mopidy.models import Track -PAUSED = PlaybackController.PAUSED -PLAYING = PlaybackController.PLAYING -STOPPED = PlaybackController.STOPPED +from tests import unittest + +PAUSED = backend.PlaybackController.PAUSED +PLAYING = backend.PlaybackController.PLAYING +STOPPED = backend.PlaybackController.STOPPED + +# FIXME migrate to using protocol.BaseTestCase instead of status.stats +# directly? + class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() + self.backend = backend.DummyBackend.start().proxy() + self.mixer = mixer.DummyMixer.start().proxy() + self.dispatcher = dispatcher.MpdDispatcher() self.context = self.dispatcher.context def tearDown(self): self.backend.stop().get() self.mixer.stop().get() - def test_clearerror(self): - result = self.dispatcher.handle_request(u'clearerror') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_currentsong(self): - track = Track() - self.backend.current_playlist.append([track]) - self.backend.playback.play() - result = self.dispatcher.handle_request(u'currentsong') - self.assert_(u'file: ' in result) - self.assert_(u'Time: 0' in result) - self.assert_(u'Artist: ' in result) - self.assert_(u'Title: ' in result) - self.assert_(u'Album: ' in result) - self.assert_(u'Track: 0' in result) - self.assert_(u'Date: ' in result) - self.assert_(u'Pos: 0' in result) - self.assert_(u'Id: 0' in result) - self.assert_(u'OK' in result) - - def test_currentsong_without_song(self): - result = self.dispatcher.handle_request(u'currentsong') - self.assert_(u'OK' in result) - - def test_idle_without_subsystems(self): - result = self.dispatcher.handle_request(u'idle') - self.assert_(u'OK' in result) - - def test_idle_with_subsystems(self): - result = self.dispatcher.handle_request(u'idle database playlist') - self.assert_(u'OK' in result) - - def test_noidle(self): - result = self.dispatcher.handle_request(u'noidle') - self.assert_(u'OK' in result) - - def test_stats_command(self): - result = self.dispatcher.handle_request(u'stats') - self.assert_(u'OK' in result) - def test_stats_method(self): result = status.stats(self.context) self.assert_('artists' in result) @@ -79,10 +42,6 @@ class StatusHandlerTest(unittest.TestCase): self.assert_('playtime' in result) self.assert_(int(result['playtime']) >= 0) - def test_status_command(self): - result = self.dispatcher.handle_request(u'status') - self.assert_(u'OK' in result) - def test_status_method_contains_volume_which_defaults_to_0(self): result = dict(status.status(self.context)) self.assert_('volume' in result) @@ -205,7 +164,14 @@ class StatusHandlerTest(unittest.TestCase): self.backend.playback.play_time_accumulated = 59123 result = dict(status.status(self.context)) self.assert_('elapsed' in result) - self.assertEqual(int(result['elapsed']), 59123) + self.assertEqual(result['elapsed'], '59.123') + + def test_status_method_when_starting_playing_contains_elapsed_zero(self): + self.backend.playback.state = PAUSED + self.backend.playback.play_time_accumulated = 123 # Less than 1000ms + result = dict(status.status(self.context)) + self.assert_('elapsed' in result) + self.assertEqual(result['elapsed'], '0.123') def test_status_method_when_playing_contains_bitrate(self): self.backend.current_playlist.append([Track(bitrate=320)]) diff --git a/tests/frontends/mpd/stickers_test.py b/tests/frontends/mpd/stickers_test.py deleted file mode 100644 index 86ac8aec..00000000 --- a/tests/frontends/mpd/stickers_test.py +++ /dev/null @@ -1,45 +0,0 @@ -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer - -class StickersHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_sticker_get(self): - result = self.dispatcher.handle_request( - u'sticker get "song" "file:///dev/urandom" "a_name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_sticker_set(self): - result = self.dispatcher.handle_request( - u'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_sticker_delete_with_name(self): - result = self.dispatcher.handle_request( - u'sticker delete "song" "file:///dev/urandom" "a_name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_sticker_delete_without_name(self): - result = self.dispatcher.handle_request( - u'sticker delete "song" "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_sticker_list(self): - result = self.dispatcher.handle_request( - u'sticker list "song" "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_sticker_find(self): - result = self.dispatcher.handle_request( - u'sticker find "song" "file:///dev/urandom" "a_name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) diff --git a/tests/frontends/mpd/stored_playlists_test.py b/tests/frontends/mpd/stored_playlists_test.py deleted file mode 100644 index 04bab6f1..00000000 --- a/tests/frontends/mpd/stored_playlists_test.py +++ /dev/null @@ -1,102 +0,0 @@ -import datetime as dt -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer -from mopidy.models import Track, Playlist - -class StoredPlaylistsHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_listplaylist(self): - self.backend.stored_playlists.playlists = [ - Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] - result = self.dispatcher.handle_request(u'listplaylist "name"') - self.assert_(u'file: file:///dev/urandom' in result) - self.assert_(u'OK' in result) - - def test_listplaylist_fails_if_no_playlist_is_found(self): - result = self.dispatcher.handle_request(u'listplaylist "name"') - self.assertEqual(result[0], - u'ACK [50@0] {listplaylist} No such playlist') - - def test_listplaylistinfo(self): - self.backend.stored_playlists.playlists = [ - Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] - result = self.dispatcher.handle_request(u'listplaylistinfo "name"') - self.assert_(u'file: file:///dev/urandom' in result) - self.assert_(u'Track: 0' in result) - self.assert_(u'Pos: 0' not in result) - self.assert_(u'OK' in result) - - def test_listplaylistinfo_fails_if_no_playlist_is_found(self): - result = self.dispatcher.handle_request(u'listplaylistinfo "name"') - self.assertEqual(result[0], - u'ACK [50@0] {listplaylistinfo} No such playlist') - - def test_listplaylists(self): - last_modified = dt.datetime(2001, 3, 17, 13, 41, 17, 12345) - self.backend.stored_playlists.playlists = [Playlist(name='a', - last_modified=last_modified)] - result = self.dispatcher.handle_request(u'listplaylists') - self.assert_(u'playlist: a' in result) - # Date without microseconds and with time zone information - self.assert_(u'Last-Modified: 2001-03-17T13:41:17Z' in result) - self.assert_(u'OK' in result) - - def test_load_known_playlist_appends_to_current_playlist(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) - self.backend.stored_playlists.playlists = [Playlist(name='A-list', - tracks=[Track(uri='c'), Track(uri='d'), Track(uri='e')])] - result = self.dispatcher.handle_request(u'load "A-list"') - self.assert_(u'OK' in result) - tracks = self.backend.current_playlist.tracks.get() - self.assertEqual(len(tracks), 5) - self.assertEqual(tracks[0].uri, 'a') - self.assertEqual(tracks[1].uri, 'b') - self.assertEqual(tracks[2].uri, 'c') - self.assertEqual(tracks[3].uri, 'd') - self.assertEqual(tracks[4].uri, 'e') - - def test_load_unknown_playlist_acks(self): - result = self.dispatcher.handle_request(u'load "unknown playlist"') - self.assert_(u'ACK [50@0] {load} No such playlist' in result) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 0) - - def test_playlistadd(self): - result = self.dispatcher.handle_request( - u'playlistadd "name" "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_playlistclear(self): - result = self.dispatcher.handle_request(u'playlistclear "name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_playlistdelete(self): - result = self.dispatcher.handle_request(u'playlistdelete "name" "5"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_playlistmove(self): - result = self.dispatcher.handle_request(u'playlistmove "name" "5" "10"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_rename(self): - result = self.dispatcher.handle_request(u'rename "old_name" "new_name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_rm(self): - result = self.dispatcher.handle_request(u'rm "name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_save(self): - result = self.dispatcher.handle_request(u'save "name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) diff --git a/tests/frontends/mpris/__init__.py b/tests/frontends/mpris/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py new file mode 100644 index 00000000..90cdab6a --- /dev/null +++ b/tests/frontends/mpris/events_test.py @@ -0,0 +1,70 @@ +import mock + +from mopidy.frontends.mpris import MprisFrontend, objects +from mopidy.models import Track + +from tests import unittest + + +class BackendEventsTest(unittest.TestCase): + def setUp(self): + self.mpris_frontend = MprisFrontend() # As a plain class, not an actor + self.mpris_object = mock.Mock(spec=objects.MprisObject) + self.mpris_frontend.mpris_object = self.mpris_object + + def test_track_playback_paused_event_changes_playback_status(self): + self.mpris_object.Get.return_value = 'Paused' + self.mpris_frontend.track_playback_paused(Track(), 0) + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), + ]) + self.mpris_object.PropertiesChanged.assert_called_with( + objects.PLAYER_IFACE, {'PlaybackStatus': 'Paused'}, []) + + def test_track_playback_resumed_event_changes_playback_status(self): + self.mpris_object.Get.return_value = 'Playing' + self.mpris_frontend.track_playback_resumed(Track(), 0) + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), + ]) + self.mpris_object.PropertiesChanged.assert_called_with( + objects.PLAYER_IFACE, {'PlaybackStatus': 'Playing'}, []) + + def test_track_playback_started_event_changes_playback_status_and_metadata(self): + self.mpris_object.Get.return_value = '...' + self.mpris_frontend.track_playback_started(Track()) + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), + ((objects.PLAYER_IFACE, 'Metadata'), {}), + ]) + self.mpris_object.PropertiesChanged.assert_called_with( + objects.PLAYER_IFACE, + {'Metadata': '...', 'PlaybackStatus': '...'}, []) + + def test_track_playback_ended_event_changes_playback_status_and_metadata(self): + self.mpris_object.Get.return_value = '...' + self.mpris_frontend.track_playback_ended(Track(), 0) + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), + ((objects.PLAYER_IFACE, 'Metadata'), {}), + ]) + self.mpris_object.PropertiesChanged.assert_called_with( + objects.PLAYER_IFACE, + {'Metadata': '...', 'PlaybackStatus': '...'}, []) + + def test_volume_changed_event_changes_volume(self): + self.mpris_object.Get.return_value = 1.0 + self.mpris_frontend.volume_changed() + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYER_IFACE, 'Volume'), {}), + ]) + self.mpris_object.PropertiesChanged.assert_called_with( + objects.PLAYER_IFACE, {'Volume': 1.0}, []) + + def test_seeked_event_causes_mpris_seeked_event(self): + self.mpris_object.Get.return_value = 31000000 + self.mpris_frontend.seeked() + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYER_IFACE, 'Position'), {}), + ]) + self.mpris_object.Seeked.assert_called_with(31000000) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py new file mode 100644 index 00000000..a966403e --- /dev/null +++ b/tests/frontends/mpris/player_interface_test.py @@ -0,0 +1,826 @@ +import mock + +from mopidy.backends.dummy import DummyBackend +from mopidy.backends.base.playback import PlaybackController +from mopidy.frontends.mpris import objects +from mopidy.mixers.dummy import DummyMixer +from mopidy.models import Album, Artist, Track + +from tests import unittest + +PLAYING = PlaybackController.PLAYING +PAUSED = PlaybackController.PAUSED +STOPPED = PlaybackController.STOPPED + + +class PlayerInterfaceTest(unittest.TestCase): + def setUp(self): + objects.MprisObject._connect_to_dbus = mock.Mock() + self.mixer = DummyMixer.start().proxy() + self.backend = DummyBackend.start().proxy() + self.mpris = objects.MprisObject() + self.mpris._backend = self.backend + + def tearDown(self): + self.backend.stop() + self.mixer.stop() + + def test_get_playback_status_is_playing_when_playing(self): + self.backend.playback.state = PLAYING + result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') + self.assertEqual('Playing', result) + + def test_get_playback_status_is_paused_when_paused(self): + self.backend.playback.state = PAUSED + result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') + self.assertEqual('Paused', result) + + def test_get_playback_status_is_stopped_when_stopped(self): + self.backend.playback.state = STOPPED + result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') + self.assertEqual('Stopped', result) + + def test_get_loop_status_is_none_when_not_looping(self): + self.backend.playback.repeat = False + self.backend.playback.single = False + result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') + self.assertEqual('None', result) + + def test_get_loop_status_is_track_when_looping_a_single_track(self): + self.backend.playback.repeat = True + self.backend.playback.single = True + result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') + self.assertEqual('Track', result) + + def test_get_loop_status_is_playlist_when_looping_the_current_playlist(self): + self.backend.playback.repeat = True + self.backend.playback.single = False + result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') + self.assertEqual('Playlist', result) + + def test_set_loop_status_is_ignored_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.backend.playback.repeat = True + self.backend.playback.single = True + self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') + self.assertEquals(self.backend.playback.repeat.get(), True) + self.assertEquals(self.backend.playback.single.get(), True) + + def test_set_loop_status_to_none_unsets_repeat_and_single(self): + self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') + self.assertEquals(self.backend.playback.repeat.get(), False) + self.assertEquals(self.backend.playback.single.get(), False) + + def test_set_loop_status_to_track_sets_repeat_and_single(self): + self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Track') + self.assertEquals(self.backend.playback.repeat.get(), True) + self.assertEquals(self.backend.playback.single.get(), True) + + def test_set_loop_status_to_playlists_sets_repeat_and_not_single(self): + self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Playlist') + self.assertEquals(self.backend.playback.repeat.get(), True) + self.assertEquals(self.backend.playback.single.get(), False) + + def test_get_rate_is_greater_or_equal_than_minimum_rate(self): + rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') + minimum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') + self.assert_(rate >= minimum_rate) + + def test_get_rate_is_less_or_equal_than_maximum_rate(self): + rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') + maximum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') + self.assert_(rate >= maximum_rate) + + def test_set_rate_is_ignored_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_set_rate_to_zero_pauses_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) + self.assertEquals(self.backend.playback.state.get(), PAUSED) + + def test_get_shuffle_returns_true_if_random_is_active(self): + self.backend.playback.random = True + result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') + self.assertTrue(result) + + def test_get_shuffle_returns_false_if_random_is_inactive(self): + self.backend.playback.random = False + result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') + self.assertFalse(result) + + def test_set_shuffle_is_ignored_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.backend.playback.random = False + result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) + self.assertFalse(self.backend.playback.random.get()) + + def test_set_shuffle_to_true_activates_random_mode(self): + self.backend.playback.random = False + self.assertFalse(self.backend.playback.random.get()) + result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) + self.assertTrue(self.backend.playback.random.get()) + + def test_set_shuffle_to_false_deactivates_random_mode(self): + self.backend.playback.random = True + self.assertTrue(self.backend.playback.random.get()) + result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) + self.assertFalse(self.backend.playback.random.get()) + + def test_get_metadata_has_trackid_even_when_no_current_track(self): + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assert_('mpris:trackid' in result.keys()) + self.assertEquals(result['mpris:trackid'], '') + + def test_get_metadata_has_trackid_based_on_cpid(self): + self.backend.current_playlist.append([Track(uri='a')]) + self.backend.playback.play() + (cpid, track) = self.backend.playback.current_cp_track.get() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('mpris:trackid', result.keys()) + self.assertEquals(result['mpris:trackid'], + '/com/mopidy/track/%d' % cpid) + + def test_get_metadata_has_track_length(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('mpris:length', result.keys()) + self.assertEquals(result['mpris:length'], 40000000) + + def test_get_metadata_has_track_uri(self): + self.backend.current_playlist.append([Track(uri='a')]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:url', result.keys()) + self.assertEquals(result['xesam:url'], 'a') + + def test_get_metadata_has_track_title(self): + self.backend.current_playlist.append([Track(name='a')]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:title', result.keys()) + self.assertEquals(result['xesam:title'], 'a') + + def test_get_metadata_has_track_artists(self): + self.backend.current_playlist.append([Track(artists=[ + Artist(name='a'), Artist(name='b'), Artist(name=None)])]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:artist', result.keys()) + self.assertEquals(result['xesam:artist'], ['a', 'b']) + + def test_get_metadata_has_track_album(self): + self.backend.current_playlist.append([Track(album=Album(name='a'))]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:album', result.keys()) + self.assertEquals(result['xesam:album'], 'a') + + def test_get_metadata_has_track_album_artists(self): + self.backend.current_playlist.append([Track(album=Album(artists=[ + Artist(name='a'), Artist(name='b'), Artist(name=None)]))]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:albumArtist', result.keys()) + self.assertEquals(result['xesam:albumArtist'], ['a', 'b']) + + def test_get_metadata_has_track_number_in_album(self): + self.backend.current_playlist.append([Track(track_no=7)]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:trackNumber', result.keys()) + self.assertEquals(result['xesam:trackNumber'], 7) + + def test_get_volume_should_return_volume_between_zero_and_one(self): + self.mixer.volume = 0 + result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') + self.assertEquals(result, 0) + + self.mixer.volume = 50 + result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') + self.assertEquals(result, 0.5) + + self.mixer.volume = 100 + result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') + self.assertEquals(result, 1) + + def test_set_volume_is_ignored_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.mixer.volume = 0 + self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) + self.assertEquals(self.mixer.volume.get(), 0) + + def test_set_volume_to_one_should_set_mixer_volume_to_100(self): + self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) + self.assertEquals(self.mixer.volume.get(), 100) + + def test_set_volume_to_anything_above_one_should_set_mixer_volume_to_100(self): + self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) + self.assertEquals(self.mixer.volume.get(), 100) + + def test_set_volume_to_anything_not_a_number_does_not_change_volume(self): + self.mixer.volume = 10 + self.mpris.Set(objects.PLAYER_IFACE, 'Volume', None) + self.assertEquals(self.mixer.volume.get(), 10) + + def test_get_position_returns_time_position_in_microseconds(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + self.backend.playback.seek(10000) + result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') + result_in_milliseconds = result_in_microseconds // 1000 + self.assert_(result_in_milliseconds >= 10000) + + def test_get_position_when_no_current_track_should_be_zero(self): + result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') + result_in_milliseconds = result_in_microseconds // 1000 + self.assertEquals(result_in_milliseconds, 0) + + def test_get_minimum_rate_is_one_or_less(self): + result = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') + self.assert_(result <= 1.0) + + def test_get_maximum_rate_is_one_or_more(self): + result = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') + self.assert_(result >= 1.0) + + def test_can_go_next_is_true_if_can_control_and_other_next_track(self): + self.mpris.get_CanControl = lambda *_: True + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') + self.assertTrue(result) + + def test_can_go_next_is_false_if_next_track_is_the_same(self): + self.mpris.get_CanControl = lambda *_: True + self.backend.current_playlist.append([Track(uri='a')]) + self.backend.playback.repeat = True + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') + self.assertFalse(result) + + def test_can_go_next_is_false_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') + self.assertFalse(result) + + def test_can_go_previous_is_true_if_can_control_and_other_previous_track(self): + self.mpris.get_CanControl = lambda *_: True + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') + self.assertTrue(result) + + def test_can_go_previous_is_false_if_previous_track_is_the_same(self): + self.mpris.get_CanControl = lambda *_: True + self.backend.current_playlist.append([Track(uri='a')]) + self.backend.playback.repeat = True + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') + self.assertFalse(result) + + def test_can_go_previous_is_false_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') + self.assertFalse(result) + + def test_can_play_is_true_if_can_control_and_current_track(self): + self.mpris.get_CanControl = lambda *_: True + self.backend.current_playlist.append([Track(uri='a')]) + self.backend.playback.play() + self.assertTrue(self.backend.playback.current_track.get()) + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') + self.assertTrue(result) + + def test_can_play_is_false_if_no_current_track(self): + self.mpris.get_CanControl = lambda *_: True + self.assertFalse(self.backend.playback.current_track.get()) + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') + self.assertFalse(result) + + def test_can_play_if_false_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') + self.assertFalse(result) + + def test_can_pause_is_true_if_can_control_and_track_can_be_paused(self): + self.mpris.get_CanControl = lambda *_: True + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPause') + self.assertTrue(result) + + def test_can_pause_if_false_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPause') + self.assertFalse(result) + + def test_can_seek_is_true_if_can_control_is_true(self): + self.mpris.get_CanControl = lambda *_: True + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanSeek') + self.assertTrue(result) + + def test_can_seek_is_false_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanSeek') + self.assertFalse(result) + + def test_can_control_is_true(self): + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanControl') + self.assertTrue(result) + + def test_next_is_ignored_if_can_go_next_is_false(self): + self.mpris.get_CanGoNext = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.mpris.Next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + def test_next_when_playing_should_skip_to_next_track_and_keep_playing(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_next_when_at_end_of_list_should_stop_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Next() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_next_when_paused_should_skip_to_next_track_and_stay_paused(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.pause() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.mpris.Next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), PAUSED) + + def test_next_when_stopped_should_skip_to_next_track_and_stay_stopped(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.stop() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.mpris.Next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_previous_is_ignored_if_can_go_previous_is_false(self): + self.mpris.get_CanGoPrevious = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.mpris.Previous() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + + def test_previous_when_playing_should_skip_to_prev_track_and_keep_playing(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Previous() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_previous_when_at_start_of_list_should_stop_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Previous() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_previous_when_paused_should_skip_to_previous_track_and_stay_paused(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + self.backend.playback.pause() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.mpris.Previous() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), PAUSED) + + def test_previous_when_stopped_should_skip_to_previous_track_and_stay_stopped(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + self.backend.playback.stop() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.mpris.Previous() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_pause_is_ignored_if_can_pause_is_false(self): + self.mpris.get_CanPause = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Pause() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_pause_when_playing_should_pause_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Pause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + + def test_pause_when_paused_has_no_effect(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.pause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.mpris.Pause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + + def test_playpause_is_ignored_if_can_pause_is_false(self): + self.mpris.get_CanPause = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.PlayPause() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_playpause_when_playing_should_pause_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.PlayPause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + + def test_playpause_when_paused_should_resume_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.pause() + + self.assertEquals(self.backend.playback.state.get(), PAUSED) + at_pause = self.backend.playback.time_position.get() + self.assert_(at_pause >= 0) + + self.mpris.PlayPause() + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + after_pause = self.backend.playback.time_position.get() + self.assert_(after_pause >= at_pause) + + def test_playpause_when_stopped_should_start_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.mpris.PlayPause() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_stop_is_ignored_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Stop() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_stop_when_playing_should_stop_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Stop() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_stop_when_paused_should_stop_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.pause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.mpris.Stop() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_play_is_ignored_if_can_play_is_false(self): + self.mpris.get_CanPlay = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.mpris.Play() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_play_when_stopped_starts_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.mpris.Play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_play_after_pause_resumes_from_same_position(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + + before_pause = self.backend.playback.time_position.get() + self.assert_(before_pause >= 0) + + self.mpris.Pause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + at_pause = self.backend.playback.time_position.get() + self.assert_(at_pause >= before_pause) + + self.mpris.Play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + after_pause = self.backend.playback.time_position.get() + self.assert_(after_pause >= at_pause) + + def test_play_when_there_is_no_track_has_no_effect(self): + self.backend.current_playlist.clear() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.mpris.Play() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_seek_is_ignored_if_can_seek_is_false(self): + self.mpris.get_CanSeek = lambda *_: False + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + + before_seek = self.backend.playback.time_position.get() + self.assert_(before_seek >= 0) + + milliseconds_to_seek = 10000 + microseconds_to_seek = milliseconds_to_seek * 1000 + + self.mpris.Seek(microseconds_to_seek) + + after_seek = self.backend.playback.time_position.get() + self.assert_(before_seek <= after_seek < ( + before_seek + milliseconds_to_seek)) + + def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + + before_seek = self.backend.playback.time_position.get() + self.assert_(before_seek >= 0) + + milliseconds_to_seek = 10000 + microseconds_to_seek = milliseconds_to_seek * 1000 + + self.mpris.Seek(microseconds_to_seek) + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + after_seek = self.backend.playback.time_position.get() + self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) + + def test_seek_seeks_given_microseconds_backward_if_negative(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + self.backend.playback.seek(20000) + + before_seek = self.backend.playback.time_position.get() + self.assert_(before_seek >= 20000) + + milliseconds_to_seek = -10000 + microseconds_to_seek = milliseconds_to_seek * 1000 + + self.mpris.Seek(microseconds_to_seek) + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + after_seek = self.backend.playback.time_position.get() + self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) + self.assert_(after_seek < before_seek) + + def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + self.backend.playback.seek(20000) + + before_seek = self.backend.playback.time_position.get() + self.assert_(before_seek >= 20000) + + milliseconds_to_seek = -30000 + microseconds_to_seek = milliseconds_to_seek * 1000 + + self.mpris.Seek(microseconds_to_seek) + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + after_seek = self.backend.playback.time_position.get() + self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) + self.assert_(after_seek < before_seek) + self.assert_(after_seek >= 0) + + def test_seek_skips_to_next_track_if_new_position_larger_than_track_length(self): + self.backend.current_playlist.append([Track(uri='a', length=40000), + Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.seek(20000) + + before_seek = self.backend.playback.time_position.get() + self.assert_(before_seek >= 20000) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + milliseconds_to_seek = 50000 + microseconds_to_seek = milliseconds_to_seek * 1000 + + self.mpris.Seek(microseconds_to_seek) + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + + after_seek = self.backend.playback.time_position.get() + self.assert_(after_seek >= 0) + self.assert_(after_seek < before_seek) + + def test_set_position_is_ignored_if_can_seek_is_false(self): + self.mpris.get_CanSeek = lambda *_: False + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + + before_set_position = self.backend.playback.time_position.get() + self.assert_(before_set_position <= 5000) + + track_id = 'a' + + position_to_set_in_milliseconds = 20000 + position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + + self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + + after_set_position = self.backend.playback.time_position.get() + self.assert_(before_set_position <= after_set_position < + position_to_set_in_milliseconds) + + def test_set_position_sets_the_current_track_position_in_microsecs(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + + before_set_position = self.backend.playback.time_position.get() + self.assert_(before_set_position <= 5000) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + track_id = '/com/mopidy/track/0' + + position_to_set_in_milliseconds = 20000 + position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + + self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + after_set_position = self.backend.playback.time_position.get() + self.assert_(after_set_position >= position_to_set_in_milliseconds) + + def test_set_position_does_nothing_if_the_position_is_negative(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + self.backend.playback.seek(20000) + + before_set_position = self.backend.playback.time_position.get() + self.assert_(before_set_position >= 20000) + self.assert_(before_set_position <= 25000) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + track_id = '/com/mopidy/track/0' + + position_to_set_in_milliseconds = -1000 + position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + + self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + + after_set_position = self.backend.playback.time_position.get() + self.assert_(after_set_position >= before_set_position) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + def test_set_position_does_nothing_if_position_is_larger_than_track_length(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + self.backend.playback.seek(20000) + + before_set_position = self.backend.playback.time_position.get() + self.assert_(before_set_position >= 20000) + self.assert_(before_set_position <= 25000) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + track_id = 'a' + + position_to_set_in_milliseconds = 50000 + position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + + self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + + after_set_position = self.backend.playback.time_position.get() + self.assert_(after_set_position >= before_set_position) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + def test_set_position_does_nothing_if_track_id_does_not_match_current_track(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + self.backend.playback.seek(20000) + + before_set_position = self.backend.playback.time_position.get() + self.assert_(before_set_position >= 20000) + self.assert_(before_set_position <= 25000) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + track_id = 'b' + + position_to_set_in_milliseconds = 0 + position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + + self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + + after_set_position = self.backend.playback.time_position.get() + self.assert_(after_set_position >= before_set_position) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + def test_open_uri_is_ignored_if_can_play_is_false(self): + self.mpris.get_CanPlay = lambda *_: False + self.backend.library.provider.dummy_library = [ + Track(uri='dummy:/test/uri')] + self.mpris.OpenUri('dummy:/test/uri') + self.assertEquals(len(self.backend.current_playlist.tracks.get()), 0) + + def test_open_uri_ignores_uris_with_unknown_uri_scheme(self): + self.assertListEqual(self.backend.uri_schemes.get(), ['dummy']) + self.mpris.get_CanPlay = lambda *_: True + self.backend.library.provider.dummy_library = [ + Track(uri='notdummy:/test/uri')] + self.mpris.OpenUri('notdummy:/test/uri') + self.assertEquals(len(self.backend.current_playlist.tracks.get()), 0) + + def test_open_uri_adds_uri_to_current_playlist(self): + self.mpris.get_CanPlay = lambda *_: True + self.backend.library.provider.dummy_library = [ + Track(uri='dummy:/test/uri')] + self.mpris.OpenUri('dummy:/test/uri') + self.assertEquals(self.backend.current_playlist.tracks.get()[0].uri, + 'dummy:/test/uri') + + def test_open_uri_starts_playback_of_new_track_if_stopped(self): + self.mpris.get_CanPlay = lambda *_: True + self.backend.library.provider.dummy_library = [ + Track(uri='dummy:/test/uri')] + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + self.mpris.OpenUri('dummy:/test/uri') + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, + 'dummy:/test/uri') + + def test_open_uri_starts_playback_of_new_track_if_paused(self): + self.mpris.get_CanPlay = lambda *_: True + self.backend.library.provider.dummy_library = [ + Track(uri='dummy:/test/uri')] + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.pause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + self.mpris.OpenUri('dummy:/test/uri') + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, + 'dummy:/test/uri') + + def test_open_uri_starts_playback_of_new_track_if_playing(self): + self.mpris.get_CanPlay = lambda *_: True + self.backend.library.provider.dummy_library = [ + Track(uri='dummy:/test/uri')] + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + self.mpris.OpenUri('dummy:/test/uri') + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, + 'dummy:/test/uri') diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py new file mode 100644 index 00000000..443efdd3 --- /dev/null +++ b/tests/frontends/mpris/root_interface_test.py @@ -0,0 +1,63 @@ +import mock + +from mopidy import settings +from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpris import objects + +from tests import unittest + + +class RootInterfaceTest(unittest.TestCase): + def setUp(self): + objects.exit_process = mock.Mock() + objects.MprisObject._connect_to_dbus = mock.Mock() + self.backend = DummyBackend.start().proxy() + self.mpris = objects.MprisObject() + + def tearDown(self): + self.backend.stop() + + def test_constructor_connects_to_dbus(self): + self.assert_(self.mpris._connect_to_dbus.called) + + def test_can_raise_returns_false(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'CanRaise') + self.assertFalse(result) + + def test_raise_does_nothing(self): + self.mpris.Raise() + + def test_can_quit_returns_true(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'CanQuit') + self.assertTrue(result) + + def test_quit_should_stop_all_actors(self): + self.mpris.Quit() + self.assert_(objects.exit_process.called) + + def test_has_track_list_returns_false(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'HasTrackList') + self.assertFalse(result) + + def test_identify_is_mopidy(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'Identity') + self.assertEquals(result, 'Mopidy') + + def test_desktop_entry_is_mopidy(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'DesktopEntry') + self.assertEquals(result, 'mopidy') + + def test_desktop_entry_is_based_on_DESKTOP_FILE_setting(self): + settings.runtime['DESKTOP_FILE'] = '/tmp/foo.desktop' + result = self.mpris.Get(objects.ROOT_IFACE, 'DesktopEntry') + self.assertEquals(result, 'foo') + settings.runtime.clear() + + def test_supported_uri_schemes_is_empty(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'SupportedUriSchemes') + self.assertEquals(len(result), 1) + self.assertEquals(result[0], 'dummy') + + def test_supported_mime_types_is_empty(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'SupportedMimeTypes') + self.assertEquals(len(result), 0) diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index 0b9a559e..66e0995e 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -1,21 +1,16 @@ -import multiprocessing -import unittest - -from tests import SkipTest - -# FIXME Our Windows build server does not support GStreamer yet import sys -if sys.platform == 'win32': - raise SkipTest from mopidy import settings from mopidy.gstreamer import GStreamer from mopidy.utils.path import path_to_uri -from tests import path_to_data_dir +from tests import unittest, path_to_data_dir # TODO BaseOutputTest? + +@unittest.skipIf(sys.platform == 'win32', + 'Our Windows build server does not support GStreamer yet') class GStreamerTest(unittest.TestCase): def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) @@ -48,11 +43,11 @@ class GStreamerTest(unittest.TestCase): self.gstreamer.start_playback() self.assertTrue(self.gstreamer.stop_playback()) - @SkipTest + @unittest.SkipTest def test_deliver_data(self): pass # TODO - @SkipTest + @unittest.SkipTest def test_end_of_data_stream(self): pass # TODO @@ -71,10 +66,10 @@ class GStreamerTest(unittest.TestCase): self.assertTrue(self.gstreamer.set_volume(100)) self.assertEqual(100, self.gstreamer.get_volume()) - @SkipTest + @unittest.SkipTest def test_set_state_encapsulation(self): pass # TODO - @SkipTest + @unittest.SkipTest def test_set_position(self): pass # TODO diff --git a/tests/help_test.py b/tests/help_test.py index 25f534c2..1fa22c2f 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -1,10 +1,12 @@ import os import subprocess import sys -import unittest import mopidy +from tests import unittest + + class HelpTest(unittest.TestCase): def test_help_has_mopidy_options(self): mopidy_dir = os.path.dirname(mopidy.__file__) diff --git a/tests/listeners_test.py b/tests/listeners_test.py new file mode 100644 index 00000000..486dcf9c --- /dev/null +++ b/tests/listeners_test.py @@ -0,0 +1,36 @@ +from mopidy.listeners import BackendListener +from mopidy.models import Track + +from tests import unittest + + +class BackendListenerTest(unittest.TestCase): + def setUp(self): + self.listener = BackendListener() + + def test_listener_has_default_impl_for_track_playback_paused(self): + self.listener.track_playback_paused(Track(), 0) + + def test_listener_has_default_impl_for_track_playback_resumed(self): + self.listener.track_playback_resumed(Track(), 0) + + def test_listener_has_default_impl_for_track_playback_started(self): + self.listener.track_playback_started(Track()) + + def test_listener_has_default_impl_for_track_playback_ended(self): + self.listener.track_playback_ended(Track(), 0) + + def test_listener_has_default_impl_for_playback_state_changed(self): + self.listener.playback_state_changed() + + def test_listener_has_default_impl_for_playlist_changed(self): + self.listener.playlist_changed() + + def test_listener_has_default_impl_for_options_changed(self): + self.listener.options_changed() + + def test_listener_has_default_impl_for_volume_changed(self): + self.listener.volume_changed() + + def test_listener_has_default_impl_for_seeked(self): + self.listener.seeked() diff --git a/tests/mixers/denon_test.py b/tests/mixers/denon_test.py index 5370f155..7fec3c82 100644 --- a/tests/mixers/denon_test.py +++ b/tests/mixers/denon_test.py @@ -1,8 +1,9 @@ -import unittest - from mopidy.mixers.denon import DenonMixer from tests.mixers.base_test import BaseMixerTest +from tests import unittest + + class DenonMixerDeviceMock(object): def __init__(self): self._open = True @@ -24,6 +25,7 @@ class DenonMixerDeviceMock(object): def open(self): self._open = True + class DenonMixerTest(BaseMixerTest, unittest.TestCase): ACTUAL_MAX = 99 INITIAL = 1 diff --git a/tests/mixers/dummy_test.py b/tests/mixers/dummy_test.py index 334dc8a1..8ae8623c 100644 --- a/tests/mixers/dummy_test.py +++ b/tests/mixers/dummy_test.py @@ -1,8 +1,9 @@ -import unittest - from mopidy.mixers.dummy import DummyMixer + +from tests import unittest from tests.mixers.base_test import BaseMixerTest + class DenonMixerTest(BaseMixerTest, unittest.TestCase): mixer_class = DummyMixer diff --git a/tests/models_test.py b/tests/models_test.py index 637a8209..978f35b6 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -1,9 +1,9 @@ -import datetime as dt -import unittest +import datetime from mopidy.models import Artist, Album, CpTrack, Track, Playlist -from tests import SkipTest +from tests import unittest + class GenericCopyTets(unittest.TestCase): def compare(self, orig, other): @@ -49,6 +49,7 @@ class GenericCopyTets(unittest.TestCase): test = lambda: Track().copy(invalid_key=True) self.assertRaises(TypeError, test) + class ArtistTest(unittest.TestCase): def test_uri(self): uri = u'an_uri' @@ -321,7 +322,7 @@ class TrackTest(unittest.TestCase): self.assertRaises(AttributeError, setattr, track, 'track_no', None) def test_date(self): - date = dt.date(1977, 1, 1) + date = datetime.date(1977, 1, 1) track = Track(date=date) self.assertEqual(track.date, date) self.assertRaises(AttributeError, setattr, track, 'date', None) @@ -400,7 +401,7 @@ class TrackTest(unittest.TestCase): self.assertEqual(hash(track1), hash(track2)) def test_eq_date(self): - date = dt.date.today() + date = datetime.date.today() track1 = Track(date=date) track2 = Track(date=date) self.assertEqual(track1, track2) @@ -425,7 +426,7 @@ class TrackTest(unittest.TestCase): self.assertEqual(hash(track1), hash(track2)) def test_eq(self): - date = dt.date.today() + date = datetime.date.today() artists = [Artist()] album = Album() track1 = Track(uri=u'uri', name=u'name', artists=artists, album=album, @@ -474,8 +475,8 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(hash(track1), hash(track2)) def test_ne_date(self): - track1 = Track(date=dt.date.today()) - track2 = Track(date=dt.date.today()-dt.timedelta(days=1)) + track1 = Track(date=datetime.date.today()) + track2 = Track(date=datetime.date.today()-datetime.timedelta(days=1)) self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -500,11 +501,11 @@ class TrackTest(unittest.TestCase): def test_ne(self): track1 = Track(uri=u'uri1', name=u'name1', artists=[Artist(name=u'name1')], album=Album(name=u'name1'), - track_no=1, date=dt.date.today(), length=100, bitrate=100, + track_no=1, date=datetime.date.today(), length=100, bitrate=100, musicbrainz_id='id1') track2 = Track(uri=u'uri2', name=u'name2', artists=[Artist(name=u'name2')], album=Album(name=u'name2'), - track_no=2, date=dt.date.today()-dt.timedelta(days=1), + track_no=2, date=datetime.date.today()-datetime.timedelta(days=1), length=200, bitrate=200, musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -535,7 +536,7 @@ class PlaylistTest(unittest.TestCase): self.assertEqual(playlist.length, 3) def test_last_modified(self): - last_modified = dt.datetime.now() + last_modified = datetime.datetime.now() playlist = Playlist(last_modified=last_modified) self.assertEqual(playlist.last_modified, last_modified) self.assertRaises(AttributeError, setattr, playlist, 'last_modified', @@ -543,7 +544,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_uri(self): tracks = [Track()] - last_modified = dt.datetime.now() + last_modified = datetime.datetime.now() playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(uri=u'another uri') @@ -554,7 +555,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_name(self): tracks = [Track()] - last_modified = dt.datetime.now() + last_modified = datetime.datetime.now() playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(name=u'another name') @@ -565,7 +566,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_tracks(self): tracks = [Track()] - last_modified = dt.datetime.now() + last_modified = datetime.datetime.now() playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_tracks = [Track(), Track()] @@ -577,8 +578,8 @@ class PlaylistTest(unittest.TestCase): def test_with_new_last_modified(self): tracks = [Track()] - last_modified = dt.datetime.now() - new_last_modified = last_modified + dt.timedelta(1) + last_modified = datetime.datetime.now() + new_last_modified = last_modified + datetime.timedelta(1) playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(last_modified=new_last_modified) diff --git a/tests/scanner_test.py b/tests/scanner_test.py index f403a221..91e67e11 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -1,10 +1,10 @@ -import unittest from datetime import date from mopidy.scanner import Scanner, translator from mopidy.models import Track, Artist, Album -from tests import path_to_data_dir, SkipTest +from tests import unittest, path_to_data_dir + class FakeGstDate(object): def __init__(self, year, month, day): @@ -12,6 +12,7 @@ class FakeGstDate(object): self.month = month self.day = day + class TranslatorTest(unittest.TestCase): def setUp(self): self.data = { @@ -126,6 +127,7 @@ class TranslatorTest(unittest.TestCase): del self.track['date'] self.check() + class ScannerTest(unittest.TestCase): def setUp(self): self.errors = {} @@ -185,6 +187,6 @@ class ScannerTest(unittest.TestCase): self.scan('scanner/image') self.assert_(self.errors) - @SkipTest + @unittest.SkipTest def test_song_without_time_is_handeled(self): pass diff --git a/tests/utils/init_test.py b/tests/utils/init_test.py index fb38e2ea..2097e3e6 100644 --- a/tests/utils/init_test.py +++ b/tests/utils/init_test.py @@ -1,15 +1,17 @@ -import unittest - from mopidy.utils import get_class +from tests import unittest + + class GetClassTest(unittest.TestCase): def test_loading_module_that_does_not_exist(self): - test = lambda: get_class('foo.bar.Baz') - self.assertRaises(ImportError, test) + self.assertRaises(ImportError, get_class, 'foo.bar.Baz') def test_loading_class_that_does_not_exist(self): - test = lambda: get_class('unittest.FooBarBaz') - self.assertRaises(ImportError, test) + self.assertRaises(ImportError, get_class, 'unittest.FooBarBaz') + + def test_loading_incorrect_class_path(self): + self.assertRaises(ImportError, get_class, 'foobarbaz') def test_import_error_message_contains_complete_class_path(self): try: diff --git a/tests/utils/network/__init__.py b/tests/utils/network/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py new file mode 100644 index 00000000..aa1be2b6 --- /dev/null +++ b/tests/utils/network/connection_test.py @@ -0,0 +1,539 @@ +import errno +import gobject +import logging +import pykka +import socket +from mock import patch, sentinel, Mock + +from mopidy.utils import network + +from tests import unittest, any_int, any_unicode + + +class ConnectionTest(unittest.TestCase): + def setUp(self): + self.mock = Mock(spec=network.Connection) + + def test_init_ensure_nonblocking_io(self): + sock = Mock(spec=socket.SocketType) + + network.Connection.__init__(self.mock, Mock(), sock, + (sentinel.host, sentinel.port), sentinel.timeout) + sock.setblocking.assert_called_once_with(False) + + def test_init_starts_actor(self): + protocol = Mock(spec=network.LineProtocol) + + network.Connection.__init__(self.mock, protocol, Mock(), + (sentinel.host, sentinel.port), sentinel.timeout) + protocol.start.assert_called_once_with(self.mock) + + def test_init_enables_recv_and_timeout(self): + network.Connection.__init__(self.mock, Mock(), Mock(), + (sentinel.host, sentinel.port), sentinel.timeout) + self.mock.enable_recv.assert_called_once_with() + self.mock.enable_timeout.assert_called_once_with() + + def test_init_stores_values_in_attributes(self): + addr = (sentinel.host, sentinel.port) + protocol = Mock(spec=network.LineProtocol) + sock = Mock(spec=socket.SocketType) + + network.Connection.__init__( + self.mock, protocol, sock, addr, sentinel.timeout) + self.assertEqual(sock, self.mock.sock) + self.assertEqual(protocol, self.mock.protocol) + self.assertEqual(sentinel.timeout, self.mock.timeout) + self.assertEqual(sentinel.host, self.mock.host) + self.assertEqual(sentinel.port, self.mock.port) + + def test_init_handles_ipv6_addr(self): + addr = (sentinel.host, sentinel.port, + sentinel.flowinfo, sentinel.scopeid) + protocol = Mock(spec=network.LineProtocol) + sock = Mock(spec=socket.SocketType) + + network.Connection.__init__( + self.mock, protocol, sock, addr, sentinel.timeout) + self.assertEqual(sentinel.host, self.mock.host) + self.assertEqual(sentinel.port, self.mock.port) + + def test_stop_disables_recv_send_and_timeout(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + self.mock.disable_timeout.assert_called_once_with() + self.mock.disable_recv.assert_called_once_with() + self.mock.disable_send.assert_called_once_with() + + def test_stop_closes_socket(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + self.mock.sock.close.assert_called_once_with() + + def test_stop_closes_socket_error(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.close.side_effect = socket.error + + network.Connection.stop(self.mock, sentinel.reason) + self.mock.sock.close.assert_called_once_with() + + def test_stop_stops_actor(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + self.mock.actor_ref.stop.assert_called_once_with() + + def test_stop_handles_actor_already_being_stopped(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.actor_ref.stop.side_effect = pykka.ActorDeadError() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + self.mock.actor_ref.stop.assert_called_once_with() + + def test_stop_sets_stopping_to_true(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + self.assertEqual(True, self.mock.stopping) + + def test_stop_does_not_proceed_when_already_stopping(self): + self.mock.stopping = True + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + self.assertEqual(0, self.mock.actor_ref.stop.call_count) + self.assertEqual(0, self.mock.sock.close.call_count) + + @patch.object(network.logger, 'log', new=Mock()) + def test_stop_logs_reason(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + network.logger.log.assert_called_once_with( + logging.DEBUG, sentinel.reason) + + @patch.object(network.logger, 'log', new=Mock()) + def test_stop_logs_reason_with_level(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason, + level=sentinel.level) + network.logger.log.assert_called_once_with( + sentinel.level, sentinel.reason) + + @patch.object(network.logger, 'log', new=Mock()) + def test_stop_logs_that_it_is_calling_itself(self): + self.mock.stopping = True + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + network.logger.log(any_int, any_unicode) + + @patch.object(gobject, 'io_add_watch', new=Mock()) + def test_enable_recv_registers_with_gobject(self): + self.mock.recv_id = None + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.fileno.return_value = sentinel.fileno + gobject.io_add_watch.return_value = sentinel.tag + + network.Connection.enable_recv(self.mock) + gobject.io_add_watch.assert_called_once_with(sentinel.fileno, + gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, + self.mock.recv_callback) + self.assertEqual(sentinel.tag, self.mock.recv_id) + + @patch.object(gobject, 'io_add_watch', new=Mock()) + def test_enable_recv_already_registered(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.recv_id = sentinel.tag + + network.Connection.enable_recv(self.mock) + self.assertEqual(0, gobject.io_add_watch.call_count) + + def test_enable_recv_does_not_change_tag(self): + self.mock.recv_id = sentinel.tag + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.enable_recv(self.mock) + self.assertEqual(sentinel.tag, self.mock.recv_id) + + @patch.object(gobject, 'source_remove', new=Mock()) + def test_disable_recv_deregisters(self): + self.mock.recv_id = sentinel.tag + + network.Connection.disable_recv(self.mock) + gobject.source_remove.assert_called_once_with(sentinel.tag) + self.assertEqual(None, self.mock.recv_id) + + @patch.object(gobject, 'source_remove', new=Mock()) + def test_disable_recv_already_deregistered(self): + self.mock.recv_id = None + + network.Connection.disable_recv(self.mock) + self.assertEqual(0, gobject.source_remove.call_count) + self.assertEqual(None, self.mock.recv_id) + + def test_enable_recv_on_closed_socket(self): + self.mock.recv_id = None + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.fileno.side_effect = socket.error(errno.EBADF, '') + + network.Connection.enable_recv(self.mock) + self.mock.stop.assert_called_once_with(any_unicode) + self.assertEqual(None, self.mock.recv_id) + + @patch.object(gobject, 'io_add_watch', new=Mock()) + def test_enable_send_registers_with_gobject(self): + self.mock.send_id = None + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.fileno.return_value = sentinel.fileno + gobject.io_add_watch.return_value = sentinel.tag + + network.Connection.enable_send(self.mock) + gobject.io_add_watch.assert_called_once_with(sentinel.fileno, + gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, + self.mock.send_callback) + self.assertEqual(sentinel.tag, self.mock.send_id) + + @patch.object(gobject, 'io_add_watch', new=Mock()) + def test_enable_send_already_registered(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.send_id = sentinel.tag + + network.Connection.enable_send(self.mock) + self.assertEqual(0, gobject.io_add_watch.call_count) + + def test_enable_send_does_not_change_tag(self): + self.mock.send_id = sentinel.tag + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.enable_send(self.mock) + self.assertEqual(sentinel.tag, self.mock.send_id) + + @patch.object(gobject, 'source_remove', new=Mock()) + def test_disable_send_deregisters(self): + self.mock.send_id = sentinel.tag + + network.Connection.disable_send(self.mock) + gobject.source_remove.assert_called_once_with(sentinel.tag) + self.assertEqual(None, self.mock.send_id) + + @patch.object(gobject, 'source_remove', new=Mock()) + def test_disable_send_already_deregistered(self): + self.mock.send_id = None + + network.Connection.disable_send(self.mock) + self.assertEqual(0, gobject.source_remove.call_count) + self.assertEqual(None, self.mock.send_id) + + def test_enable_send_on_closed_socket(self): + self.mock.send_id = None + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.fileno.side_effect = socket.error(errno.EBADF, '') + + network.Connection.enable_send(self.mock) + self.assertEqual(None, self.mock.send_id) + + @patch.object(gobject, 'timeout_add_seconds', new=Mock()) + def test_enable_timeout_clears_existing_timeouts(self): + self.mock.timeout = 10 + + network.Connection.enable_timeout(self.mock) + self.mock.disable_timeout.assert_called_once_with() + + @patch.object(gobject, 'timeout_add_seconds', new=Mock()) + def test_enable_timeout_add_gobject_timeout(self): + self.mock.timeout = 10 + gobject.timeout_add_seconds.return_value = sentinel.tag + + network.Connection.enable_timeout(self.mock) + gobject.timeout_add_seconds.assert_called_once_with(10, + self.mock.timeout_callback) + self.assertEqual(sentinel.tag, self.mock.timeout_id) + + @patch.object(gobject, 'timeout_add_seconds', new=Mock()) + def test_enable_timeout_does_not_add_timeout(self): + self.mock.timeout = 0 + network.Connection.enable_timeout(self.mock) + self.assertEqual(0, gobject.timeout_add_seconds.call_count) + + self.mock.timeout = -1 + network.Connection.enable_timeout(self.mock) + self.assertEqual(0, gobject.timeout_add_seconds.call_count) + + self.mock.timeout = None + network.Connection.enable_timeout(self.mock) + self.assertEqual(0, gobject.timeout_add_seconds.call_count) + + def test_enable_timeout_does_not_call_disable_for_invalid_timeout(self): + self.mock.timeout = 0 + network.Connection.enable_timeout(self.mock) + self.assertEqual(0, self.mock.disable_timeout.call_count) + + self.mock.timeout = -1 + network.Connection.enable_timeout(self.mock) + self.assertEqual(0, self.mock.disable_timeout.call_count) + + self.mock.timeout = None + network.Connection.enable_timeout(self.mock) + self.assertEqual(0, self.mock.disable_timeout.call_count) + + @patch.object(gobject, 'source_remove', new=Mock()) + def test_disable_timeout_deregisters(self): + self.mock.timeout_id = sentinel.tag + + network.Connection.disable_timeout(self.mock) + gobject.source_remove.assert_called_once_with(sentinel.tag) + self.assertEqual(None, self.mock.timeout_id) + + @patch.object(gobject, 'source_remove', new=Mock()) + def test_disable_timeout_already_deregistered(self): + self.mock.timeout_id = None + + network.Connection.disable_timeout(self.mock) + self.assertEqual(0, gobject.source_remove.call_count) + self.assertEqual(None, self.mock.timeout_id) + + def test_queue_send_acquires_and_releases_lock(self): + self.mock.send_lock = Mock() + self.mock.send_buffer = '' + + network.Connection.queue_send(self.mock, 'data') + self.mock.send_lock.acquire.assert_called_once_with(True) + self.mock.send_lock.release.assert_called_once_with() + + def test_queue_send_calls_send(self): + self.mock.send_buffer = '' + self.mock.send_lock = Mock() + self.mock.send.return_value = '' + + network.Connection.queue_send(self.mock, 'data') + self.mock.send.assert_called_once_with('data') + self.assertEqual(0, self.mock.enable_send.call_count) + self.assertEqual('', self.mock.send_buffer) + + def test_queue_send_calls_enable_send_for_partial_send(self): + self.mock.send_buffer = '' + self.mock.send_lock = Mock() + self.mock.send.return_value = 'ta' + + network.Connection.queue_send(self.mock, 'data') + self.mock.send.assert_called_once_with('data') + self.mock.enable_send.assert_called_once_with() + self.assertEqual('ta', self.mock.send_buffer) + + def test_queue_send_calls_send_with_existing_buffer(self): + self.mock.send_buffer = 'foo' + self.mock.send_lock = Mock() + self.mock.send.return_value = '' + + network.Connection.queue_send(self.mock, 'bar') + self.mock.send.assert_called_once_with('foobar') + self.assertEqual(0, self.mock.enable_send.call_count) + self.assertEqual('', self.mock.send_buffer) + + def test_recv_callback_respects_io_err(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.actor_ref = Mock() + + self.assertTrue(network.Connection.recv_callback(self.mock, + sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_recv_callback_respects_io_hup(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.actor_ref = Mock() + + self.assertTrue(network.Connection.recv_callback(self.mock, + sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_recv_callback_respects_io_hup_and_io_err(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.actor_ref = Mock() + + self.assertTrue(network.Connection.recv_callback(self.mock, + sentinel.fd, gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_recv_callback_sends_data_to_actor(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.recv.return_value = 'data' + self.mock.actor_ref = Mock() + + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.actor_ref.send_one_way.assert_called_once_with( + {'received': 'data'}) + + def test_recv_callback_handles_dead_actors(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.recv.return_value = 'data' + self.mock.actor_ref = Mock() + self.mock.actor_ref.send_one_way.side_effect = pykka.ActorDeadError() + + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_recv_callback_gets_no_data(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.recv.return_value = '' + self.mock.actor_ref = Mock() + + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_recv_callback_recoverable_error(self): + self.mock.sock = Mock(spec=socket.SocketType) + + for error in (errno.EWOULDBLOCK, errno.EINTR): + self.mock.sock.recv.side_effect = socket.error(error, '') + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.assertEqual(0, self.mock.stop.call_count) + + def test_recv_callback_unrecoverable_error(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.recv.side_effect = socket.error + + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_send_callback_respects_io_err(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 1 + self.mock.send_lock = Mock() + self.mock.actor_ref = Mock() + self.mock.send_buffer = '' + + self.assertTrue(network.Connection.send_callback(self.mock, + sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_send_callback_respects_io_hup(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 1 + self.mock.send_lock = Mock() + self.mock.actor_ref = Mock() + self.mock.send_buffer = '' + + self.assertTrue(network.Connection.send_callback(self.mock, + sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_send_callback_respects_io_hup_and_io_err(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 1 + self.mock.send_lock = Mock() + self.mock.actor_ref = Mock() + self.mock.send_buffer = '' + + self.assertTrue(network.Connection.send_callback(self.mock, + sentinel.fd, gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_send_callback_acquires_and_releases_lock(self): + self.mock.send_lock = Mock() + self.mock.send_lock.acquire.return_value = True + self.mock.send_buffer = '' + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 0 + + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.send_lock.acquire.assert_called_once_with(False) + self.mock.send_lock.release.assert_called_once_with() + + def test_send_callback_fails_to_acquire_lock(self): + self.mock.send_lock = Mock() + self.mock.send_lock.acquire.return_value = False + self.mock.send_buffer = '' + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 0 + + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.send_lock.acquire.assert_called_once_with(False) + self.assertEqual(0, self.mock.sock.send.call_count) + + def test_send_callback_sends_all_data(self): + self.mock.send_lock = Mock() + self.mock.send_lock.acquire.return_value = True + self.mock.send_buffer = 'data' + self.mock.send.return_value = '' + + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.disable_send.assert_called_once_with() + self.mock.send.assert_called_once_with('data') + self.assertEqual('', self.mock.send_buffer) + + def test_send_callback_sends_partial_data(self): + self.mock.send_lock = Mock() + self.mock.send_lock.acquire.return_value = True + self.mock.send_buffer = 'data' + self.mock.send.return_value = 'ta' + + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.send.assert_called_once_with('data') + self.assertEqual('ta', self.mock.send_buffer) + + def test_send_recoverable_error(self): + self.mock.sock = Mock(spec=socket.SocketType) + + for error in (errno.EWOULDBLOCK, errno.EINTR): + self.mock.sock.send.side_effect = socket.error(error, '') + + network.Connection.send(self.mock, 'data') + self.assertEqual(0, self.mock.stop.call_count) + + def test_send_calls_socket_send(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 4 + + self.assertEqual('', network.Connection.send(self.mock, 'data')) + self.mock.sock.send.assert_called_once_with('data') + + def test_send_calls_socket_send_partial_send(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 2 + + self.assertEqual('ta', network.Connection.send(self.mock, 'data')) + self.mock.sock.send.assert_called_once_with('data') + + def test_send_unrecoverable_error(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.side_effect = socket.error + + self.assertEqual('', network.Connection.send(self.mock, 'data')) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_timeout_callback(self): + self.mock.timeout = 10 + + self.assertFalse(network.Connection.timeout_callback(self.mock)) + self.mock.stop.assert_called_once_with(any_unicode) diff --git a/tests/utils/network/lineprotocol_test.py b/tests/utils/network/lineprotocol_test.py new file mode 100644 index 00000000..b323de09 --- /dev/null +++ b/tests/utils/network/lineprotocol_test.py @@ -0,0 +1,290 @@ +#encoding: utf-8 + +import re +from mock import sentinel, Mock + +from mopidy.utils import network + +from tests import unittest + + +class LineProtocolTest(unittest.TestCase): + def setUp(self): + self.mock = Mock(spec=network.LineProtocol) + + self.mock.terminator = network.LineProtocol.terminator + self.mock.encoding = network.LineProtocol.encoding + self.mock.delimeter = network.LineProtocol.delimeter + self.mock.prevent_timeout = False + + def test_init_stores_values_in_attributes(self): + delimeter = re.compile(network.LineProtocol.terminator) + network.LineProtocol.__init__(self.mock, sentinel.connection) + self.assertEqual(sentinel.connection, self.mock.connection) + self.assertEqual('', self.mock.recv_buffer) + self.assertEqual(delimeter, self.mock.delimeter) + self.assertFalse(self.mock.prevent_timeout) + + def test_init_compiles_delimeter(self): + self.mock.delimeter = '\r?\n' + delimeter = re.compile('\r?\n') + + network.LineProtocol.__init__(self.mock, sentinel.connection) + self.assertEqual(delimeter, self.mock.delimeter) + + def test_on_receive_no_new_lines_adds_to_recv_buffer(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [] + + network.LineProtocol.on_receive(self.mock, {'received': 'data'}) + self.assertEqual('data', self.mock.recv_buffer) + self.mock.parse_lines.assert_called_once_with() + self.assertEqual(0, self.mock.on_line_received.call_count) + + def test_on_receive_toggles_timeout(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [] + + network.LineProtocol.on_receive(self.mock, {'received': 'data'}) + self.mock.connection.disable_timeout.assert_called_once_with() + self.mock.connection.enable_timeout.assert_called_once_with() + + def test_on_receive_toggles_unless_prevent_timeout_is_set(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [] + self.mock.prevent_timeout = True + + network.LineProtocol.on_receive(self.mock, {'received': 'data'}) + self.mock.connection.disable_timeout.assert_called_once_with() + self.assertEqual(0, self.mock.connection.enable_timeout.call_count) + + def test_on_receive_no_new_lines_calls_parse_lines(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [] + + network.LineProtocol.on_receive(self.mock, {'received': 'data'}) + self.mock.parse_lines.assert_called_once_with() + self.assertEqual(0, self.mock.on_line_received.call_count) + + def test_on_receive_with_new_line_calls_decode(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [sentinel.line] + + network.LineProtocol.on_receive(self.mock, {'received': 'data\n'}) + self.mock.parse_lines.assert_called_once_with() + self.mock.decode.assert_called_once_with(sentinel.line) + + def test_on_receive_with_new_line_calls_on_recieve(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [sentinel.line] + self.mock.decode.return_value = sentinel.decoded + + network.LineProtocol.on_receive(self.mock, {'received': 'data\n'}) + self.mock.on_line_received.assert_called_once_with(sentinel.decoded) + + def test_on_receive_with_new_line_with_failed_decode(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [sentinel.line] + self.mock.decode.return_value = None + + network.LineProtocol.on_receive(self.mock, {'received': 'data\n'}) + self.assertEqual(0, self.mock.on_line_received.call_count) + + def test_on_receive_with_new_lines_calls_on_recieve(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = ['line1', 'line2'] + self.mock.decode.return_value = sentinel.decoded + + network.LineProtocol.on_receive(self.mock, + {'received': 'line1\nline2\n'}) + self.assertEqual(2, self.mock.on_line_received.call_count) + + def test_parse_lines_emtpy_buffer(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = '' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertRaises(StopIteration, lines.next) + + def test_parse_lines_no_terminator(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = 'data' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertRaises(StopIteration, lines.next) + + def test_parse_lines_termintor(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = 'data\n' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual('data', lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('', self.mock.recv_buffer) + + def test_parse_lines_termintor_with_carriage_return(self): + self.mock.delimeter = re.compile(r'\r?\n') + self.mock.recv_buffer = 'data\r\n' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual('data', lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('', self.mock.recv_buffer) + + def test_parse_lines_no_data_before_terminator(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = '\n' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual('', lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('', self.mock.recv_buffer) + + def test_parse_lines_extra_data_after_terminator(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = 'data1\ndata2' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual('data1', lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('data2', self.mock.recv_buffer) + + def test_parse_lines_unicode(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = u'æøå\n'.encode('utf-8') + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual(u'æøå'.encode('utf-8'), lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('', self.mock.recv_buffer) + + def test_parse_lines_multiple_lines(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = 'abc\ndef\nghi\njkl' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual('abc', lines.next()) + self.assertEqual('def', lines.next()) + self.assertEqual('ghi', lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('jkl', self.mock.recv_buffer) + + def test_parse_lines_multiple_calls(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = 'data1' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('data1', self.mock.recv_buffer) + + self.mock.recv_buffer += '\ndata2' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual('data1', lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('data2', self.mock.recv_buffer) + + def test_send_lines_called_with_no_lines(self): + self.mock.connection = Mock(spec=network.Connection) + + network.LineProtocol.send_lines(self.mock, []) + self.assertEqual(0, self.mock.encode.call_count) + self.assertEqual(0, self.mock.connection.queue_send.call_count) + + def test_send_lines_calls_join_lines(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.join_lines.return_value = 'lines' + + network.LineProtocol.send_lines(self.mock, sentinel.lines) + self.mock.join_lines.assert_called_once_with(sentinel.lines) + + def test_send_line_encodes_joined_lines_with_final_terminator(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.join_lines.return_value = u'lines\n' + + network.LineProtocol.send_lines(self.mock, sentinel.lines) + self.mock.encode.assert_called_once_with(u'lines\n') + + def test_send_lines_sends_encoded_string(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.join_lines.return_value = 'lines' + self.mock.encode.return_value = sentinel.data + + network.LineProtocol.send_lines(self.mock, sentinel.lines) + self.mock.connection.queue_send.assert_called_once_with(sentinel.data) + + def test_join_lines_returns_empty_string_for_no_lines(self): + self.assertEqual(u'', network.LineProtocol.join_lines(self.mock, [])) + + def test_join_lines_returns_joined_lines(self): + self.assertEqual(u'1\n2\n', network.LineProtocol.join_lines( + self.mock, [u'1', u'2'])) + + def test_decode_calls_decode_on_string(self): + string = Mock() + + network.LineProtocol.decode(self.mock, string) + string.decode.assert_called_once_with(self.mock.encoding) + + def test_decode_plain_ascii(self): + result = network.LineProtocol.decode(self.mock, 'abc') + self.assertEqual(u'abc', result) + self.assertEqual(unicode, type(result)) + + def test_decode_utf8(self): + result = network.LineProtocol.decode( + self.mock, u'æøå'.encode('utf-8')) + self.assertEqual(u'æøå', result) + self.assertEqual(unicode, type(result)) + + def test_decode_invalid_data(self): + string = Mock() + string.decode.side_effect = UnicodeError + + network.LineProtocol.decode(self.mock, string) + self.mock.stop.assert_called_once_with() + + def test_encode_calls_encode_on_string(self): + string = Mock() + + network.LineProtocol.encode(self.mock, string) + string.encode.assert_called_once_with(self.mock.encoding) + + def test_encode_plain_ascii(self): + result = network.LineProtocol.encode(self.mock, u'abc') + self.assertEqual('abc', result) + self.assertEqual(str, type(result)) + + def test_encode_utf8(self): + result = network.LineProtocol.encode(self.mock, u'æøå') + self.assertEqual(u'æøå'.encode('utf-8'), result) + self.assertEqual(str, type(result)) + + def test_encode_invalid_data(self): + string = Mock() + string.encode.side_effect = UnicodeError + + network.LineProtocol.encode(self.mock, string) + self.mock.stop.assert_called_once_with() + + def test_host_property(self): + mock = Mock(spec=network.Connection) + mock.host = sentinel.host + + lineprotocol = network.LineProtocol(mock) + self.assertEqual(sentinel.host, lineprotocol.host) + + def test_port_property(self): + mock = Mock(spec=network.Connection) + mock.port = sentinel.port + + lineprotocol = network.LineProtocol(mock) + self.assertEqual(sentinel.port, lineprotocol.port) diff --git a/tests/utils/network/server_test.py b/tests/utils/network/server_test.py new file mode 100644 index 00000000..e0399525 --- /dev/null +++ b/tests/utils/network/server_test.py @@ -0,0 +1,186 @@ +import errno +import gobject +import socket +from mock import patch, sentinel, Mock + +from mopidy.utils import network + +from tests import unittest, any_int + + +class ServerTest(unittest.TestCase): + def setUp(self): + self.mock = Mock(spec=network.Server) + + def test_init_calls_create_server_socket(self): + network.Server.__init__(self.mock, sentinel.host, + sentinel.port, sentinel.protocol) + self.mock.create_server_socket.assert_called_once_with( + sentinel.host, sentinel.port) + + def test_init_calls_register_server(self): + sock = Mock(spec=socket.SocketType) + sock.fileno.return_value = sentinel.fileno + self.mock.create_server_socket.return_value = sock + + network.Server.__init__(self.mock, sentinel.host, + sentinel.port, sentinel.protocol) + self.mock.register_server_socket.assert_called_once_with( + sentinel.fileno) + + def test_init_fails_on_fileno_call(self): + sock = Mock(spec=socket.SocketType) + sock.fileno.side_effect = socket.error + self.mock.create_server_socket.return_value = sock + + self.assertRaises(socket.error, network.Server.__init__, + self.mock, sentinel.host, sentinel.port, sentinel.protocol) + + def test_init_stores_values_in_attributes(self): + # This need to be a mock and no a sentinel as fileno() is called on it + sock = Mock(spec=socket.SocketType) + self.mock.create_server_socket.return_value = sock + + network.Server.__init__(self.mock, sentinel.host, sentinel.port, + sentinel.protocol, max_connections=sentinel.max_connections, + timeout=sentinel.timeout) + self.assertEqual(sentinel.protocol, self.mock.protocol) + self.assertEqual(sentinel.max_connections, self.mock.max_connections) + self.assertEqual(sentinel.timeout, self.mock.timeout) + self.assertEqual(sock, self.mock.server_socket) + + @patch.object(network, 'create_socket', spec=socket.SocketType) + def test_create_server_socket_sets_up_listener(self, create_socket): + sock = create_socket.return_value + + network.Server.create_server_socket(self.mock, + sentinel.host, sentinel.port) + sock.setblocking.assert_called_once_with(False) + sock.bind.assert_called_once_with((sentinel.host, sentinel.port)) + sock.listen.assert_called_once_with(any_int) + + @patch.object(network, 'create_socket', new=Mock()) + def test_create_server_socket_fails(self): + network.create_socket.side_effect = socket.error + self.assertRaises(socket.error, network.Server.create_server_socket, + self.mock, sentinel.host, sentinel.port) + + @patch.object(network, 'create_socket', new=Mock()) + def test_create_server_bind_fails(self): + sock = network.create_socket.return_value + sock.bind.side_effect = socket.error + + self.assertRaises(socket.error, network.Server.create_server_socket, + self.mock, sentinel.host, sentinel.port) + + @patch.object(network, 'create_socket', new=Mock()) + def test_create_server_listen_fails(self): + sock = network.create_socket.return_value + sock.listen.side_effect = socket.error + + self.assertRaises(socket.error, network.Server.create_server_socket, + self.mock, sentinel.host, sentinel.port) + + @patch.object(gobject, 'io_add_watch', new=Mock()) + def test_register_server_socket_sets_up_io_watch(self): + network.Server.register_server_socket(self.mock, sentinel.fileno) + gobject.io_add_watch.assert_called_once_with(sentinel.fileno, + gobject.IO_IN, self.mock.handle_connection) + + def test_handle_connection(self): + self.mock.accept_connection.return_value = ( + sentinel.sock, sentinel.addr) + self.mock.maximum_connections_exceeded.return_value = False + + self.assertTrue(network.Server.handle_connection( + self.mock, sentinel.fileno, gobject.IO_IN)) + self.mock.accept_connection.assert_called_once_with() + self.mock.maximum_connections_exceeded.assert_called_once_with() + self.mock.init_connection.assert_called_once_with( + sentinel.sock, sentinel.addr) + self.assertEqual(0, self.mock.reject_connection.call_count) + + def test_handle_connection_exceeded_connections(self): + self.mock.accept_connection.return_value = ( + sentinel.sock, sentinel.addr) + self.mock.maximum_connections_exceeded.return_value = True + + self.assertTrue(network.Server.handle_connection( + self.mock, sentinel.fileno, gobject.IO_IN)) + self.mock.accept_connection.assert_called_once_with() + self.mock.maximum_connections_exceeded.assert_called_once_with() + self.mock.reject_connection.assert_called_once_with( + sentinel.sock, sentinel.addr) + self.assertEqual(0, self.mock.init_connection.call_count) + + def test_accept_connection(self): + sock = Mock(spec=socket.SocketType) + sock.accept.return_value = (sentinel.sock, sentinel.addr) + self.mock.server_socket = sock + + sock, addr = network.Server.accept_connection(self.mock) + self.assertEqual(sentinel.sock, sock) + self.assertEqual(sentinel.addr, addr) + + def test_accept_connection_recoverable_error(self): + sock = Mock(spec=socket.SocketType) + self.mock.server_socket = sock + + for error in (errno.EAGAIN, errno.EINTR): + sock.accept.side_effect = socket.error(error, '') + self.assertRaises(network.ShouldRetrySocketCall, + network.Server.accept_connection, self.mock) + + # FIXME decide if this should be allowed to propegate + def test_accept_connection_unrecoverable_error(self): + sock = Mock(spec=socket.SocketType) + self.mock.server_socket = sock + sock.accept.side_effect = socket.error + self.assertRaises(socket.error, + network.Server.accept_connection, self.mock) + + def test_maximum_connections_exceeded(self): + self.mock.max_connections = 10 + + self.mock.number_of_connections.return_value = 11 + self.assertTrue(network.Server.maximum_connections_exceeded(self.mock)) + + self.mock.number_of_connections.return_value = 10 + self.assertTrue(network.Server.maximum_connections_exceeded(self.mock)) + + self.mock.number_of_connections.return_value = 9 + self.assertFalse(network.Server.maximum_connections_exceeded(self.mock)) + + @patch('pykka.registry.ActorRegistry.get_by_class') + def test_number_of_connections(self, get_by_class): + self.mock.protocol = sentinel.protocol + + get_by_class.return_value = [1, 2, 3] + self.assertEqual(3, network.Server.number_of_connections(self.mock)) + + get_by_class.return_value = [] + self.assertEqual(0, network.Server.number_of_connections(self.mock)) + + @patch.object(network, 'Connection', new=Mock()) + def test_init_connection(self): + self.mock.protocol = sentinel.protocol + self.mock.timeout = sentinel.timeout + + network.Server.init_connection(self.mock, sentinel.sock, sentinel.addr) + network.Connection.assert_called_once_with(sentinel.protocol, + sentinel.sock, sentinel.addr, sentinel.timeout) + + def test_reject_connection(self): + sock = Mock(spec=socket.SocketType) + + network.Server.reject_connection(self.mock, sock, + (sentinel.host, sentinel.port)) + sock.close.assert_called_once_with() + + def test_reject_connection_error(self): + sock = Mock(spec=socket.SocketType) + sock.close.side_effect = socket.error + + network.Server.reject_connection(self.mock, sock, + (sentinel.host, sentinel.port)) + sock.close.assert_called_once_with() diff --git a/tests/utils/network_test.py b/tests/utils/network/utils_test.py similarity index 58% rename from tests/utils/network_test.py rename to tests/utils/network/utils_test.py index 66229036..1e11673e 100644 --- a/tests/utils/network_test.py +++ b/tests/utils/network/utils_test.py @@ -1,57 +1,57 @@ -import mock import socket -import unittest +from mock import patch, Mock from mopidy.utils import network -from tests import SkipTest +from tests import unittest + class FormatHostnameTest(unittest.TestCase): - @mock.patch('mopidy.utils.network.has_ipv6', True) + @patch('mopidy.utils.network.has_ipv6', True) def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): network.has_ipv6 = True self.assertEqual(network.format_hostname('0.0.0.0'), '::ffff:0.0.0.0') self.assertEqual(network.format_hostname('1.0.0.1'), '::ffff:1.0.0.1') - @mock.patch('mopidy.utils.network.has_ipv6', False) + @patch('mopidy.utils.network.has_ipv6', False) def test_format_hostname_does_nothing_when_only_ipv4_available(self): network.has_ipv6 = False - self.assertEquals(network.format_hostname('0.0.0.0'), '0.0.0.0') + self.assertEqual(network.format_hostname('0.0.0.0'), '0.0.0.0') class TryIPv6SocketTest(unittest.TestCase): - @mock.patch('socket.has_ipv6', False) + @patch('socket.has_ipv6', False) def test_system_that_claims_no_ipv6_support(self): - self.assertFalse(network._try_ipv6_socket()) + self.assertFalse(network.try_ipv6_socket()) - @mock.patch('socket.has_ipv6', True) - @mock.patch('socket.socket') + @patch('socket.has_ipv6', True) + @patch('socket.socket') def test_system_with_broken_ipv6(self, socket_mock): socket_mock.side_effect = IOError() - self.assertFalse(network._try_ipv6_socket()) + self.assertFalse(network.try_ipv6_socket()) - @mock.patch('socket.has_ipv6', True) - @mock.patch('socket.socket') + @patch('socket.has_ipv6', True) + @patch('socket.socket') def test_with_working_ipv6(self, socket_mock): - socket_mock.return_value = mock.Mock() - self.assertTrue(network._try_ipv6_socket()) + socket_mock.return_value = Mock() + self.assertTrue(network.try_ipv6_socket()) class CreateSocketTest(unittest.TestCase): - @mock.patch('mopidy.utils.network.has_ipv6', False) - @mock.patch('socket.socket') + @patch('mopidy.utils.network.has_ipv6', False) + @patch('socket.socket') def test_ipv4_socket(self, socket_mock): network.create_socket() self.assertEqual(socket_mock.call_args[0], (socket.AF_INET, socket.SOCK_STREAM)) - @mock.patch('mopidy.utils.network.has_ipv6', True) - @mock.patch('socket.socket') + @patch('mopidy.utils.network.has_ipv6', True) + @patch('socket.socket') def test_ipv6_socket(self, socket_mock): network.create_socket() self.assertEqual(socket_mock.call_args[0], (socket.AF_INET6, socket.SOCK_STREAM)) - @SkipTest + @unittest.SkipTest def test_ipv6_only_is_set(self): pass diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 088a7049..ba1fcf97 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -4,12 +4,12 @@ import os import shutil import sys import tempfile -import unittest from mopidy.utils.path import (get_or_create_folder, mtime, path_to_uri, uri_to_path, split_path, find_files) -from tests import path_to_data_dir +from tests import unittest, path_to_data_dir + class GetOrCreateFolderTest(unittest.TestCase): def setUp(self): diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 973c2280..55e1156b 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -1,10 +1,12 @@ import os -import unittest from mopidy import settings as default_settings_module, SettingsError from mopidy.utils.settings import (format_settings_list, mask_value_if_secret, SettingsProxy, validate_settings) +from tests import unittest + + class ValidateSettingsTest(unittest.TestCase): def setUp(self): self.defaults = { @@ -150,6 +152,14 @@ class SettingsProxyTest(unittest.TestCase): actual = self.settings.TEST self.assertEqual(actual, './test') + def test_value_ending_in_file_can_be_none(self): + self.settings.TEST_FILE = None + self.assertEqual(self.settings.TEST_FILE, None) + + def test_value_ending_in_path_can_be_none(self): + self.settings.TEST_PATH = None + self.assertEqual(self.settings.TEST_PATH, None) + def test_interactive_input_of_missing_defaults(self): self.settings.default['TEST'] = '' interactive_input = 'input' diff --git a/tests/version_test.py b/tests/version_test.py index 7bfb540e..4544349d 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -1,8 +1,10 @@ from distutils.version import StrictVersion as SV -import unittest import platform -from mopidy import get_version, get_plain_version, get_platform, get_python +from mopidy import get_plain_version, get_platform, get_python + +from tests import unittest + class VersionTest(unittest.TestCase): def test_current_version_is_parsable_as_a_strict_version_number(self): @@ -19,8 +21,9 @@ class VersionTest(unittest.TestCase): self.assert_(SV('0.3.0') < SV('0.3.1')) self.assert_(SV('0.3.1') < SV('0.4.0')) self.assert_(SV('0.4.0') < SV('0.4.1')) - self.assert_(SV('0.4.1') < SV(get_plain_version())) - self.assert_(SV(get_plain_version()) < SV('0.5.1')) + self.assert_(SV('0.4.1') < SV('0.5.0')) + self.assert_(SV('0.5.0') < SV(get_plain_version())) + self.assert_(SV(get_plain_version()) < SV('0.6.1')) def test_get_platform_contains_platform(self): self.assert_(platform.platform() in get_platform()) diff --git a/tools/idle.py b/tools/idle.py new file mode 100644 index 00000000..aa56dce2 --- /dev/null +++ b/tools/idle.py @@ -0,0 +1,201 @@ +#! /usr/bin/env python + +# This script is helper to systematicly test the behaviour of MPD's idle +# command. It is simply provided as a quick hack, expect nothing more. + +import logging +import pprint +import socket + +host = '' +port = 6601 + +url = "13 - a-ha - White Canvas.mp3" +artist = "a-ha" + +data = {'id': None, 'id2': None, 'url': url, 'artist': artist} + +# Commands to run before test requests to coerce MPD into right state +setup_requests = [ + 'clear', + 'add "%(url)s"', + 'add "%(url)s"', + 'add "%(url)s"', + 'play', +# 'pause', # Uncomment to test paused idle behaviour +# 'stop', # Uncomment to test stopped idle behaviour +] + +# List of commands to test for idle behaviour. Ordering of list is important in +# order to keep MPD state as intended. Commands that are obviously +# informational only or "harmfull" have been excluded. +test_requests = [ + 'add "%(url)s"', + 'addid "%(url)s" "1"', + 'clear', +# 'clearerror', +# 'close', +# 'commands', + 'consume "1"', + 'consume "0"', +# 'count', + 'crossfade "1"', + 'crossfade "0"', +# 'currentsong', +# 'delete "1:2"', + 'delete "0"', + 'deleteid "%(id)s"', + 'disableoutput "0"', + 'enableoutput "0"', +# 'find', +# 'findadd "artist" "%(artist)s"', +# 'idle', +# 'kill', +# 'list', +# 'listall', +# 'listallinfo', +# 'listplaylist', +# 'listplaylistinfo', +# 'listplaylists', +# 'lsinfo', + 'move "0:1" "2"', + 'move "0" "1"', + 'moveid "%(id)s" "1"', + 'next', +# 'notcommands', +# 'outputs', +# 'password', + 'pause', +# 'ping', + 'play', + 'playid "%(id)s"', +# 'playlist', + 'playlistadd "foo" "%(url)s"', + 'playlistclear "foo"', + 'playlistadd "foo" "%(url)s"', + 'playlistdelete "foo" "0"', +# 'playlistfind', +# 'playlistid', +# 'playlistinfo', + 'playlistadd "foo" "%(url)s"', + 'playlistadd "foo" "%(url)s"', + 'playlistmove "foo" "0" "1"', +# 'playlistsearch', +# 'plchanges', +# 'plchangesposid', + 'previous', + 'random "1"', + 'random "0"', + 'rm "bar"', + 'rename "foo" "bar"', + 'repeat "0"', + 'rm "bar"', + 'save "bar"', + 'load "bar"', +# 'search', + 'seek "1" "10"', + 'seekid "%(id)s" "10"', +# 'setvol "10"', + 'shuffle', + 'shuffle "0:1"', + 'single "1"', + 'single "0"', +# 'stats', +# 'status', + 'stop', + 'swap "1" "2"', + 'swapid "%(id)s" "%(id2)s"', +# 'tagtypes', +# 'update', +# 'urlhandlers', +# 'volume', +] + + +def create_socketfile(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, port)) + sock.settimeout(0.5) + fd = sock.makefile('rw', 1) # 1 = line buffered + fd.readline() # Read banner + return fd + + +def wait(fd, prefix=None, collect=None): + while True: + line = fd.readline().rstrip() + if prefix: + logging.debug('%s: %s', prefix, repr(line)) + if line.split()[0] in ('OK', 'ACK'): + break + + +def collect_ids(fd): + fd.write('playlistinfo\n') + + ids = [] + while True: + line = fd.readline() + if line.split()[0] == 'OK': + break + if line.split()[0] == 'Id:': + ids.append(line.split()[1]) + return ids + + +def main(): + subsystems = {} + + command = create_socketfile() + + for test in test_requests: + # Remove any old ids + del data['id'] + del data['id2'] + + # Run setup code to force MPD into known state + for setup in setup_requests: + command.write(setup % data + '\n') + wait(command) + + data['id'], data['id2'] = collect_ids(command)[:2] + + # This connection needs to be make after setup commands are done or + # else they will cause idle events. + idle = create_socketfile() + + # Wait for new idle events + idle.write('idle\n') + + test = test % data + + logging.debug('idle: %s', repr('idle')) + logging.debug('command: %s', repr(test)) + + command.write(test + '\n') + wait(command, prefix='command') + + while True: + try: + line = idle.readline().rstrip() + except socket.timeout: + # Abort try if we time out. + idle.write('noidle\n') + break + + logging.debug('idle: %s', repr(line)) + + if line == 'OK': + break + + request_type = test.split()[0] + subsystem = line.split()[1] + subsystems.setdefault(request_type, set()).add(subsystem) + + logging.debug('---') + + pprint.pprint(subsystems) + + +if __name__ == '__main__': + main()