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'))