diff --git a/README.rst b/README.rst index 6855135e..c6187119 100644 --- a/README.rst +++ b/README.rst @@ -11,8 +11,9 @@ platforms, including Windows, Mac OS X, Linux, and iPhone and Android phones. To install Mopidy, check out `the installation docs `_. -* `Documentation `_ +* `Documentation (latest release) `_ * `Documentation (development version) `_ * `Source code `_ * `Issue tracker `_ * IRC: ``#mopidy`` at `irc.freenode.net `_ +* `Download development snapshot `_ diff --git a/docs/changes.rst b/docs/changes.rst index dadbb6b9..cb34993e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,31 +5,82 @@ Changes This change log is used to track all major changes to Mopidy. -0.2.0 (in development) +0.3.0 (in development) ====================== No description yet. +**Changes** + +- None so far. + + +0.2.0 (2010-10-24) +================== + +In Mopidy 0.2.0 we've added a `Last.fm `_ scrobbling +support, which means that Mopidy now can submit meta data about the tracks you +play to your Last.fm profile. See :mod:`mopidy.frontends.lastfm` for +details on new dependencies and settings. If you use Mopidy's Last.fm support, +please join the `Mopidy group at Last.fm `_. + +With the exception of the work on the Last.fm scrobbler, there has been a +couple of quiet months in the Mopidy camp. About the only thing going on, has +been stabilization work and bug fixing. All bugs reported on GitHub, plus some, +have been fixed in 0.2.0. Thus, we hope this will be a great release! + +We've worked a bit on OS X support, but not all issues are completely solved +yet. :issue:`25` is the one that is currently blocking OS X support. Any help +solving it will be greatly appreciated! + +Finally, please :ref:`update your pyspotify installation +` when upgrading to Mopidy 0.2.0. The latest pyspotify +got a fix for the segmentation fault that occurred when playing music and +searching at the same time, thanks to Valentin David. + **Important changes** - Added a Last.fm scrobbler. See :mod:`mopidy.frontends.lastfm` for details. **Changes** -- Simplify the default log format, :attr:`mopidy.settings.CONSOLE_LOG_FORMAT`. - From a user's point of view: Less noise, more information. -- Rename the :option:`--dump` command line option to - :option:`--save-debug-log`. -- Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to - :attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for :option:`--verbose` - too. -- Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to - :attr:`mopidy.settings.DEBUG_LOG_FILENAME`. -- Switched from using subprocesses to threads. This partly fixes the OS X - support. See :issue:`14` for details. +- Logging and command line options: + + - Simplify the default log format, + :attr:`mopidy.settings.CONSOLE_LOG_FORMAT`. From a user's point of view: + Less noise, more information. + - Rename the :option:`--dump` command line option to + :option:`--save-debug-log`. + - Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to + :attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for :option:`--verbose` + too. + - Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to + :attr:`mopidy.settings.DEBUG_LOG_FILENAME`. + - MPD frontend: - - ``add ""`` and ``addid ""`` now behaves as expected. + - MPD command ``list`` now supports queries by artist, album name, and date, + as used by e.g. the Ario client. (Fixes: :issue:`20`) + - MPD command ``add ""`` and ``addid ""`` now behaves as expected. (Fixes + :issue:`16`) + - MPD command ``playid "-1"`` now correctly resumes playback if paused. + +- Random mode: + + - Fix wrong behavior on end of track and next after random mode has been + used. (Fixes: :issue:`18`) + - Fix infinite recursion loop crash on playback of non-playable tracks when + in random mode. (Fixes :issue:`17`) + - Fix assertion error that happened if one removed tracks from the current + playlist, while in random mode. (Fixes :issue:`22`) + +- Switched from using subprocesses to threads. (Fixes: :issue:`14`) +- :mod:`mopidy.outputs.gstreamer`: Set ``caps`` on the ``appsrc`` bin before + use. This makes sound output work with GStreamer >= 0.10.29, which includes + the versions used in Ubuntu 10.10 and on OS X if using Homebrew. (Fixes: + :issue:`21`, :issue:`24`, contributes to :issue:`14`) +- Improved handling of uncaught exceptions in threads. The entire process + should now exit immediately. 0.1.0 (2010-08-23) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index eac94799..4adde637 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -151,20 +151,25 @@ Then, to generate docs:: Creating releases ================= -1. Update changelog and commit it. +#. Update changelog and commit it. -2. Tag release:: +#. Merge the release branch (``develop`` in the example) into master:: - git tag -a -m "Release v0.1.0a0" v0.1.0a0 + git checkout master + git merge --no-ff -m "Release v0.2.0" develop -3. Push to GitHub:: +#. Tag the release:: + + git tag -a -m "Release v0.2.0" v0.2.0 + +#. Push to GitHub:: git push git push --tags -4. Build package and upload to PyPI:: +#. Build package and upload to PyPI:: rm MANIFEST # Will be regenerated by setup.py python setup.py sdist upload -5. Spread the word. +#. Spread the word. diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 7d3052c4..fffa25c7 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -3,7 +3,7 @@ if not (2, 6) <= sys.version_info < (3,): sys.exit(u'Mopidy requires Python >= 2.6, < 3') def get_version(): - return u'0.2.0a1' + return u'0.3.0' class MopidyException(Exception): def __init__(self, message, *args, **kwargs): diff --git a/mopidy/backends/base/current_playlist.py b/mopidy/backends/base/current_playlist.py index c8c83a62..34a16369 100644 --- a/mopidy/backends/base/current_playlist.py +++ b/mopidy/backends/base/current_playlist.py @@ -12,13 +12,10 @@ class BaseCurrentPlaylistController(object): :type backend: :class:`BaseBackend` """ - #: The current playlist version. Integer which is increased every time the - #: current playlist is changed. Is not reset before Mopidy is restarted. - version = 0 - def __init__(self, backend): self.backend = backend self._cp_tracks = [] + self._version = 0 def destroy(self): """Cleanup after component.""" @@ -42,6 +39,19 @@ class BaseCurrentPlaylistController(object): """ return [ct[1] for ct in self._cp_tracks] + @property + def version(self): + """ + The current playlist version. Integer which is increased every time the + current playlist is changed. Is not reset before Mopidy is restarted. + """ + return self._version + + @version.setter + def version(self, version): + self._version = version + self.backend.playback.on_current_playlist_change() + def add(self, track, at_position=None): """ Add the track to the end of, or at the given position in the current @@ -71,16 +81,13 @@ class BaseCurrentPlaylistController(object): :param tracks: tracks to append :type tracks: list of :class:`mopidy.models.Track` """ - self.version += 1 for track in tracks: self.add(track) - self.backend.playback.on_current_playlist_change() def clear(self): """Clear the current playlist.""" self._cp_tracks = [] self.version += 1 - self.backend.playback.on_current_playlist_change() def get(self, **criteria): """ @@ -146,7 +153,6 @@ class BaseCurrentPlaylistController(object): to_position += 1 self._cp_tracks = new_cp_tracks self.version += 1 - self.backend.playback.on_current_playlist_change() def remove(self, **criteria): """ @@ -191,7 +197,6 @@ class BaseCurrentPlaylistController(object): random.shuffle(shuffled) self._cp_tracks = before + shuffled + after self.version += 1 - self.backend.playback.on_current_playlist_change() def mpd_format(self, *args, **kwargs): """Not a part of the generic backend API.""" diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 3c887120..b7ceeee2 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -10,6 +10,9 @@ class BasePlaybackController(object): :type backend: :class:`BaseBackend` """ + # pylint: disable = R0902 + # Too many instance attributes + #: Constant representing the paused state. PAUSED = u'paused' @@ -130,6 +133,9 @@ class BasePlaybackController(object): Not necessarily the same track as :attr:`cp_track_at_next`. """ + # pylint: disable = R0911 + # Too many return statements + cp_tracks = self.backend.current_playlist.cp_tracks if not cp_tracks: @@ -142,17 +148,16 @@ class BasePlaybackController(object): random.shuffle(self._shuffled) self._first_shuffle = False - if self._shuffled: + if self.random and self._shuffled: return self._shuffled[0] if self.current_cp_track is None: return cp_tracks[0] if self.repeat and self.single: - return cp_tracks[ - (self.current_playlist_position) % len(cp_tracks)] + return cp_tracks[self.current_playlist_position] - if self.repeat: + if self.repeat and not self.single: return cp_tracks[ (self.current_playlist_position + 1) % len(cp_tracks)] @@ -195,7 +200,7 @@ class BasePlaybackController(object): random.shuffle(self._shuffled) self._first_shuffle = False - if self._shuffled: + if self.random and self._shuffled: return self._shuffled[0] if self.current_cp_track is None: @@ -315,11 +320,8 @@ class BasePlaybackController(object): if self.cp_track_at_eot: self._trigger_stopped_playing_event() self.play(self.cp_track_at_eot) - if self.random and self.current_cp_track in self._shuffled: - self._shuffled.remove(self.current_cp_track) else: - self.stop() - self.current_cp_track = None + self.stop(clear_current_track=True) if self.consume: self.backend.current_playlist.remove(cpid=original_cp_track[0]) @@ -333,13 +335,10 @@ class BasePlaybackController(object): self._first_shuffle = True self._shuffled = [] - if not self.backend.current_playlist.cp_tracks: - self.stop() - self.current_cp_track = None - elif (self.current_cp_track not in + if (not self.backend.current_playlist.cp_tracks or + self.current_cp_track not in self.backend.current_playlist.cp_tracks): - self.current_cp_track = None - self.stop() + self.stop(clear_current_track=True) def next(self): """Play the next track.""" @@ -350,11 +349,7 @@ class BasePlaybackController(object): self._trigger_stopped_playing_event() self.play(self.cp_track_at_next) else: - self.stop() - self.current_cp_track = None - - if self.random and self.current_cp_track in self._shuffled: - self._shuffled.remove(self.current_cp_track) + self.stop(clear_current_track=True) def pause(self): """Pause playback.""" @@ -385,15 +380,21 @@ class BasePlaybackController(object): if cp_track is not None: assert cp_track in self.backend.current_playlist.cp_tracks - elif not self.current_cp_track: + + if cp_track is None and self.current_cp_track is None: cp_track = self.cp_track_at_next - if self.state == self.PAUSED and cp_track is None: + if cp_track is None and self.state == self.PAUSED: self.resume() - elif cp_track is not None: + + if cp_track is not None: + self.state = self.STOPPED self.current_cp_track = cp_track self.state = self.PLAYING if not self._play(cp_track[1]): + # Track is not playable + if self.random and self._shuffled: + self._shuffled.remove(cp_track) if on_error_step == 1: self.next() elif on_error_step == -1: @@ -477,13 +478,21 @@ class BasePlaybackController(object): """ raise NotImplementedError - def stop(self): - """Stop playing.""" + def stop(self, clear_current_track=False): + """ + Stop playing. + + :param clear_current_track: whether to clear the current track _after_ + stopping + :type clear_current_track: boolean + """ if self.state == self.STOPPED: return self._trigger_stopped_playing_event() if self._stop(): self.state = self.STOPPED + if clear_current_track: + self.current_cp_track = None def _stop(self): """ @@ -501,11 +510,12 @@ class BasePlaybackController(object): For internal use only. Should be called by the backend directly after a track has started playing. """ - self.backend.core_queue.put({ - 'to': 'frontend', - 'command': 'started_playing', - 'track': self.current_track, - }) + if self.current_track is not None: + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'started_playing', + 'track': self.current_track, + }) def _trigger_stopped_playing_event(self): """ @@ -515,9 +525,10 @@ class BasePlaybackController(object): is stopped playing, e.g. at the next, previous, and stop actions and at end-of-track. """ - self.backend.core_queue.put({ - 'to': 'frontend', - 'command': 'stopped_playing', - 'track': self.current_track, - 'stop_position': self.time_position, - }) + if self.current_track is not None: + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'stopped_playing', + 'track': self.current_track, + 'stop_position': self.time_position, + }) diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 6d4f3156..62cbd7e2 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -44,16 +44,19 @@ class DummyLibraryController(BaseLibraryController): class DummyPlaybackController(BasePlaybackController): def _next(self, track): - return True + """Pass None as track to force failure""" + return track is not None def _pause(self): return True def _play(self, track): - return True + """Pass None as track to force failure""" + return track is not None def _previous(self, track): - return True + """Pass None as track to force failure""" + return track is not None def _resume(self): return True diff --git a/mopidy/backends/libspotify/library.py b/mopidy/backends/libspotify/library.py index eb1c24d9..972eaf03 100644 --- a/mopidy/backends/libspotify/library.py +++ b/mopidy/backends/libspotify/library.py @@ -6,6 +6,7 @@ from spotify import Link, SpotifyError from mopidy.backends.base import BaseLibraryController from mopidy.backends.libspotify import ENCODING from mopidy.backends.libspotify.translator import LibspotifyTranslator +from mopidy.models import Playlist logger = logging.getLogger('mopidy.backends.libspotify.library') @@ -28,15 +29,27 @@ class LibspotifyLibraryController(BaseLibraryController): pass # TODO def search(self, **query): + if not query: + # Since we can't search for the entire Spotify library, we return + # all tracks in the stored playlists when the query is empty. + tracks = [] + for playlist in self.backend.stored_playlists.playlists: + tracks += playlist.tracks + return Playlist(tracks=tracks) spotify_query = [] for (field, values) in query.iteritems(): + if field == u'track': + field = u'title' + if field == u'date': + field = u'year' if not hasattr(values, '__iter__'): values = [values] for value in values: - if field == u'track': - field = u'title' if field == u'any': spotify_query.append(value) + elif field == u'year': + value = int(value.split('-')[0]) # Extract year + spotify_query.append(u'%s:%d' % (field, value)) else: spotify_query.append(u'%s:"%s"' % (field, value)) spotify_query = u' '.join(spotify_query) diff --git a/mopidy/backends/libspotify/session_manager.py b/mopidy/backends/libspotify/session_manager.py index 9554fa3f..45841350 100644 --- a/mopidy/backends/libspotify/session_manager.py +++ b/mopidy/backends/libspotify/session_manager.py @@ -11,6 +11,9 @@ from mopidy.utils.process import BaseThread logger = logging.getLogger('mopidy.backends.libspotify.session_manager') +# pylint: disable = R0901 +# LibspotifySessionManager: Too many ancestors (9/7) + class LibspotifySessionManager(SpotifySessionManager, BaseThread): cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) @@ -19,12 +22,8 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread): def __init__(self, username, password, core_queue, output): SpotifySessionManager.__init__(self, username, password) - BaseThread.__init__(self) + BaseThread.__init__(self, core_queue) self.name = 'LibspotifySMThread' - # Run as a daemon thread, so Mopidy won't wait for this thread to exit - # before Mopidy exits. - self.daemon = True - self.core_queue = core_queue self.output = output self.connected = threading.Event() self.session = None @@ -69,16 +68,21 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread): def music_delivery(self, session, frames, frame_size, num_frames, sample_type, sample_rate, channels): """Callback used by pyspotify""" - # TODO Base caps_string on arguments + # pylint: disable = R0913 + # Too many arguments (8/5) + assert sample_type == 0, u'Expects 16-bit signed integer samples' capabilites = """ audio/x-raw-int, endianness=(int)1234, - channels=(int)2, + channels=(int)%(channels)d, width=(int)16, depth=(int)16, - signed=True, - rate=(int)44100 - """ + signed=(boolean)true, + rate=(int)%(sample_rate)d + """ % { + 'sample_rate': sample_rate, + 'channels': channels, + } self.output.deliver_data(capabilites, bytes(frames)) def play_token_lost(self, session): @@ -97,7 +101,7 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread): def search(self, query, connection): """Search method used by Mopidy backend""" - def callback(results, userdata): + def callback(results, userdata=None): # TODO Include results from results.albums(), etc. too playlist = Playlist(tracks=[ LibspotifyTranslator.to_mopidy_track(t) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index e5bfe8f8..04761e17 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -5,7 +5,9 @@ import os import shutil from mopidy import settings -from mopidy.backends.base import * +from mopidy.backends.base import (BaseBackend, BaseLibraryController, + BaseStoredPlaylistsController, BaseCurrentPlaylistController, + BasePlaybackController) from mopidy.models import Playlist, Track, Album from mopidy.utils.process import pickle_connection diff --git a/mopidy/core.py b/mopidy/core.py index 5351e2a5..69760094 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -1,20 +1,22 @@ import logging import multiprocessing import optparse +import sys from mopidy import get_version, settings, OptionalDependencyError 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 BaseProcess +from mopidy.utils.process import BaseThread from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.core') -class CoreProcess(BaseProcess): +class CoreProcess(BaseThread): def __init__(self): - super(CoreProcess, self).__init__(name='CoreProcess') self.core_queue = multiprocessing.Queue() + super(CoreProcess, self).__init__(self.core_queue) + self.name = 'CoreProcess' self.options = self.parse_options() self.output = None self.backend = None @@ -79,7 +81,9 @@ class CoreProcess(BaseProcess): return frontends def process_message(self, message): - if message.get('to') == 'output': + if message.get('to') == 'core': + self.process_message_to_core(message) + elif message.get('to') == 'output': self.output.process_message(message) elif message.get('to') == 'frontend': for frontend in self.frontends: @@ -92,3 +96,12 @@ class CoreProcess(BaseProcess): self.backend.stored_playlists.playlists = message['playlists'] else: logger.warning(u'Cannot handle message: %s', message) + + def process_message_to_core(self, message): + assert message['to'] == 'core', u'Message recipient must be "core".' + if message['command'] == 'exit': + if message['reason'] is not None: + logger.info(u'Exiting (%s)', message['reason']) + sys.exit(message['status']) + else: + logger.warning(u'Cannot handle message: %s', message) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index bba69a5b..0cf534af 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -5,9 +5,9 @@ import time try: import pylast -except ImportError as e: +except ImportError as import_error: from mopidy import OptionalDependencyError - raise OptionalDependencyError(e) + raise OptionalDependencyError(import_error) from mopidy import get_version, settings, SettingsError from mopidy.frontends.base import BaseFrontend @@ -45,7 +45,7 @@ class LastfmFrontend(BaseFrontend): def __init__(self, *args, **kwargs): super(LastfmFrontend, self).__init__(*args, **kwargs) (self.connection, other_end) = multiprocessing.Pipe() - self.thread = LastfmFrontendThread(other_end) + self.thread = LastfmFrontendThread(self.core_queue, other_end) def start(self): self.thread.start() @@ -58,10 +58,9 @@ class LastfmFrontend(BaseFrontend): class LastfmFrontendThread(BaseThread): - def __init__(self, connection): - super(LastfmFrontendThread, self).__init__() + def __init__(self, core_queue, connection): + super(LastfmFrontendThread, self).__init__(core_queue) self.name = u'LastfmFrontendThread' - self.daemon = True self.connection = connection self.lastfm = None self.scrobbler = None @@ -84,7 +83,7 @@ class LastfmFrontendThread(BaseThread): CLIENT_ID, CLIENT_VERSION) logger.info(u'Connected to Last.fm') except SettingsError as e: - logger.info(u'Last.fm scrobbler did not start.') + logger.info(u'Last.fm scrobbler not started') logger.debug(u'Last.fm settings error: %s', e) except (pylast.WSError, socket.error) as e: logger.error(u'Last.fm connection error: %s', e) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 2a477e1c..ab5f2e8c 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -5,9 +5,11 @@ from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdArgError, from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers # Do not remove the following import. The protocol modules must be imported to # get them registered as request handlers. +# pylint: disable = W0611 from mopidy.frontends.mpd.protocol import (audio_output, command_list, connection, current_playlist, empty, music_db, playback, reflection, status, stickers, stored_playlists) +# pylint: enable = W0611 from mopidy.utils import flatten class MpdDispatcher(object): diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index d4dcf50d..fb3a3a09 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,7 +1,8 @@ import re +import shlex from mopidy.frontends.mpd.protocol import handle_pattern, stored_playlists -from mopidy.frontends.mpd.exceptions import MpdNotImplemented +from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented def _build_query(mpd_query): """ @@ -81,13 +82,9 @@ def findadd(frontend, query): # TODO Add result to current playlist #result = frontend.find(query) -@handle_pattern(r'^list (?P[Aa]rtist)$') -@handle_pattern(r'^list "(?P[Aa]rtist)"$') -@handle_pattern(r'^list (?Palbum( artist)?)' - '( "(?P[^"]+)")*$') -@handle_pattern(r'^list "(?Palbum(" "artist)?)"' - '( "(?P[^"]+)")*$') -def list_(frontend, field, artist=None): +@handle_pattern(r'^list "?(?P([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?' + '( (?P.*))?$') +def list_(frontend, field, mpd_query=None): """ *musicpd.org, music database section:* @@ -101,22 +98,70 @@ def list_(frontend, field, artist=None): This filters the result list by an artist. + *Clarifications:* + + The musicpd.org documentation for ``list`` is far from complete. The + command also supports the following variant: + + ``list {TYPE} {QUERY}`` + + Where ``QUERY`` applies to all ``TYPE``. ``QUERY`` is one or more pairs + of a field name and a value. If the ``QUERY`` consists of more than one + pair, the pairs are AND-ed together to find the result. Examples of + valid queries and what they should return: + + ``list "artist" "artist" "ABBA"`` + List artists where the artist name is "ABBA". Response:: + + Artist: ABBA + OK + + ``list "album" "artist" "ABBA"`` + Lists albums where the artist name is "ABBA". Response:: + + Album: More ABBA Gold: More ABBA Hits + Album: Absolute More Christmas + Album: Gold: Greatest Hits + OK + + ``list "artist" "album" "Gold: Greatest Hits"`` + Lists artists where the album name is "Gold: Greatest Hits". + Response:: + + Artist: ABBA + OK + + ``list "artist" "artist" "ABBA" "artist" "TLC"`` + Lists artists where the artist name is "ABBA" *and* "TLC". Should + never match anything. Response:: + + OK + + ``list "date" "artist" "ABBA"`` + Lists dates where artist name is "ABBA". Response:: + + Date: + Date: 1992 + Date: 1993 + OK + + ``list "date" "artist" "ABBA" "album" "Gold: Greatest Hits"`` + Lists dates where artist name is "ABBA" and album name is "Gold: + Greatest Hits". Response:: + + Date: 1992 + OK + + ``list "genre" "artist" "The Rolling Stones"`` + Lists genres where artist name is "The Rolling Stones". Response:: + + Genre: + Genre: Rock + OK + *GMPC:* - does not add quotes around the field argument. - - asks for "list artist" to get available artists and will not query - for artist/album information if this is not retrived - - asks for multiple fields, i.e.:: - - list album artist "an artist name" - - returns the albums available for the asked artist:: - - list album artist "Tiesto" - Album: Radio Trance Vol 4-Promo-CD - Album: Ur A Tear in the Open CDR - Album: Simple Trance 2004 Step One - Album: In Concert 05-10-2003 *ncmpc:* @@ -124,31 +169,70 @@ def list_(frontend, field, artist=None): - capitalizes the field argument. """ field = field.lower() + query = _list_build_query(field, mpd_query) if field == u'artist': - return _list_artist(frontend) - elif field == u'album artist': - return _list_album_artist(frontend, artist) - # TODO More to implement + return _list_artist(frontend, query) + elif field == u'album': + return _list_album(frontend, query) + elif field == u'date': + return _list_date(frontend, query) + elif field == u'genre': + pass # TODO We don't have genre in our internal data structures yet -def _list_artist(frontend): - """ - Since we don't know exactly all available artists, we respond with - the artists we know for sure, which is all artists in our stored playlists. - """ +def _list_build_query(field, mpd_query): + """Converts a ``list`` query to a Mopidy query.""" + if mpd_query is None: + return {} + # shlex does not seem to be friends with unicode objects + tokens = shlex.split(mpd_query.encode('utf-8')) + tokens = [t.decode('utf-8') for t in tokens] + if len(tokens) == 1: + if field == u'album': + return {'artist': [tokens[0]]} + else: + raise MpdArgError( + u'should be "Album" for 3 arguments', command=u'list') + elif len(tokens) % 2 == 0: + query = {} + while tokens: + key = tokens[0].lower() + key = str(key) # Needed for kwargs keys on OS X and Windows + value = tokens[1] + tokens = tokens[2:] + if key not in (u'artist', u'album', u'date', u'genre'): + raise MpdArgError(u'not able to parse args', command=u'list') + if key in query: + query[key].append(value) + else: + query[key] = [value] + return query + else: + raise MpdArgError(u'not able to parse args', command=u'list') + +def _list_artist(frontend, query): artists = set() - for playlist in frontend.backend.stored_playlists.playlists: - for track in playlist.tracks: - for artist in track.artists: - artists.add((u'Artist', artist.name)) + playlist = frontend.backend.library.find_exact(**query) + for track in playlist.tracks: + for artist in track.artists: + artists.add((u'Artist', artist.name)) return artists -def _list_album_artist(frontend, artist): - playlist = frontend.backend.library.find_exact(artist=[artist]) +def _list_album(frontend, query): albums = set() + playlist = frontend.backend.library.find_exact(**query) for track in playlist.tracks: - albums.add((u'Album', track.album.name)) + if track.album is not None: + albums.add((u'Album', track.album.name)) return albums +def _list_date(frontend, query): + dates = set() + playlist = frontend.backend.library.find_exact(**query) + for track in playlist.tracks: + if track.date is not None: + dates.add((u'Date', track.date.strftime('%Y-%m-%d'))) + return dates + @handle_pattern(r'^listall "(?P[^"]+)"') def listall(frontend, uri): """ diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index c3fbdd5f..2f5dd29e 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -138,6 +138,10 @@ def playid(frontend, cpid): at the first track. """ cpid = int(cpid) + paused = (frontend.backend.playback.state == + frontend.backend.playback.PAUSED) + if cpid == -1 and paused: + return frontend.backend.playback.resume() try: if cpid == -1: cp_track = _get_cp_track_for_play_minus_one(frontend) diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index 4381fe0a..7caf21f9 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -24,6 +24,9 @@ class MpdServer(asyncore.dispatcher): try: if socket.has_ipv6: self.create_socket(socket.AF_INET6, socket.SOCK_STREAM) + # Explicitly configure socket to work for both IPv4 and IPv6 + self.socket.setsockopt( + socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) else: self.create_socket(socket.AF_INET, socket.SOCK_STREAM) self.set_reuse_addr() diff --git a/mopidy/frontends/mpd/thread.py b/mopidy/frontends/mpd/thread.py index 0fb048ec..0ad5ee68 100644 --- a/mopidy/frontends/mpd/thread.py +++ b/mopidy/frontends/mpd/thread.py @@ -8,10 +8,8 @@ logger = logging.getLogger('mopidy.frontends.mpd.thread') class MpdThread(BaseThread): def __init__(self, core_queue): - super(MpdThread, self).__init__() + super(MpdThread, self).__init__(core_queue) self.name = u'MpdThread' - self.daemon = True - self.core_queue = core_queue def run_inside_try(self): logger.debug(u'Starting MPD server thread') diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 929d2e1d..8caa9700 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -4,7 +4,7 @@ from multiprocessing import Pipe from mopidy import settings from mopidy.mixers import BaseMixer -from mopidy.utils.process import BaseProcess +from mopidy.utils.process import BaseThread logger = logging.getLogger('mopidy.mixers.nad') @@ -40,7 +40,7 @@ class NadMixer(BaseMixer): super(NadMixer, self).__init__(*args, **kwargs) self._volume = None self._pipe, other_end = Pipe() - NadTalker(pipe=other_end).start() + NadTalker(self.backend.core_queue, pipe=other_end).start() def _get_volume(self): return self._volume @@ -50,7 +50,7 @@ class NadMixer(BaseMixer): self._pipe.send({'command': 'set_volume', 'volume': volume}) -class NadTalker(BaseProcess): +class NadTalker(BaseThread): """ Independent process which does the communication with the NAD device. @@ -72,8 +72,9 @@ class NadTalker(BaseProcess): # Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration. _nad_volume = None - def __init__(self, pipe=None): - super(NadTalker, self).__init__(name='NadTalker') + def __init__(self, core_queue, pipe=None): + super(NadTalker, self).__init__(core_queue) + self.name = u'NadTalker' self.pipe = pipe self._device = None diff --git a/mopidy/outputs/dummy.py b/mopidy/outputs/dummy.py index fd42b38b..060ee02f 100644 --- a/mopidy/outputs/dummy.py +++ b/mopidy/outputs/dummy.py @@ -5,6 +5,9 @@ class DummyOutput(BaseOutput): Audio output used for testing. """ + # pylint: disable = R0902 + # Too many instance attributes (9/7) + #: For testing. :class:`True` if :meth:`start` has been called. start_called = False diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 346f6254..52bd302d 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -29,7 +29,7 @@ class GStreamerOutput(BaseOutput): def __init__(self, *args, **kwargs): super(GStreamerOutput, self).__init__(*args, **kwargs) # Start a helper thread that can run the gobject.MainLoop - self.messages_thread = GStreamerMessagesThread() + self.messages_thread = GStreamerMessagesThread(self.core_queue) # Start a helper thread that can process the output_queue self.output_queue = multiprocessing.Queue() @@ -78,7 +78,8 @@ class GStreamerOutput(BaseOutput): return self._send_recv({'command': 'get_position'}) def set_position(self, position): - return self._send_recv({'command': 'set_position', 'position': position}) + return self._send_recv({'command': 'set_position', + 'position': position}) def set_state(self, state): return self._send_recv({'command': 'set_state', 'state': state}) @@ -91,10 +92,9 @@ class GStreamerOutput(BaseOutput): class GStreamerMessagesThread(BaseThread): - def __init__(self): - super(GStreamerMessagesThread, self).__init__() + def __init__(self, core_queue): + super(GStreamerMessagesThread, self).__init__(core_queue) self.name = u'GStreamerMessagesThread' - self.daemon = True def run_inside_try(self): gobject.MainLoop().run() @@ -113,10 +113,8 @@ class GStreamerPlayerThread(BaseThread): """ def __init__(self, core_queue, output_queue): - super(GStreamerPlayerThread, self).__init__() + super(GStreamerPlayerThread, self).__init__(core_queue) self.name = u'GStreamerPlayerThread' - self.daemon = True - self.core_queue = core_queue self.output_queue = output_queue self.gst_pipeline = None @@ -142,7 +140,16 @@ class GStreamerPlayerThread(BaseThread): uri_bin.connect('pad-added', self.process_new_pad, pad) self.gst_pipeline.add(uri_bin) else: - app_src = gst.element_factory_make('appsrc', 'src') + app_src = gst.element_factory_make('appsrc', 'appsrc') + app_src_caps = gst.Caps(""" + audio/x-raw-int, + endianness=(int)1234, + channels=(int)2, + width=(int)16, + depth=(int)16, + signed=(boolean)true, + rate=(int)44100""") + app_src.set_property('caps', app_src_caps) self.gst_pipeline.add(app_src) app_src.get_pad('src').link(pad) @@ -208,12 +215,12 @@ class GStreamerPlayerThread(BaseThread): def deliver_data(self, caps_string, data): """Deliver audio data to be played""" - data_src = self.gst_pipeline.get_by_name('src') + app_src = self.gst_pipeline.get_by_name('appsrc') caps = gst.caps_from_string(caps_string) buffer_ = gst.Buffer(buffer(data)) buffer_.set_caps(caps) - data_src.set_property('caps', caps) - data_src.emit('push-buffer', buffer_) + app_src.set_property('caps', caps) + app_src.emit('push-buffer', buffer_) def end_of_data_stream(self): """ @@ -222,7 +229,7 @@ class GStreamerPlayerThread(BaseThread): We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. """ - self.gst_pipeline.get_by_name('src').emit('end-of-stream') + self.gst_pipeline.get_by_name('appsrc').emit('end-of-stream') def set_state(self, state_name): """ diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 09446c93..c34d018c 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -3,7 +3,6 @@ import multiprocessing import multiprocessing.dummy from multiprocessing.reduction import reduce_connection import pickle -import sys from mopidy import SettingsError @@ -17,24 +16,27 @@ def unpickle_connection(pickled_connection): (func, args) = pickle.loads(pickled_connection) return func(*args) - class BaseProcess(multiprocessing.Process): + def __init__(self, core_queue): + super(BaseProcess, self).__init__() + self.core_queue = core_queue + def run(self): logger.debug(u'%s: Starting process', self.name) try: self.run_inside_try() except KeyboardInterrupt: - logger.info(u'%s: Interrupted by user', self.name) - sys.exit(0) + logger.info(u'Interrupted by user') + self.exit(0, u'Interrupted by user') except SettingsError as e: logger.error(e.message) - sys.exit(1) + self.exit(1, u'Settings error') except ImportError as e: logger.error(e) - sys.exit(1) + self.exit(2, u'Import error') except Exception as e: logger.exception(e) - raise e + self.exit(3, u'Unknown error') def run_inside_try(self): raise NotImplementedError @@ -42,27 +44,43 @@ class BaseProcess(multiprocessing.Process): def destroy(self): self.terminate() + def exit(self, status=0, reason=None): + self.core_queue.put({'to': 'core', 'command': 'exit', + 'status': status, 'reason': reason}) + self.destroy() + class BaseThread(multiprocessing.dummy.Process): + def __init__(self, core_queue): + super(BaseThread, self).__init__() + self.core_queue = core_queue + # No thread should block process from exiting + self.daemon = True + def run(self): - logger.debug(u'%s: Starting process', self.name) + logger.debug(u'%s: Starting thread', self.name) try: self.run_inside_try() except KeyboardInterrupt: - logger.info(u'%s: Interrupted by user', self.name) - sys.exit(0) + logger.info(u'Interrupted by user') + self.exit(0, u'Interrupted by user') except SettingsError as e: logger.error(e.message) - sys.exit(1) + self.exit(1, u'Settings error') except ImportError as e: logger.error(e) - sys.exit(1) + self.exit(2, u'Import error') except Exception as e: logger.exception(e) - raise e + self.exit(3, u'Unknown error') def run_inside_try(self): raise NotImplementedError def destroy(self): pass + + def exit(self, status=0, reason=None): + self.core_queue.put({'to': 'core', 'command': 'exit', + 'status': status, 'reason': reason}) + self.destroy() diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 1d3a0fa0..ac75cb70 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -23,7 +23,9 @@ class SettingsProxy(object): if not os.path.isfile(settings_file): return {} sys.path.insert(0, dotdir) + # pylint: disable = F0401 import settings as local_settings_module + # pylint: enable = F0401 return self._get_settings_dict_from_module(local_settings_module) def _get_settings_dict_from_module(self, module): diff --git a/pylintrc b/pylintrc index d405a71f..d2f84b77 100644 --- a/pylintrc +++ b/pylintrc @@ -5,18 +5,19 @@ # # C0103 - Invalid name "%s" (should match %s) # C0111 - Missing docstring -# C0112 - Empty docstring # E0102 - %s already defined line %s +# Does not understand @property getters and setters # E0202 - An attribute inherited from %s hide this method +# Does not understand @property getters and setters # E1101 - %s %r has no %r member +# Does not understand @property getters and setters # R0201 - Method could be a function # R0801 - Similar lines in %s files # R0903 - Too few public methods (%s/%s) # R0904 - Too many public methods (%s/%s) -# W0141 - Used builtin function %r +# R0921 - Abstract class not referenced +# W0141 - Used builtin function '%s' # W0142 - Used * or ** magic -# W0401 - Wildcard import %s -# W0511 - TODO, FIXME and XXX in the code # W0613 - Unused argument %r # -disable-msg = C0103,C0111,C0112,E0102,E0202,E1101,R0201,R0801,R0903,R0904,W0141,W0142,W0401,W0511,W0613 +disable = C0103,C0111,E0102,E0202,E1101,R0201,R0801,R0903,R0904,R0921,W0141,W0142,W0613 diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 59c7b39f..05f08e18 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -128,7 +128,7 @@ class BaseCurrentPlaylistControllerTest(object): def test_append_does_not_reset_version(self): version = self.controller.version self.controller.append([]) - self.assertEqual(self.controller.version, version + 1) + self.assertEqual(self.controller.version, version) @populate_playlist def test_append_preserves_playing_state(self): @@ -249,7 +249,12 @@ class BaseCurrentPlaylistControllerTest(object): self.assertEqual(self.tracks[0], shuffled_tracks[0]) self.assertEqual(set(self.tracks), set(shuffled_tracks)) - def test_version(self): + def test_version_does_not_change_when_appending_nothing(self): version = self.controller.version self.controller.append([]) + self.assertEquals(version, self.controller.version) + + def test_version_increases_when_appending_something(self): + version = self.controller.version + self.controller.append([Track()]) self.assert_(version < self.controller.version) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index ca4d9941..4caaf44b 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -524,7 +524,7 @@ class BasePlaybackControllerTest(object): wrapper.called = False self.playback.on_current_playlist_change = wrapper - self.backend.current_playlist.append([]) + self.backend.current_playlist.append([Track()]) self.assert_(wrapper.called) diff --git a/tests/frontends/mpd/current_playlist_test.py b/tests/frontends/mpd/current_playlist_test.py index 8e4b62f9..8a4b9ab5 100644 --- a/tests/frontends/mpd/current_playlist_test.py +++ b/tests/frontends/mpd/current_playlist_test.py @@ -135,7 +135,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def test_deleteid(self): self.b.current_playlist.append([Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 2) - result = self.h.handle_request(u'deleteid "2"') + result = self.h.handle_request(u'deleteid "1"') self.assertEqual(len(self.b.current_playlist.tracks), 1) self.assert_(u'OK' in result) @@ -193,7 +193,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'moveid "5" "2"') + result = self.h.handle_request(u'moveid "4" "2"') self.assertEqual(self.b.current_playlist.tracks[0].name, 'a') self.assertEqual(self.b.current_playlist.tracks[1].name, 'b') self.assertEqual(self.b.current_playlist.tracks[2].name, 'e') @@ -229,7 +229,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): result = self.h.handle_request( u'playlistfind filename "file:///exists"') self.assert_(u'file: file:///exists' in result) - self.assert_(u'Id: 1' in result) + self.assert_(u'Id: 0' in result) self.assert_(u'Pos: 0' in result) self.assert_(u'OK' in result) @@ -242,11 +242,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def test_playlistid_with_songid(self): self.b.current_playlist.append([Track(name='a'), Track(name='b')]) - result = self.h.handle_request(u'playlistid "2"') + result = self.h.handle_request(u'playlistid "1"') self.assert_(u'Title: a' not in result) - self.assert_(u'Id: 1' not in result) + self.assert_(u'Id: 0' not in result) self.assert_(u'Title: b' in result) - self.assert_(u'Id: 2' in result) + self.assert_(u'Id: 1' in result) self.assert_(u'OK' in result) def test_playlistid_with_not_existing_songid_fails(self): @@ -429,7 +429,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'swapid "2" "5"') + result = self.h.handle_request(u'swapid "1" "4"') self.assertEqual(self.b.current_playlist.tracks[0].name, 'a') self.assertEqual(self.b.current_playlist.tracks[1].name, 'e') self.assertEqual(self.b.current_playlist.tracks[2].name, 'c') diff --git a/tests/frontends/mpd/music_db_test.py b/tests/frontends/mpd/music_db_test.py index 5fcc393c..05b8ebd0 100644 --- a/tests/frontends/mpd/music_db_test.py +++ b/tests/frontends/mpd/music_db_test.py @@ -15,6 +15,59 @@ class MusicDatabaseHandlerTest(unittest.TestCase): self.assert_(u'playtime: 0' in result) self.assert_(u'OK' in result) + def test_findadd(self): + result = self.h.handle_request(u'findadd "album" "what"') + self.assert_(u'OK' in result) + + def test_listall(self): + result = self.h.handle_request(u'listall "file:///dev/urandom"') + self.assert_(u'ACK [0@0] {} Not implemented' in result) + + def test_listallinfo(self): + result = self.h.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.h.handle_request(u'lsinfo') + listplaylists_result = self.h.handle_request(u'listplaylists') + self.assertEqual(lsinfo_result, listplaylists_result) + + def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): + lsinfo_result = self.h.handle_request(u'lsinfo ""') + listplaylists_result = self.h.handle_request(u'listplaylists') + self.assertEqual(lsinfo_result, listplaylists_result) + + def test_lsinfo_for_root_returns_same_as_listplaylists(self): + lsinfo_result = self.h.handle_request(u'lsinfo "/"') + listplaylists_result = self.h.handle_request(u'listplaylists') + self.assertEqual(lsinfo_result, listplaylists_result) + + def test_update_without_uri(self): + result = self.h.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.h.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.h.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.h.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.b = DummyBackend(mixer_class=DummyMixer) + self.h = dispatcher.MpdDispatcher(backend=self.b) + def test_find_album(self): result = self.h.handle_request(u'find "album" "what"') self.assert_(u'OK' in result) @@ -48,11 +101,20 @@ class MusicDatabaseHandlerTest(unittest.TestCase): u'find album "album_what" artist "artist_what"') self.assert_(u'OK' in result) - def test_findadd(self): - result = self.h.handle_request(u'findadd "album" "what"') - self.assert_(u'OK' in result) - def test_list_artist(self): +class MusicDatabaseListTest(unittest.TestCase): + def setUp(self): + self.b = DummyBackend(mixer_class=DummyMixer) + self.h = dispatcher.MpdDispatcher(backend=self.b) + + def test_list_foo_returns_ack(self): + result = self.h.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.h.handle_request(u'list "artist"') self.assert_(u'OK' in result) @@ -64,44 +126,177 @@ class MusicDatabaseHandlerTest(unittest.TestCase): result = self.h.handle_request(u'list Artist') self.assert_(u'OK' in result) - def test_list_artist_with_artist_should_fail(self): + def test_list_artist_with_query_of_one_token(self): result = self.h.handle_request(u'list "artist" "anartist"') - self.assertEqual(result[0], u'ACK [2@0] {list} incorrect arguments') + self.assertEqual(result[0], + u'ACK [2@0] {list} should be "Album" for 3 arguments') - def test_list_album_without_artist(self): + def test_list_artist_with_unknown_field_in_query_returns_ack(self): + result = self.h.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.h.handle_request(u'list "artist" "artist" "anartist"') + self.assert_(u'OK' in result) + + def test_list_artist_by_album(self): + result = self.h.handle_request(u'list "artist" "album" "analbum"') + self.assert_(u'OK' in result) + + def test_list_artist_by_full_date(self): + result = self.h.handle_request(u'list "artist" "date" "2001-01-01"') + self.assert_(u'OK' in result) + + def test_list_artist_by_year(self): + result = self.h.handle_request(u'list "artist" "date" "2001"') + self.assert_(u'OK' in result) + + def test_list_artist_by_genre(self): + result = self.h.handle_request(u'list "artist" "genre" "agenre"') + self.assert_(u'OK' in result) + + def test_list_artist_by_artist_and_album(self): + result = self.h.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.h.handle_request(u'list "album"') self.assert_(u'OK' in result) - def test_list_album_with_artist(self): + def test_list_album_without_quotes(self): + result = self.h.handle_request(u'list album') + self.assert_(u'OK' in result) + + def test_list_album_without_quotes_and_capitalized(self): + result = self.h.handle_request(u'list Album') + self.assert_(u'OK' in result) + + def test_list_album_with_artist_name(self): result = self.h.handle_request(u'list "album" "anartist"') self.assert_(u'OK' in result) - def test_list_album_artist_with_artist_without_quotes(self): - result = self.h.handle_request(u'list album artist "anartist"') + def test_list_album_by_artist(self): + result = self.h.handle_request(u'list "album" "artist" "anartist"') self.assert_(u'OK' in result) - def test_listall(self): - result = self.h.handle_request(u'listall "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + def test_list_album_by_album(self): + result = self.h.handle_request(u'list "album" "album" "analbum"') + self.assert_(u'OK' in result) - def test_listallinfo(self): - result = self.h.handle_request(u'listallinfo "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + def test_list_album_by_full_date(self): + result = self.h.handle_request(u'list "album" "date" "2001-01-01"') + self.assert_(u'OK' in result) - def test_lsinfo_without_path_returns_same_as_listplaylists(self): - lsinfo_result = self.h.handle_request(u'lsinfo') - listplaylists_result = self.h.handle_request(u'listplaylists') - self.assertEqual(lsinfo_result, listplaylists_result) + def test_list_album_by_year(self): + result = self.h.handle_request(u'list "album" "date" "2001"') + self.assert_(u'OK' in result) - def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): - lsinfo_result = self.h.handle_request(u'lsinfo ""') - listplaylists_result = self.h.handle_request(u'listplaylists') - self.assertEqual(lsinfo_result, listplaylists_result) + def test_list_album_by_genre(self): + result = self.h.handle_request(u'list "album" "genre" "agenre"') + self.assert_(u'OK' in result) - def test_lsinfo_for_root_returns_same_as_listplaylists(self): - lsinfo_result = self.h.handle_request(u'lsinfo "/"') - listplaylists_result = self.h.handle_request(u'listplaylists') - self.assertEqual(lsinfo_result, listplaylists_result) + def test_list_album_by_artist_and_album(self): + result = self.h.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.h.handle_request(u'list "date"') + self.assert_(u'OK' in result) + + def test_list_date_without_quotes(self): + result = self.h.handle_request(u'list date') + self.assert_(u'OK' in result) + + def test_list_date_without_quotes_and_capitalized(self): + result = self.h.handle_request(u'list Date') + self.assert_(u'OK' in result) + + def test_list_date_with_query_of_one_token(self): + result = self.h.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.h.handle_request(u'list "date" "artist" "anartist"') + self.assert_(u'OK' in result) + + def test_list_date_by_album(self): + result = self.h.handle_request(u'list "date" "album" "analbum"') + self.assert_(u'OK' in result) + + def test_list_date_by_full_date(self): + result = self.h.handle_request(u'list "date" "date" "2001-01-01"') + self.assert_(u'OK' in result) + + def test_list_date_by_year(self): + result = self.h.handle_request(u'list "date" "date" "2001"') + self.assert_(u'OK' in result) + + def test_list_date_by_genre(self): + result = self.h.handle_request(u'list "date" "genre" "agenre"') + self.assert_(u'OK' in result) + + def test_list_date_by_artist_and_album(self): + result = self.h.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.h.handle_request(u'list "genre"') + self.assert_(u'OK' in result) + + def test_list_genre_without_quotes(self): + result = self.h.handle_request(u'list genre') + self.assert_(u'OK' in result) + + def test_list_genre_without_quotes_and_capitalized(self): + result = self.h.handle_request(u'list Genre') + self.assert_(u'OK' in result) + + def test_list_genre_with_query_of_one_token(self): + result = self.h.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.h.handle_request(u'list "genre" "artist" "anartist"') + self.assert_(u'OK' in result) + + def test_list_genre_by_album(self): + result = self.h.handle_request(u'list "genre" "album" "analbum"') + self.assert_(u'OK' in result) + + def test_list_genre_by_full_date(self): + result = self.h.handle_request(u'list "genre" "date" "2001-01-01"') + self.assert_(u'OK' in result) + + def test_list_genre_by_year(self): + result = self.h.handle_request(u'list "genre" "date" "2001"') + self.assert_(u'OK' in result) + + def test_list_genre_by_genre(self): + result = self.h.handle_request(u'list "genre" "genre" "agenre"') + self.assert_(u'OK' in result) + + def test_list_genre_by_artist_and_album(self): + result = self.h.handle_request( + u'list "genre" "artist" "anartist" "album" "analbum"') + self.assert_(u'OK' in result) + + +class MusicDatabaseSearchTest(unittest.TestCase): + def setUp(self): + self.b = DummyBackend(mixer_class=DummyMixer) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_search_album(self): result = self.h.handle_request(u'search "album" "analbum"') @@ -147,22 +342,4 @@ class MusicDatabaseHandlerTest(unittest.TestCase): result = self.h.handle_request(u'search "sometype" "something"') self.assertEqual(result[0], u'ACK [2@0] {search} incorrect arguments') - def test_update_without_uri(self): - result = self.h.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.h.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.h.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.h.handle_request(u'rescan "file:///dev/urandom"') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) diff --git a/tests/frontends/mpd/playback_test.py b/tests/frontends/mpd/playback_test.py index 3ba48a54..4e60546d 100644 --- a/tests/frontends/mpd/playback_test.py +++ b/tests/frontends/mpd/playback_test.py @@ -254,7 +254,7 @@ class PlaybackControlHandlerTest(unittest.TestCase): def test_playid(self): self.b.current_playlist.append([Track()]) - result = self.h.handle_request(u'playid "1"') + result = self.h.handle_request(u'playid "0"') self.assert_(u'OK' in result) self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) @@ -285,6 +285,18 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assertEqual(self.b.playback.STOPPED, self.b.playback.state) self.assertEqual(self.b.playback.current_track, None) + def test_playid_minus_one_resumes_if_paused(self): + self.b.current_playlist.append([Track(length=40000)]) + self.b.playback.seek(30000) + self.assert_(self.b.playback.time_position >= 30000) + self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) + self.b.playback.pause() + self.assertEquals(self.b.playback.PAUSED, self.b.playback.state) + result = self.h.handle_request(u'playid "-1"') + self.assert_(u'OK' in result) + self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) + self.assert_(self.b.playback.time_position >= 30000) + def test_playid_which_does_not_exist(self): self.b.current_playlist.append([Track()]) result = self.h.handle_request(u'playid "12345"') @@ -310,7 +322,7 @@ class PlaybackControlHandlerTest(unittest.TestCase): def test_seekid(self): self.b.current_playlist.append([Track(length=40000)]) - result = self.h.handle_request(u'seekid "1" "30"') + result = self.h.handle_request(u'seekid "0" "30"') self.assert_(u'OK' in result) self.assert_(self.b.playback.time_position >= 30000) @@ -318,8 +330,8 @@ class PlaybackControlHandlerTest(unittest.TestCase): seek_track = Track(uri='2', length=40000) self.b.current_playlist.append( [Track(length=40000), seek_track]) - result = self.h.handle_request(u'seekid "2" "30"') - self.assertEqual(self.b.playback.current_cpid, 2) + result = self.h.handle_request(u'seekid "1" "30"') + self.assertEqual(self.b.playback.current_cpid, 1) self.assertEqual(self.b.playback.current_track, seek_track) def test_stop(self): diff --git a/tests/frontends/mpd/regression_test.py b/tests/frontends/mpd/regression_test.py new file mode 100644 index 00000000..3cfdb855 --- /dev/null +++ b/tests/frontends/mpd/regression_test.py @@ -0,0 +1,110 @@ +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/jodal/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(mixer_class=DummyMixer) + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), None, + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + self.mpd = dispatcher.MpdDispatcher(backend=self.backend) + + def test(self): + random.seed(1) # Playlist order: abcfde + self.mpd.handle_request(u'play') + self.assertEquals('a', self.backend.playback.current_track.uri) + self.mpd.handle_request(u'random "1"') + self.mpd.handle_request(u'next') + self.assertEquals('b', self.backend.playback.current_track.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.uri) + self.mpd.handle_request(u'next') + self.assertEquals('d', self.backend.playback.current_track.uri) + self.mpd.handle_request(u'next') + self.assertEquals('e', self.backend.playback.current_track.uri) + + +class IssueGH18RegressionTest(unittest.TestCase): + """ + The issue: http://github.com/jodal/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(mixer_class=DummyMixer) + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), Track(uri='c'), + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + self.mpd = dispatcher.MpdDispatcher(backend=self.backend) + + 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 + self.mpd.handle_request(u'next') + cp_track_2 = self.backend.playback.current_cp_track + self.mpd.handle_request(u'next') + cp_track_3 = self.backend.playback.current_cp_track + + 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/jodal/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(mixer_class=DummyMixer) + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), Track(uri='c'), + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + self.mpd = dispatcher.MpdDispatcher(backend=self.backend) + + 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') diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index fbd0ff9e..1afe6ccd 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -27,7 +27,7 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(u'Track: 0' in result) self.assert_(u'Date: ' in result) self.assert_(u'Pos: 0' in result) - self.assert_(u'Id: 1' in result) + self.assert_(u'Id: 0' in result) self.assert_(u'OK' in result) def test_currentsong_without_song(self): @@ -166,7 +166,7 @@ class StatusHandlerTest(unittest.TestCase): self.b.playback.play() result = dict(dispatcher.status.status(self.h)) self.assert_('songid' in result) - self.assertEqual(int(result['songid']), 1) + self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): self.b.current_playlist.append([Track(length=None)]) diff --git a/tests/version_test.py b/tests/version_test.py index b2ef1fce..a8bc2955 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -12,7 +12,7 @@ class VersionTest(unittest.TestCase): self.assert_(SV('0.1.0a1') < SV('0.1.0a2')) self.assert_(SV('0.1.0a2') < SV('0.1.0a3')) self.assert_(SV('0.1.0a3') < SV('0.1.0')) - self.assert_(SV('0.1.0') < SV(get_version())) - self.assert_(SV(get_version()) < SV('0.2.0')) - self.assert_(SV('0.1.1') < SV('0.2.0')) - self.assert_(SV('0.2.0') < SV('1.0.0')) + self.assert_(SV('0.1.0') < SV('0.2.0')) + self.assert_(SV('0.1.0') < SV('1.0.0')) + self.assert_(SV('0.2.0') < SV(get_version())) + self.assert_(SV(get_version()) < SV('0.3.1'))