diff --git a/.mailmap b/.mailmap index 15d8f359..93a4aed1 100644 --- a/.mailmap +++ b/.mailmap @@ -1,3 +1,5 @@ +Thomas Adamcik +Thomas Adamcik Kristian Klette Johannes Knutsen Johannes Knutsen diff --git a/.travis.yml b/.travis.yml index 7acda2bd..df08679b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ install: - "sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" - "sudo apt-get update || true" - "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')" - - "pip install -r requirements/http.txt" # Until ws4py is packaged as a .deb before_script: - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 00000000..45e1a37e --- /dev/null +++ b/AUTHORS @@ -0,0 +1,16 @@ +- Stein Magnus Jodal +- Johannes Knutsen +- Thomas Adamcik +- Kristian Klette +- Martins Grunskis +- Henrik Olsson +- Antoine Pierlot-Garcin +- John Bäckstrand +- Fred Hatfull +- Erling Børresen +- David C +- Christian Johansen +- Matt Bray +- Trygve Aaberge +- Wouter van Wijk +- Jeremy B. Merrill diff --git a/docs/_static/woutervanwijk-mopidy-webclient.png b/docs/_static/woutervanwijk-mopidy-webclient.png new file mode 100644 index 00000000..0dd99acc Binary files /dev/null and b/docs/_static/woutervanwijk-mopidy-webclient.png differ diff --git a/docs/api/backends.rst b/docs/api/backends.rst index f0aadd53..32c04d37 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -46,5 +46,6 @@ Backend implementations ======================= * :mod:`mopidy.backends.dummy` -* :mod:`mopidy.backends.spotify` * :mod:`mopidy.backends.local` +* :mod:`mopidy.backends.spotify` +* :mod:`mopidy.backends.stream` diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 2237b4e7..8488b408 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -44,6 +44,7 @@ The following requirements applies to any frontend implementation: Frontend implementations ======================== +* :mod:`mopidy.frontends.http` * :mod:`mopidy.frontends.lastfm` * :mod:`mopidy.frontends.mpd` * :mod:`mopidy.frontends.mpris` diff --git a/docs/authors.rst b/docs/authors.rst index 822abc15..97a2dd2b 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -4,13 +4,7 @@ Authors Contributors to Mopidy in the order of appearance: -- Stein Magnus Jodal -- Johannes Knutsen -- Thomas Adamcik -- Kristian Klette - -A complete list of persons with commits accepted into the Mopidy repo can be -found at `GitHub `_. +.. include:: ../AUTHORS Showing your appreciation diff --git a/docs/changes.rst b/docs/changes.rst index aa69536c..c2b076fc 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,9 +4,176 @@ Changes This change log is used to track all major changes to Mopidy. -v0.10.0 (in development) + +v0.12.0 (in development) ======================== +(in development) + +- Make Mopidy work on early Python 2.6 versions. (Fixes: :issue:`302`) + + - ``optparse`` fails if the first argument to ``add_option`` is a unicode + string on Python < 2.6.2rc1. + + - ``foo(**data)`` fails if the keys in ``data`` is unicode strings on Python + < 2.6.5rc1. + +**Spotify backend** + +- Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`) + +**Local backend** + +- Make ``mopidy-scan`` support symlinks. + +**Stream backend** + +We've added a new backend for playing audio streams, the :mod:`stream backend +`. It is activated by default. + +The stream backend supports the intersection of what your GStreamer +installation supports and what protocols are included in the +:attr:`mopidy.settings.STREAM_PROTOCOLS` settings. + +Current limitations: + +- No metadata about the current track in the stream is available. + +- Playlists are not parsed, so you can't play e.g. a M3U or PLS file which + contains stream URIs. You need to extract the stream URL from the playlist + yourself. See :issue:`303` for progress on this. + + +v0.11.0 (2012-12-24) +==================== + +In celebration of Mopidy's three year anniversary December 23, we're releasing +Mopidy 0.11. This release brings several improvements, most notably better +search which now includes matching artists and albums from Spotify in the +search results. + +**Settings** + +- The settings validator now complains if a setting which expects a tuple of + values (e.g. :attr:`mopidy.settings.BACKENDS`, + :attr:`mopidy.settings.FRONTENDS`) has a non-iterable value. This typically + happens because the setting value contains a single value and one has + forgotten to add a comma after the string, making the value a tuple. (Fixes: + :issue:`278`) + +**Spotify backend** + +- Add :attr:`mopidy.settings.SPOTIFY_TIMEOUT` setting which allows you to + control how long we should wait before giving up on Spotify searches, etc. + +- Add support for looking up albums, artists, and playlists by URI in addition + to tracks. (Fixes: :issue:`67`) + + As an example of how this can be used, you can try the the following MPD + commands which now all adds one or more tracks to your tracklist:: + + add "spotify:track:1mwt9hzaH7idmC5UCoOUkz" + add "spotify:album:3gpHG5MGwnipnap32lFYvI" + add "spotify:artist:5TgQ66WuWkoQ2xYxaSTnVP" + add "spotify:user:p3.no:playlist:0XX6tamRiqEgh3t6FPFEkw" + +- Increase max number of tracks returned by searches from 100 to 200, which + seems to be Spotify's current max limit. + +**Local backend** + +- Load track dates from tag cache. + +- Add support for searching by track date. + +**MPD frontend** + +- Add :attr:`mopidy.settings.MPD_SERVER_CONNECTION_TIMEOUT` setting which + controls how long an MPD client can stay inactive before the connection is + closed by the server. + +- Add support for the ``findadd`` command. + +- Updated to match the MPD 0.17 protocol (Fixes: :issue:`228`): + + - Add support for ``seekcur`` command. + + - Add support for ``config`` command. + + - Add support for loading a range of tracks from a playlist to the ``load`` + command. + + - Add support for ``searchadd`` command. + + - Add support for ``searchaddpl`` command. + + - Add empty stubs for channel commands for client to client communication. + +- Add support for search by date. + +- Make ``seek`` and ``seekid`` not restart the current track before seeking in + it. + +- Include fake tracks representing albums and artists in the search results. + When these are added to the tracklist, they expand to either all tracks in + the album or all tracks by the artist. This makes it easy to play full albums + in proper order, which is a feature that have been frequently requested. + (Fixes: :issue:`67`, :issue:`148`) + +**Internal changes** + +*Models:* + +- Specified that :attr:`mopidy.models.Playlist.last_modified` should be in UTC. + +- Added :class:`mopidy.models.SearchResult` model to encapsulate search results + consisting of more than just tracks. + +*Core API:* + +- Change the following methods to return :class:`mopidy.models.SearchResult` + objects which can include both track results and other results: + + - :meth:`mopidy.core.LibraryController.find_exact` + - :meth:`mopidy.core.LibraryController.search` + +- Change the following methods to accept either a dict with filters or kwargs. + Previously they only accepted kwargs, which made them impossible to use from + the Mopidy.js through JSON-RPC, which doesn't support kwargs. + + - :meth:`mopidy.core.LibraryController.find_exact` + - :meth:`mopidy.core.LibraryController.search` + - :meth:`mopidy.core.PlaylistsController.filter` + - :meth:`mopidy.core.TracklistController.filter` + - :meth:`mopidy.core.TracklistController.remove` + +- Actually trigger the :meth:`mopidy.core.CoreListener.volume_changed` event. + +- Include the new volume level in the + :meth:`mopidy.core.CoreListener.volume_changed` event. + +- The ``track_playback_{paused,resumed,started,ended}`` events now include a + :class:`mopidy.models.TlTrack` instead of a :class:`mopidy.models.Track`. + +*Audio:* + +- Mixers with fewer than 100 volume levels could report another volume level + than what you just set due to the conversion between Mopidy's 0-100 range and + the mixer's range. Now Mopidy returns the recently set volume if the mixer + reports a volume level that matches the recently set volume, otherwise the + mixer's volume level is rescaled to the 1-100 range and returned. + + +v0.10.0 (2012-12-12) +==================== + +We've added an HTTP frontend for those wanting to build web clients for Mopidy! + +**Dependencies** + +- pyspotify >= 1.9, < 1.11 is now required for Spotify support. In other words, + you're free to upgrade to pyspotify 1.10, but it isn't a requirement. + **Documentation** - Added installation instructions for Fedora. @@ -29,6 +196,10 @@ v0.10.0 (in development) :option:`-v`/:option:`--verbose` options to control the amount of logging output when scanning. +- The scanner can now handle files with other encodings than UTF-8. Rebuild + your tag cache with ``mopidy-scan`` to include tracks that may have been + ignored previously. + **HTTP frontend** - Added new optional HTTP frontend which exposes Mopidy's core API through diff --git a/docs/clients/http.rst b/docs/clients/http.rst index e41adb5b..c6e8b7d8 100644 --- a/docs/clients/http.rst +++ b/docs/clients/http.rst @@ -4,11 +4,26 @@ HTTP clients ************ -Mopidy added an :ref:`http-frontend` in 0.10 which provides the building blocks -needed for creating web clients for Mopidy with the help of a WebSocket and a -JavaScript library provided by Mopidy. +Mopidy added an :ref:`HTTP frontend ` in 0.10 which provides the +building blocks needed for creating web clients for Mopidy with the help of a +WebSocket and a JavaScript library provided by Mopidy. This page will list any HTTP/web Mopidy clients. If you've created one, please notify us so we can include your client on this page. See :ref:`http-frontend` for details on how to build your own web client. + + +woutervanwijk/Mopidy-Webclient +============================== + +.. image:: /_static/woutervanwijk-mopidy-webclient.png + :width: 410 + :height: 511 + +The first web client for Mopidy is still under development, but is already very +usable. It targets both desktop and mobile browsers. + +To try it out, get a copy of https://github.com/woutervanwijk/Mopidy-WebClient +and point the :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` setting towards +your copy of the web client. diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index fbb07364..8a4d9409 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -210,6 +210,10 @@ software packages, as Wheezy is going to be the next release of Debian. aplay /usr/share/sounds/alsa/Front_Center.wav + If you hear a voice saying "Front Center," then your sound is working. Don't + be concerned if this test sound includes static, output from Mopidy will not. + Test your sound with gstreamer to determine sound quality. + To make the change to analog output stick, you can add the ``amixer`` command to e.g. ``/etc/rc.local``, which will be executed when the system is booting. diff --git a/docs/modules/backends/stream.rst b/docs/modules/backends/stream.rst new file mode 100644 index 00000000..73e53048 --- /dev/null +++ b/docs/modules/backends/stream.rst @@ -0,0 +1,7 @@ +*********************************************** +:mod:`mopidy.backends.stream` -- Stream backend +*********************************************** + +.. automodule:: mopidy.backends.stream + :synopsis: Backend for playing audio streams + :members: diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/frontends/mpd.rst index 090ca5cd..f25b90f2 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/frontends/mpd.rst @@ -30,6 +30,14 @@ Audio output :members: +Channels +-------- + +.. automodule:: mopidy.frontends.mpd.protocol.channels + :synopsis: MPD protocol: channels -- client to client communication + :members: + + Command list ------------ diff --git a/fabfile.py b/fabfile.py new file mode 100644 index 00000000..370c81be --- /dev/null +++ b/fabfile.py @@ -0,0 +1,21 @@ +from fabric.api import local + + +def test(path=None): + path = path or 'tests/' + local('nosetests ' + path) + + +def autotest(path=None): + while True: + local('clear') + test(path) + local( + 'inotifywait -q -e create -e modify -e delete ' + '--exclude ".*\.(pyc|sw.)" -r mopidy/ tests/') + + +def update_authors(): + # Keep authors in the order of appearance and use awk to filter out dupes + local( + "git log --format='- %aN <%aE>' --reverse | awk '!x[$0]++' > AUTHORS") diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 918e1459..2e5aeeba 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -23,7 +23,7 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.9.0' +__version__ = '0.11.0' from mopidy import settings as default_settings_module diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 952f158c..e111fcef 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -79,37 +79,40 @@ def main(): def parse_options(): parser = optparse.OptionParser( version='Mopidy %s' % versioning.get_version()) + # NOTE Python 2.6: To support Python versions < 2.6.2rc1 we must use + # bytestrings for the first argument to ``add_option`` + # See https://github.com/mopidy/mopidy/issues/302 for details parser.add_option( - '--help-gst', + b'--help-gst', action='store_true', dest='help_gst', help='show GStreamer help options') parser.add_option( - '-i', '--interactive', + b'-i', '--interactive', action='store_true', dest='interactive', help='ask interactively for required settings which are missing') parser.add_option( - '-q', '--quiet', + b'-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') parser.add_option( - '-v', '--verbose', + b'-v', '--verbose', action='count', default=1, dest='verbosity_level', help='more output (debug level)') parser.add_option( - '--save-debug-log', + b'--save-debug-log', action='store_true', dest='save_debug_log', help='save debug log to "./mopidy.log"') parser.add_option( - '--list-settings', + b'--list-settings', action='callback', callback=settings_utils.list_settings_optparse_callback, help='list current settings') parser.add_option( - '--list-deps', + b'--list-deps', action='callback', callback=deps.list_deps_optparse_callback, help='list dependencies and their versions') parser.add_option( - '--debug-thread', + b'--debug-thread', action='store_true', dest='debug_thread', help='run background thread that dumps tracebacks on SIGUSR1') return parser.parse_args(args=mopidy_args)[0] diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 7cf1dcee..5adb333c 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -4,3 +4,5 @@ from __future__ import unicode_literals from .actor import Audio from .listener import AudioListener from .constants import PlaybackState +from .utils import (calculate_duration, create_buffer, millisecond_to_clocktime, + supported_uri_schemes) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 0a0cb0be..e3d5ac87 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -39,10 +39,17 @@ class Audio(pykka.ThreadingActor): super(Audio, self).__init__() self._playbin = None + self._mixer = None self._mixer_track = None + self._mixer_scale = None self._software_mixing = False + self._volume_set = None + self._appsrc = None + self._appsrc_caps = None + self._appsrc_seek_data_callback = None + self._appsrc_seek_data_id = None self._notify_source_signal_id = None self._about_to_finish_id = None @@ -75,7 +82,13 @@ class Audio(pykka.ThreadingActor): 'notify::source', self._on_new_source) def _on_about_to_finish(self, element): - self._appsrc = None + source, self._appsrc = self._appsrc, None + if source is None: + return + self._appsrc_caps = None + if self._appsrc_seek_data_id is not None: + source.disconnect(self._appsrc_seek_data_id) + self._appsrc_seek_data_id = None # TODO: this is just a horrible hack to get us started. the # comunication is correct, but this way of hooking it up is not. @@ -90,17 +103,21 @@ class Audio(pykka.ThreadingActor): if source.get_factory().get_name() != 'appsrc': return - # These caps matches the audio data provided by libspotify - default_caps = gst.Caps( - b'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' - b'width=(int)16, depth=(int)16, signed=(boolean)true, ' - b'rate=(int)44100') - source.set_property('caps', default_caps) - # GStreamer does not like unicode + source.set_property('caps', self._appsrc_caps) source.set_property('format', b'time') + source.set_property('stream-type', b'seekable') + + self._appsrc_seek_data_id = source.connect( + 'seek-data', self._appsrc_on_seek_data) self._appsrc = source + def _appsrc_on_seek_data(self, appsrc, time_in_ns): + time_in_ms = time_in_ns // gst.MSECOND + if self._appsrc_seek_data_callback is not None: + self._appsrc_seek_data_callback(time_in_ms) + return True + def _teardown_playbin(self): if self._about_to_finish_id: self._playbin.disconnect(self._about_to_finish_id) @@ -156,6 +173,8 @@ class Audio(pykka.ThreadingActor): self._mixer = mixer self._mixer_track = track + self._mixer_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) logger.info( 'Audio mixer set to "%s" using track "%s"', mixer.get_factory().get_name(), track.label) @@ -245,6 +264,25 @@ class Audio(pykka.ThreadingActor): """ self._playbin.set_property('uri', uri) + def set_appsrc(self, caps, seek_data=None): + """ + Switch to using appsrc for getting audio to be played. + + You *MUST* call :meth:`prepare_change` before calling this method. + + :param caps: GStreamer caps string describing the audio format to + expect + :type caps: string + :param seek_data: callback for when data from a new position is needed + to continue playback + :type seek_data: callable which takes time position in ms + """ + if isinstance(caps, unicode): + caps = caps.encode('utf-8') + self._appsrc_caps = gst.Caps(caps) + self._appsrc_seek_data_callback = seek_data + self._playbin.set_property('uri', 'appsrc://') + def emit_data(self, buffer_): """ Call this to deliver raw audio data to be played. @@ -277,13 +315,11 @@ class Audio(pykka.ThreadingActor): :rtype: int """ - if self._playbin.get_state()[1] == gst.STATE_NULL: - return 0 try: position = self._playbin.query_position(gst.FORMAT_TIME)[0] return position // gst.MSECOND - except gst.QueryError, e: - logger.error('time_position failed: %s', e) + except gst.QueryError: + logger.debug('Position query failed') return 0 def set_position(self, position): @@ -294,12 +330,9 @@ class Audio(pykka.ThreadingActor): :type position: int :rtype: :class:`True` if successful, else :class:`False` """ - self._playbin.get_state() # block until state changes are done - handeled = self._playbin.seek_simple( + return self._playbin.seek_simple( gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) - self._playbin.get_state() # block until seek is done - return handeled def start_playback(self): """ @@ -395,10 +428,19 @@ class Audio(pykka.ThreadingActor): volumes = self._mixer.get_volume(self._mixer_track) avg_volume = float(sum(volumes)) / len(volumes) - new_scale = (0, 100) - old_scale = ( - self._mixer_track.min_volume, self._mixer_track.max_volume) - return self._rescale(avg_volume, old=old_scale, new=new_scale) + internal_scale = (0, 100) + + if self._volume_set is not None: + volume_set_on_mixer_scale = self._rescale( + self._volume_set, old=internal_scale, new=self._mixer_scale) + else: + volume_set_on_mixer_scale = None + + if volume_set_on_mixer_scale == avg_volume: + return self._volume_set + else: + return self._rescale( + avg_volume, old=self._mixer_scale, new=internal_scale) def set_volume(self, volume): """ @@ -415,11 +457,12 @@ class Audio(pykka.ThreadingActor): if self._mixer is None: return False - old_scale = (0, 100) - new_scale = ( - self._mixer_track.min_volume, self._mixer_track.max_volume) + self._volume_set = volume - volume = self._rescale(volume, old=old_scale, new=new_scale) + internal_scale = (0, 100) + + volume = self._rescale( + volume, old=internal_scale, new=self._mixer_scale) volumes = (volume,) * self._mixer_track.num_channels self._mixer.set_volume(self._mixer_track, volumes) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index b5cb522d..52ab4757 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -1,11 +1,11 @@ """Mixer that controls volume using a NAD amplifier. +The NAD amplifier must be connected to the machine running Mopidy using a +serial cable. + **Dependencies:** -- pyserial (python-serial in Debian/Ubuntu) - -- The NAD amplifier must be connected to the machine running Mopidy using a - serial cable. +.. literalinclude:: ../../../../requirements/external_mixers.txt **Settings:** diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py new file mode 100644 index 00000000..9d0f46dd --- /dev/null +++ b/mopidy/audio/utils.py @@ -0,0 +1,50 @@ +from __future__ import unicode_literals + +import pygst +pygst.require('0.10') +import gst + + +def calculate_duration(num_samples, sample_rate): + """Determine duration of samples using GStreamer helper for precise math.""" + return gst.util_uint64_scale(num_samples, gst.SECOND, sample_rate) + + +def create_buffer(data, capabilites=None, timestamp=None, duration=None): + """Create a new GStreamer buffer based on provided data. + + Mainly intended to keep gst imports out of non-audio modules. + """ + buffer_ = gst.Buffer(data) + if capabilites: + if isinstance(capabilites, basestring): + capabilites = gst.caps_from_string(capabilites) + buffer_.set_caps(capabilites) + if timestamp: + buffer_.timestamp = timestamp + if duration: + buffer_.duration = duration + return buffer_ + + +def millisecond_to_clocktime(value): + """Convert a millisecond time to internal gstreamer time.""" + return value * gst.MSECOND + + +def supported_uri_schemes(uri_schemes): + """Determine which URIs we can actually support from provided whitelist. + + :param uri_schemes: list/set of URIs to check support for. + :type uri_schemes: list or set or URI schemes as strings. + :rtype: set of URI schemes we can support via this GStreamer install. + """ + supported_schemes = set() + registry = gst.registry_get_default() + + for factory in registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY): + for uri in factory.get_uri_protocols(): + if uri in uri_schemes: + supported_schemes.add(uri) + + return supported_schemes diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index ebef096f..2f077e49 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -57,9 +57,9 @@ class BaseLibraryProvider(object): """ See :meth:`mopidy.core.LibraryController.find_exact`. - *MUST be implemented by subclass.* + *MAY be implemented by subclass.* """ - raise NotImplementedError + pass def lookup(self, uri): """ @@ -73,17 +73,17 @@ class BaseLibraryProvider(object): """ See :meth:`mopidy.core.LibraryController.refresh`. - *MUST be implemented by subclass.* + *MAY be implemented by subclass.* """ - raise NotImplementedError + pass def search(self, **query): """ See :meth:`mopidy.core.LibraryController.search`. - *MUST be implemented by subclass.* + *MAY be implemented by subclass.* """ - raise NotImplementedError + pass class BasePlaybackProvider(object): diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 39180bbb..c6997b12 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -19,7 +19,7 @@ from __future__ import unicode_literals import pykka from mopidy.backends import base -from mopidy.models import Playlist +from mopidy.models import Playlist, SearchResult class DummyBackend(pykka.ThreadingActor, base.Backend): @@ -37,8 +37,8 @@ class DummyLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] - self.dummy_find_exact_result = [] - self.dummy_search_result = [] + self.dummy_find_exact_result = SearchResult() + self.dummy_search_result = SearchResult() def find_exact(self, **query): return self.dummy_find_exact_result diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index e0e6f423..eb328ce2 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -4,7 +4,7 @@ import logging from mopidy import settings from mopidy.backends import base -from mopidy.models import Album +from mopidy.models import Album, SearchResult from .translator import parse_mpd_tag_cache @@ -46,28 +46,31 @@ class LocalLibraryProvider(base.BaseLibraryProvider): for value in values: q = value.strip() + uri_filter = lambda t: q == t.uri track_filter = lambda t: q == t.name album_filter = lambda t: q == getattr(t, 'album', Album()).name artist_filter = lambda t: filter( lambda a: q == a.name, t.artists) - uri_filter = lambda t: q == t.uri + date_filter = lambda t: q == t.date any_filter = lambda t: ( track_filter(t) or album_filter(t) or artist_filter(t) or uri_filter(t)) - if field == 'track': + if field == 'uri': + result_tracks = filter(uri_filter, result_tracks) + elif field == 'track': result_tracks = filter(track_filter, result_tracks) elif field == 'album': result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': - result_tracks = filter(uri_filter, result_tracks) + elif field == 'date': + result_tracks = filter(date_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return result_tracks + return SearchResult(uri='file:search', tracks=result_tracks) def search(self, **query): self._validate_query(query) @@ -80,28 +83,31 @@ class LocalLibraryProvider(base.BaseLibraryProvider): for value in values: q = value.strip().lower() + uri_filter = lambda t: q in t.uri.lower() track_filter = lambda t: q in t.name.lower() album_filter = lambda t: q in getattr( t, 'album', Album()).name.lower() artist_filter = lambda t: filter( lambda a: q in a.name.lower(), t.artists) - uri_filter = lambda t: q in t.uri.lower() + date_filter = lambda t: t.date and t.date.startswith(q) any_filter = lambda t: track_filter(t) or album_filter(t) or \ artist_filter(t) or uri_filter(t) - if field == 'track': + if field == 'uri': + result_tracks = filter(uri_filter, result_tracks) + elif field == 'track': result_tracks = filter(track_filter, result_tracks) elif field == 'album': result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': - result_tracks = filter(uri_filter, result_tracks) + elif field == 'date': + result_tracks = filter(date_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return result_tracks + return SearchResult(uri='file:search', tracks=result_tracks) def _validate_query(self, query): for (_, values) in query.iteritems(): diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 59e2957a..157804b4 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import logging +import urllib from mopidy.models import Track, Artist, Album from mopidy.utils.encoding import locale_decode @@ -97,6 +98,9 @@ def _convert_mpd_data(data, tracks, music_dir): if not data: return + # NOTE: kwargs are explicitly made bytestrings to work on Python + # 2.6.0/2.6.1. See https://github.com/mopidy/mopidy/issues/302 for details. + track_kwargs = {} album_kwargs = {} artist_kwargs = {} @@ -104,56 +108,60 @@ def _convert_mpd_data(data, tracks, music_dir): if 'track' in data: if '/' in data['track']: - album_kwargs['num_tracks'] = int(data['track'].split('/')[1]) - track_kwargs['track_no'] = int(data['track'].split('/')[0]) + album_kwargs[b'num_tracks'] = int(data['track'].split('/')[1]) + track_kwargs[b'track_no'] = int(data['track'].split('/')[0]) else: - track_kwargs['track_no'] = int(data['track']) + track_kwargs[b'track_no'] = int(data['track']) if 'artist' in data: - artist_kwargs['name'] = data['artist'] - albumartist_kwargs['name'] = data['artist'] + artist_kwargs[b'name'] = data['artist'] + albumartist_kwargs[b'name'] = data['artist'] if 'albumartist' in data: - albumartist_kwargs['name'] = data['albumartist'] + albumartist_kwargs[b'name'] = data['albumartist'] if 'album' in data: - album_kwargs['name'] = data['album'] + album_kwargs[b'name'] = data['album'] if 'title' in data: - track_kwargs['name'] = data['title'] + track_kwargs[b'name'] = data['title'] + + if 'date' in data: + track_kwargs[b'date'] = data['date'] if 'musicbrainz_trackid' in data: - track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid'] + track_kwargs[b'musicbrainz_id'] = data['musicbrainz_trackid'] if 'musicbrainz_albumid' in data: - album_kwargs['musicbrainz_id'] = data['musicbrainz_albumid'] + album_kwargs[b'musicbrainz_id'] = data['musicbrainz_albumid'] if 'musicbrainz_artistid' in data: - artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid'] + artist_kwargs[b'musicbrainz_id'] = data['musicbrainz_artistid'] if 'musicbrainz_albumartistid' in data: - albumartist_kwargs['musicbrainz_id'] = ( + albumartist_kwargs[b'musicbrainz_id'] = ( data['musicbrainz_albumartistid']) if data['file'][0] == '/': path = data['file'][1:] else: path = data['file'] + path = urllib.unquote(path) if artist_kwargs: artist = Artist(**artist_kwargs) - track_kwargs['artists'] = [artist] + track_kwargs[b'artists'] = [artist] if albumartist_kwargs: albumartist = Artist(**albumartist_kwargs) - album_kwargs['artists'] = [albumartist] + album_kwargs[b'artists'] = [albumartist] if album_kwargs: album = Album(**album_kwargs) - track_kwargs['album'] = album + track_kwargs[b'album'] = album - track_kwargs['uri'] = path_to_uri(music_dir, path) - track_kwargs['length'] = int(data.get('time', 0)) * 1000 + track_kwargs[b'uri'] = path_to_uri(music_dir, path) + track_kwargs[b'length'] = int(data.get('time', 0)) * 1000 track = Track(**track_kwargs) tracks.add(track) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 141656cc..507511f4 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -20,8 +20,7 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend **Dependencies:** -- libspotify >= 12, < 13 (libspotify12 package from apt.mopidy.com) -- pyspotify >= 1.9, < 1.10 (python-spotify package from apt.mopidy.com) +.. literalinclude:: ../../../requirements/spotify.txt **Settings:** diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index df04058b..96e5f616 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -1,23 +1,33 @@ from __future__ import unicode_literals import logging -import Queue +import time +import urllib +import pykka from spotify import Link, SpotifyError +from mopidy import settings from mopidy.backends import base -from mopidy.models import Track +from mopidy.models import Track, SearchResult from . import translator logger = logging.getLogger('mopidy.backends.spotify') +TRACK_AVAILABLE = 1 + class SpotifyTrack(Track): """Proxy object for unloaded Spotify tracks.""" - def __init__(self, uri): + def __init__(self, uri=None, track=None): super(SpotifyTrack, self).__init__() - self._spotify_track = Link.from_string(uri).as_track() + if (uri and track) or (not uri and not track): + raise AttributeError('uri or track must be provided') + elif uri: + self._spotify_track = Link.from_string(uri).as_track() + elif track: + self._spotify_track = track self._unloaded_track = Track(uri=uri, name='[loading...]') self._track = None @@ -57,34 +67,132 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def lookup(self, uri): try: - return [SpotifyTrack(uri)] - except SpotifyError as e: - logger.debug('Failed to lookup "%s": %s', uri, e) + link = Link.from_string(uri) + if link.type() == Link.LINK_TRACK: + return self._lookup_track(uri) + if link.type() == Link.LINK_ALBUM: + return self._lookup_album(uri) + elif link.type() == Link.LINK_ARTIST: + return self._lookup_artist(uri) + elif link.type() == Link.LINK_PLAYLIST: + return self._lookup_playlist(uri) + else: + return [] + except SpotifyError as error: + logger.debug(u'Failed to lookup "%s": %s', uri, error) return [] + def _lookup_track(self, uri): + track = Link.from_string(uri).as_track() + self._wait_for_object_to_load(track) + if track.is_loaded(): + if track.availability() == TRACK_AVAILABLE: + return [SpotifyTrack(track=track)] + else: + return [] + else: + return [SpotifyTrack(uri=uri)] + + def _lookup_album(self, uri): + album = Link.from_string(uri).as_album() + album_browser = self.backend.spotify.session.browse_album(album) + self._wait_for_object_to_load(album_browser) + return [ + SpotifyTrack(track=t) + for t in album_browser if t.availability() == TRACK_AVAILABLE] + + def _lookup_artist(self, uri): + artist = Link.from_string(uri).as_artist() + artist_browser = self.backend.spotify.session.browse_artist(artist) + self._wait_for_object_to_load(artist_browser) + return [ + SpotifyTrack(track=t) + for t in artist_browser if t.availability() == TRACK_AVAILABLE] + + def _lookup_playlist(self, uri): + playlist = Link.from_string(uri).as_playlist() + self._wait_for_object_to_load(playlist) + return [ + SpotifyTrack(track=t) + for t in playlist if t.availability() == TRACK_AVAILABLE] + + def _wait_for_object_to_load( + self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): + # XXX Sleeping to wait for the Spotify object to load is an ugly hack, + # but it works. We should look into other solutions for this. + wait_until = time.time() + timeout + while not spotify_obj.is_loaded(): + time.sleep(0.1) + if time.time() > wait_until: + logger.debug( + 'Timeout: Spotify object did not load in %ds', timeout) + return + def refresh(self, uri=None): 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 playlists when the query is empty. + return self._get_all_tracks() + + uris = query.get('uri', []) + if uris: tracks = [] - for playlist in self.backend.playlists.playlists: - tracks += playlist.tracks - return tracks + for uri in uris: + tracks += self.lookup(uri) + if len(uris) == 1: + uri = uris[0] + else: + uri = 'spotify:search' + return SearchResult(uri=uri, tracks=tracks) + + spotify_query = self._translate_search_query(query) + logger.debug('Spotify search query: %s' % spotify_query) + + future = pykka.ThreadingFuture() + + def callback(results, userdata=None): + search_result = SearchResult( + uri='spotify:search:%s' % ( + urllib.quote(results.query().encode('utf-8'))), + albums=[ + translator.to_mopidy_album(a) for a in results.albums()], + artists=[ + translator.to_mopidy_artist(a) for a in results.artists()], + tracks=[ + translator.to_mopidy_track(t) for t in results.tracks()]) + future.set(search_result) + + # Wait always returns None on python 2.6 :/ + self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT) + if not self.backend.spotify.connected.is_set(): + logger.debug('Not connected: Spotify search cancelled') + return SearchResult(uri='spotify:search') + + self.backend.spotify.session.search( + spotify_query, callback, + album_count=200, artist_count=200, track_count=200) + + try: + return future.get(timeout=settings.SPOTIFY_TIMEOUT) + except pykka.Timeout: + logger.debug( + 'Timeout: Spotify search did not return in %ds', + settings.SPOTIFY_TIMEOUT) + return SearchResult(uri='spotify:search') + + def _get_all_tracks(self): + # Since we can't search for the entire Spotify library, we return + # all tracks in the playlists when the query is empty. + tracks = [] + for playlist in self.backend.playlists.playlists: + tracks += playlist.tracks + return SearchResult(uri='spotify:search', tracks=tracks) + + def _translate_search_query(self, mopidy_query): spotify_query = [] - for (field, values) in query.iteritems(): - if field == 'uri': - tracks = [] - for value in values: - track = self.lookup(value) - if track: - tracks.append(track) - return tracks - elif field == 'track': - field = 'title' - elif field == 'date': + for (field, values) in mopidy_query.iteritems(): + if field == 'date': field = 'year' if not hasattr(values, '__iter__'): values = [values] @@ -97,10 +205,4 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): else: spotify_query.append('%s:"%s"' % (field, value)) spotify_query = ' '.join(spotify_query) - logger.debug('Spotify search query: %s' % spotify_query) - queue = Queue.Queue() - self.backend.spotify.search(spotify_query, queue) - try: - return queue.get(timeout=3) # XXX What is an reasonable timeout? - except Queue.Empty: - return [] + return spotify_query diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 2e41f072..c8d4a659 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,105 +1,65 @@ from __future__ import unicode_literals import logging -import time +import functools from spotify import Link, SpotifyError +from mopidy import audio from mopidy.backends import base -from mopidy.core import PlaybackState logger = logging.getLogger('mopidy.backends.spotify') +def seek_data_callback(spotify_backend, time_position): + logger.debug('seek_data_callback(%d) called', time_position) + spotify_backend.playback.on_seek_data(time_position) + + class SpotifyPlaybackProvider(base.BasePlaybackProvider): + # These GStreamer caps matches the audio data provided by libspotify + _caps = ( + 'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' + 'width=(int)16, depth=(int)16, signed=(boolean)true, ' + 'rate=(int)44100') + def __init__(self, *args, **kwargs): super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) - - self._timer = TrackPositionTimer() - - def pause(self): - self._timer.pause() - - return super(SpotifyPlaybackProvider, self).pause() + self._first_seek = False def change_track(self, track): - self.audio.set_uri('appsrc://').get() - self.audio.set_metadata(track).get() + seek_data_callback_bound = functools.partial( + seek_data_callback, self.backend.actor_ref.proxy()) + + self._first_seek = True + + self.audio.set_appsrc(self._caps, seek_data=seek_data_callback_bound) + self.audio.set_metadata(track) + try: self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) + self.backend.spotify.buffer_timestamp = 0 self.backend.spotify.session.play(1) + + return True except SpotifyError as e: logger.info('Playback of %s failed: %s', track.uri, e) return False - self._timer.play() - return True - - def resume(self): - time_position = self.get_time_position() - self._timer.resume() - self.audio.prepare_change() - result = self.seek(time_position) - self.audio.start_playback() - return result - - def seek(self, time_position): - self.backend.spotify.session.seek(time_position) - self._timer.seek(time_position) - return True def stop(self): self.backend.spotify.session.play(0) - return super(SpotifyPlaybackProvider, self).stop() - def get_time_position(self): - # XXX: The default implementation of get_time_position hangs/times out - # when used with the Spotify backend and GStreamer appsrc. If this can - # be resolved, we no longer need to use a wall clock based time - # position for Spotify playback. - return self._timer.get_time_position() + def on_seek_data(self, time_position): + logger.debug('playback.on_seek_data(%d) called', time_position) + if time_position == 0 and self._first_seek: + self._first_seek = False + logger.debug('Skipping seek due to issue #300') + return -class TrackPositionTimer(object): - """ - Keeps track of time position in a track using the wall clock and playback - events. - - To not introduce a reverse dependency on the playback controller, this - class keeps track of playback state itself. - """ - - def __init__(self): - self._state = PlaybackState.STOPPED - self._accumulated = 0 - self._started = 0 - - def play(self): - self._state = PlaybackState.PLAYING - self._accumulated = 0 - self._started = self._wall_time() - - def pause(self): - self._state = PlaybackState.PAUSED - self._accumulated += self._wall_time() - self._started - - def resume(self): - self._state = PlaybackState.PLAYING - - def seek(self, time_position): - self._started = self._wall_time() - self._accumulated = time_position - - def get_time_position(self): - if self._state == PlaybackState.PLAYING: - time_since_started = self._wall_time() - self._started - return self._accumulated + time_since_started - elif self._state == PlaybackState.PAUSED: - return self._accumulated - elif self._state == PlaybackState.STOPPED: - return 0 - - def _wall_time(self): - return int(time.time() * 1000) + self.backend.spotify.buffer_timestamp = audio.millisecond_to_clocktime( + time_position) + self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 288c61f2..7f71dc76 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -1,9 +1,5 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - import logging import os import threading @@ -46,6 +42,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.backend_ref = backend_ref self.connected = threading.Event() + self.buffer_timestamp = 0 self.container_manager = None self.playlist_manager = None @@ -83,6 +80,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): def logged_out(self, session): """Callback used by pyspotify""" logger.info('Disconnected from Spotify') + self.connected.clear() def metadata_updated(self, session): """Callback used by pyspotify""" @@ -119,8 +117,14 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): 'sample_rate': sample_rate, 'channels': channels, } - buffer_ = gst.Buffer(bytes(frames)) - buffer_.set_caps(gst.caps_from_string(capabilites)) + + duration = audio.calculate_duration(num_frames, sample_rate) + buffer_ = audio.create_buffer(bytes(frames), + capabilites=capabilites, + timestamp=self.buffer_timestamp, + duration=duration) + + self.buffer_timestamp += duration if self.audio.emit_data(buffer_).get(): return num_frames @@ -165,19 +169,6 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): logger.info('Loaded %d Spotify playlist(s)', len(playlists)) BackendListener.send('playlists_loaded') - def search(self, query, queue): - """Search method used by Mopidy backend""" - def callback(results, userdata=None): - # TODO Include results from results.albums(), etc. too - # TODO Consider launching a second search if results.total_tracks() - # is larger than len(results.tracks()) - tracks = [ - translator.to_mopidy_track(t) for t in results.tracks()] - queue.put(tracks) - self.connected.wait() - self.session.search( - query, callback, track_count=100, album_count=0, artist_count=0) - def logout(self): """Log out from spotify""" logger.debug('Logging out from Spotify') diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py new file mode 100644 index 00000000..82755540 --- /dev/null +++ b/mopidy/backends/stream/__init__.py @@ -0,0 +1,23 @@ +"""A backend for playing music for streaming music. + +This backend will handle streaming of URIs in +:attr:`mopidy.settings.STREAM_PROTOCOLS` assuming the right plugins are +installed. + +**Issues:** + +https://github.com/mopidy/mopidy/issues?labels=Stream+backend + +**Dependencies:** + +- None + +**Settings:** + +- :attr:`mopidy.settings.STREAM_PROTOCOLS` +""" + +from __future__ import unicode_literals + +# flake8: noqa +from .actor import StreamBackend diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py new file mode 100644 index 00000000..f80ac7a9 --- /dev/null +++ b/mopidy/backends/stream/actor.py @@ -0,0 +1,37 @@ +from __future__ import unicode_literals + +import logging +import urlparse + +import pykka + +from mopidy import audio as audio_lib, settings +from mopidy.backends import base +from mopidy.models import Track + +logger = logging.getLogger('mopidy.backends.stream') + + +class StreamBackend(pykka.ThreadingActor, base.Backend): + def __init__(self, audio): + super(StreamBackend, self).__init__() + + self.library = StreamLibraryProvider(backend=self) + self.playback = base.BasePlaybackProvider(audio=audio, backend=self) + self.playlists = None + + self.uri_schemes = audio_lib.supported_uri_schemes( + settings.STREAM_PROTOCOLS) + + +# TODO: Should we consider letting lookup know how to expand common playlist +# formats (m3u, pls, etc) for http(s) URIs? +class StreamLibraryProvider(base.BaseLibraryProvider): + def lookup(self, uri): + if urlparse.urlsplit(uri).scheme not in self.backend.uri_schemes: + return [] + # TODO: actually lookup the stream metadata by getting tags in same + # way as we do for updating the local library with mopidy.scanner + # Note that we would only want the stream metadata at this stage, + # not the currently playing track's. + return [Track(uri=uri, name=uri)] diff --git a/mopidy/core/library.py b/mopidy/core/library.py index c1a89222..e4be7ce8 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -import itertools import urlparse import pykka @@ -17,27 +16,32 @@ class LibraryController(object): uri_scheme = urlparse.urlparse(uri).scheme return self.backends.with_library_by_uri_scheme.get(uri_scheme, None) - def find_exact(self, **query): + def find_exact(self, query=None, **kwargs): """ Search the library for tracks where ``field`` is ``values``. Examples:: # Returns results matching 'a' + find_exact({'any': ['a']}) find_exact(any=['a']) + # Returns results matching artist 'xyz' + find_exact({'artist': ['xyz']}) find_exact(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + find_exact({'any': ['a', 'b'], 'artist': ['xyz']}) find_exact(any=['a', 'b'], artist=['xyz']) :param query: one or more queries to search for :type query: dict - :rtype: list of :class:`mopidy.models.Track` + :rtype: list of :class:`mopidy.models.SearchResult` """ + query = query or kwargs futures = [ b.library.find_exact(**query) for b in self.backends.with_library] - results = pykka.get_all(futures) - return list(itertools.chain(*results)) + return [result for result in pykka.get_all(futures) if result] def lookup(self, uri): """ @@ -72,24 +76,29 @@ class LibraryController(object): b.library.refresh(uri) for b in self.backends.with_library] pykka.get_all(futures) - def search(self, **query): + def search(self, query=None, **kwargs): """ Search the library for tracks where ``field`` contains ``values``. Examples:: # Returns results matching 'a' + search({'any': ['a']}) search(any=['a']) + # Returns results matching artist 'xyz' + search({'artist': ['xyz']}) search(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + search({'any': ['a', 'b'], 'artist': ['xyz']}) search(any=['a', 'b'], artist=['xyz']) :param query: one or more queries to search for :type query: dict - :rtype: list of :class:`mopidy.models.Track` + :rtype: list of :class:`mopidy.models.SearchResult` """ + query = query or kwargs futures = [ b.library.search(**query) for b in self.backends.with_library] - results = pykka.get_all(futures) - return list(itertools.chain(*results)) + return [result for result in pykka.get_all(futures) if result] diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 7c4ab093..c93fc39e 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -34,51 +34,51 @@ class CoreListener(object): """ getattr(self, event)(**kwargs) - def track_playback_paused(self, track, time_position): + def track_playback_paused(self, tl_track, time_position): """ Called whenever track playback is paused. *MAY* be implemented by actor. - :param track: the track that was playing when playback paused - :type track: :class:`mopidy.models.Track` + :param tl_track: the track that was playing when playback paused + :type tl_track: :class:`mopidy.models.TlTrack` :param time_position: the time position in milliseconds :type time_position: int """ pass - def track_playback_resumed(self, track, time_position): + def track_playback_resumed(self, tl_track, time_position): """ Called whenever track playback is resumed. *MAY* be implemented by actor. - :param track: the track that was playing when playback resumed - :type track: :class:`mopidy.models.Track` + :param tl_track: the track that was playing when playback resumed + :type tl_track: :class:`mopidy.models.TlTrack` :param time_position: the time position in milliseconds :type time_position: int """ pass - def track_playback_started(self, track): + def track_playback_started(self, tl_track): """ Called whenever a new track starts playing. *MAY* be implemented by actor. - :param track: the track that just started playing - :type track: :class:`mopidy.models.Track` + :param tl_track: the track that just started playing + :type tl_track: :class:`mopidy.models.TlTrack` """ pass - def track_playback_ended(self, track, time_position): + def track_playback_ended(self, tl_track, time_position): """ Called whenever playback of a track ends. *MAY* be implemented by actor. - :param track: the track that was played before playback stopped - :type track: :class:`mopidy.models.Track` + :param tl_track: the track that was played before playback stopped + :type tl_track: :class:`mopidy.models.TlTrack` :param time_position: the time position in milliseconds :type time_position: int """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 3ca2631f..afd34394 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -289,6 +289,8 @@ class PlaybackController(object): # For testing self._volume = volume + self._trigger_volume_changed(volume) + volume = property(get_volume, set_volume) """Volume as int in range [0..100] or :class:`None`""" @@ -485,7 +487,7 @@ class PlaybackController(object): return listener.CoreListener.send( 'track_playback_paused', - track=self.current_track, time_position=self.time_position) + tl_track=self.current_tl_track, time_position=self.time_position) def _trigger_track_playback_resumed(self): logger.debug('Triggering track playback resumed event') @@ -493,22 +495,23 @@ class PlaybackController(object): return listener.CoreListener.send( 'track_playback_resumed', - track=self.current_track, time_position=self.time_position) + tl_track=self.current_tl_track, time_position=self.time_position) def _trigger_track_playback_started(self): logger.debug('Triggering track playback started event') - if self.current_track is None: + if self.current_tl_track is None: return listener.CoreListener.send( - 'track_playback_started', track=self.current_track) + 'track_playback_started', + tl_track=self.current_tl_track) def _trigger_track_playback_ended(self): logger.debug('Triggering track playback ended event') - if self.current_track is None: + if self.current_tl_track is None: return listener.CoreListener.send( 'track_playback_ended', - track=self.current_track, time_position=self.time_position) + tl_track=self.current_tl_track, time_position=self.time_position) def _trigger_playback_state_changed(self, old_state, new_state): logger.debug('Triggering playback state change event') @@ -520,6 +523,10 @@ class PlaybackController(object): logger.debug('Triggering options changed event') listener.CoreListener.send('options_changed') + def _trigger_volume_changed(self, volume): + logger.debug('Triggering volume changed event') + listener.CoreListener.send('volume_changed', volume=volume) + def _trigger_seeked(self, time_position): logger.debug('Triggering seeked event') listener.CoreListener.send('seeked', time_position=time_position) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 6a368ac6..62098c7f 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -70,21 +70,29 @@ class PlaylistsController(object): if backend: backend.playlists.delete(uri).get() - def filter(self, **criteria): + def filter(self, criteria=None, **kwargs): """ Filter playlists by the given criterias. Examples:: - filter(name='a') # Returns track with name 'a' - filter(uri='xyz') # Returns track with URI 'xyz' - filter(name='a', uri='xyz') # Returns track with name 'a' and URI - # 'xyz' + # Returns track with name 'a' + filter({'name': 'a'}) + filter(name='a') + + # Returns track with URI 'xyz' + filter({'uri': 'xyz'}) + filter(uri='xyz') + + # Returns track with name 'a' and URI 'xyz' + filter({'name': 'a', 'uri': 'xyz'}) + filter(name='a', uri='xyz') :param criteria: one or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.Playlist` """ + criteria = criteria or kwargs matches = self.playlists for (key, value) in criteria.iteritems(): matches = filter(lambda p: getattr(p, key) == value, matches) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 656e15b1..402e6c09 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -103,21 +103,33 @@ class TracklistController(object): self._tl_tracks = [] self._increase_version() - def filter(self, **criteria): + def filter(self, criteria=None, **kwargs): """ Filter the tracklist by the given criterias. Examples:: - filter(tlid=7) # Returns track with TLID 7 (tracklist ID) - filter(id=1) # Returns track with ID 1 - filter(uri='xyz') # Returns track with URI 'xyz' - filter(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz' + # Returns track with TLID 7 (tracklist ID) + filter({'tlid': 7}) + filter(tlid=7) + + # Returns track with ID 1 + filter({'id': 1}) + filter(id=1) + + # Returns track with URI 'xyz' + filter({'uri': 'xyz'}) + filter(uri='xyz') + + # Returns track with ID 1 and URI 'xyz' + filter({'id': 1, 'uri': 'xyz'}) + filter(id=1, uri='xyz') :param criteria: on or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.TlTrack` """ + criteria = criteria or kwargs matches = self._tl_tracks for (key, value) in criteria.iteritems(): if key == 'tlid': @@ -172,7 +184,7 @@ class TracklistController(object): self._tl_tracks = new_tl_tracks self._increase_version() - def remove(self, **criteria): + def remove(self, criteria=None, **kwargs): """ Remove the matching tracks from the tracklist. @@ -184,7 +196,7 @@ class TracklistController(object): :type criteria: dict :rtype: list of :class:`mopidy.models.TlTrack` that was removed """ - tl_tracks = self.filter(**criteria) + tl_tracks = self.filter(criteria, **kwargs) for tl_track in tl_tracks: position = self._tl_tracks.index(tl_track) del self._tl_tracks[position] diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 59c867d8..94b8e58e 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -4,9 +4,7 @@ from a web based client. **Dependencies** -- ``cherrypy`` - -- ``ws4py`` +.. literalinclude:: ../../../requirements/http.txt **Settings** @@ -229,7 +227,7 @@ Once your Mopidy.js object has connected to the Mopidy server and emits the .. code-block:: js - mopidy.on("state:online", function () [ + mopidy.on("state:online", function () { mopidy.playback.next(); }); @@ -324,7 +322,7 @@ event listeners, and delete the object like this: .. code-block:: js // Close the WebSocket without reconnecting. Letting the object be garbage - // collected will have the same effect, so this isn't striclty necessary. + // collected will have the same effect, so this isn't strictly necessary. mopidy.close(); // Unregister all event listeners. If you don't do this, you may have @@ -452,7 +450,7 @@ Example to get started with 9. The web page should now queue and play your first playlist every time your load it. See the browser's console for output from the function, any errors, - and a all events that are emitted. + and all events that are emitted. """ # flake8: noqa diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 7f367262..61dc306c 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -8,7 +8,7 @@ Frontend which scrobbles the music you play to your `Last.fm **Dependencies:** -- `pylast `_ >= 0.5.7 +.. literalinclude:: ../../../requirements/lastfm.txt **Settings:** @@ -66,7 +66,8 @@ class LastfmFrontend(pykka.ThreadingActor, CoreListener): logger.error('Error during Last.fm setup: %s', e) self.stop() - def track_playback_started(self, track): + def track_playback_started(self, tl_track): + track = tl_track.track artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 self.last_start_time = int(time.time()) @@ -83,7 +84,8 @@ class LastfmFrontend(pykka.ThreadingActor, CoreListener): pylast.MalformedResponseError, pylast.WSError) as e: logger.warning('Error submitting playing track to Last.fm: %s', e) - def track_playback_ended(self, track, time_position): + def track_playback_ended(self, tl_track, time_position): + track = tl_track.track artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 time_position = time_position // 1000 diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 572192ef..6b4eacc8 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -19,6 +19,29 @@ original MPD server. Make sure :attr:`mopidy.settings.FRONTENDS` includes ``mopidy.frontends.mpd.MpdFrontend``. By default, the setting includes the MPD frontend. + +**Limitations:** + +This is a non exhaustive list of MPD features that Mopidy doesn't support. +Items on this list will probably not be supported in the near future. + +- Toggling of audio outputs is not supported +- Channels for client-to-client communication are not supported +- Stickers are not supported +- Crossfade is not supported +- Replay gain is not supported +- ``count`` does not provide any statistics +- ``stats`` does not provide any statistics +- ``list`` does not support listing tracks by genre +- ``decoders`` does not provide information about available decoders + +The following items are currently not supported, but should be added in the +near future: + +- Modifying stored playlists is not supported +- ``tagtypes`` is not supported +- Browsing the file system is not supported +- Live update of the music database is not supported """ from __future__ import unicode_literals diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 925b15b7..11e07aa7 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -23,7 +23,8 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): network.Server( hostname, port, protocol=session.MpdSession, protocol_kwargs={'core': core}, - max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) + max_connections=settings.MPD_SERVER_MAX_CONNECTIONS, + timeout=settings.MPD_SERVER_CONNECTION_TIMEOUT) except IOError as error: logger.error( 'MPD server startup failed: %s', @@ -49,5 +50,5 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def options_changed(self): self.send_idle('options') - def volume_changed(self): + def volume_changed(self, volume): self.send_idle('mixer') diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index a8bdc2c7..1827624b 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -21,8 +21,8 @@ ENCODING = 'UTF-8' #: The MPD protocol uses ``\n`` as line terminator. LINE_TERMINATOR = '\n' -#: The MPD protocol version is 0.16.0. -VERSION = '0.16.0' +#: The MPD protocol version is 0.17.0. +VERSION = '0.17.0' MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required']) @@ -74,6 +74,7 @@ def load_protocol_modules(): """ # pylint: disable = W0612 from . import ( # noqa - audio_output, command_list, connection, current_playlist, empty, - music_db, playback, reflection, status, stickers, stored_playlists) + audio_output, channels, command_list, connection, current_playlist, + empty, music_db, playback, reflection, status, stickers, + stored_playlists) # pylint: enable = W0612 diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index b4d491e5..01982a71 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -39,6 +39,6 @@ def outputs(context): """ return [ ('outputid', 0), - ('outputname', None), + ('outputname', 'Default'), ('outputenabled', 1), ] diff --git a/mopidy/frontends/mpd/protocol/channels.py b/mopidy/frontends/mpd/protocol/channels.py new file mode 100644 index 00000000..11ac6fda --- /dev/null +++ b/mopidy/frontends/mpd/protocol/channels.py @@ -0,0 +1,69 @@ +from __future__ import unicode_literals + +from mopidy.frontends.mpd.protocol import handle_request +from mopidy.frontends.mpd.exceptions import MpdNotImplemented + + +@handle_request(r'^subscribe "(?P[A-Za-z0-9:._-]+)"$') +def subscribe(context, channel): + """ + *musicpd.org, client to client section:* + + ``subscribe {NAME}`` + + Subscribe to a channel. The channel is created if it does not exist + already. The name may consist of alphanumeric ASCII characters plus + underscore, dash, dot and colon. + """ + raise MpdNotImplemented # TODO + + +@handle_request(r'^unsubscribe "(?P[A-Za-z0-9:._-]+)"$') +def unsubscribe(context, channel): + """ + *musicpd.org, client to client section:* + + ``unsubscribe {NAME}`` + + Unsubscribe from a channel. + """ + raise MpdNotImplemented # TODO + + +@handle_request(r'^channels$') +def channels(context): + """ + *musicpd.org, client to client section:* + + ``channels`` + + Obtain a list of all channels. The response is a list of "channel:" + lines. + """ + raise MpdNotImplemented # TODO + + +@handle_request(r'^readmessages$') +def readmessages(context): + """ + *musicpd.org, client to client section:* + + ``readmessages`` + + Reads messages for this client. The response is a list of "channel:" + and "message:" lines. + """ + raise MpdNotImplemented # TODO + + +@handle_request( + r'^sendmessage "(?P[A-Za-z0-9:._-]+)" "(?P[^"]*)"$') +def sendmessage(context, channel, text): + """ + *musicpd.org, client to client section:* + + ``sendmessage {CHANNEL} {TEXT}`` + + Send a message to the specified channel. + """ + raise MpdNotImplemented # TODO diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 00b9ec00..c457ee02 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,40 +1,42 @@ from __future__ import unicode_literals -import re -import shlex +import functools +import itertools -from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented +from mopidy.models import Track +from mopidy.frontends.mpd import translator +from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists -from mopidy.frontends.mpd.translator import tracks_to_mpd_format -def _build_query(mpd_query): - """ - Parses a MPD query string and converts it to the Mopidy query format. - """ - query_pattern = ( - r'"?(?:[Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny)"? "[^"]+"') - query_parts = re.findall(query_pattern, mpd_query) - query_part_pattern = ( - r'"?(?P([Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny))"? ' - r'"(?P[^"]+)"') - query = {} - for query_part in query_parts: - m = re.match(query_part_pattern, query_part) - field = m.groupdict()['field'].lower() - if field == 'title': - field = 'track' - elif field in ('file', 'filename'): - field = 'uri' - field = str(field) # Needed for kwargs keys on OS X and Windows - what = m.groupdict()['what'] - if not what: - raise ValueError - if field in query: - query[field].append(what) - else: - query[field] = [what] - return query +QUERY_RE = ( + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' + r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') + + +def _get_field(field, search_results): + return list(itertools.chain(*[getattr(r, field) for r in search_results])) + + +_get_albums = functools.partial(_get_field, 'albums') +_get_artists = functools.partial(_get_field, 'artists') +_get_tracks = functools.partial(_get_field, 'tracks') + + +def _album_as_track(album): + return Track( + uri=album.uri, + name='Album: ' + album.name, + artists=album.artists, + album=album, + date=album.date) + + +def _artist_as_track(artist): + return Track( + uri=artist.uri, + name='Artist: ' + artist.name, + artists=[artist]) @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') @@ -50,17 +52,17 @@ def count(context, tag, needle): return [('songs', 0), ('playtime', 0)] # TODO -@handle_request( - r'^find (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +@handle_request(r'^find ' + QUERY_RE) def find(context, mpd_query): """ *musicpd.org, music database section:* ``find {TYPE} {WHAT}`` - Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be - ``album``, ``artist``, or ``title``. ``WHAT`` is what to find. + Finds songs in the db that are exactly ``WHAT``. ``TYPE`` can be any + tag supported by MPD, or one of the two special parameters - ``file`` + to search by full path (relative to database root), and ``any`` to + match against all available tags. ``WHAT`` is what to find. *GMPC:* @@ -79,29 +81,35 @@ def find(context, mpd_query): - uses "file" instead of "filename". """ try: - query = _build_query(mpd_query) + query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - return tracks_to_mpd_format( - context.core.library.find_exact(**query).get()) + results = context.core.library.find_exact(**query).get() + result_tracks = [] + if 'artist' not in query: + result_tracks += [_artist_as_track(a) for a in _get_artists(results)] + if 'album' not in query: + result_tracks += [_album_as_track(a) for a in _get_albums(results)] + result_tracks += _get_tracks(results) + return translator.tracks_to_mpd_format(result_tracks) -@handle_request( - r'^findadd ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' - r'"[^"]+"\s?)+)$') -def findadd(context, query): +@handle_request(r'^findadd ' + QUERY_RE) +def findadd(context, mpd_query): """ *musicpd.org, music database section:* ``findadd {TYPE} {WHAT}`` Finds songs in the db that are exactly ``WHAT`` and adds them to - current playlist. ``TYPE`` can be any tag supported by MPD. - ``WHAT`` is what to find. + current playlist. Parameters have the same meaning as for ``find``. """ - # TODO Add result to current playlist - #result = context.find(query) + try: + query = translator.query_from_mpd_search_format(mpd_query) + except ValueError: + return + results = context.core.library.find_exact(**query).get() + context.core.tracklist.add(_get_tracks(results)) @handle_request( @@ -191,7 +199,7 @@ def list_(context, field, mpd_query=None): """ field = field.lower() try: - query = _list_build_query(field, mpd_query) + query = translator.query_from_mpd_list_format(field, mpd_query) except ValueError: return if field == 'artist': @@ -204,51 +212,10 @@ def list_(context, field, mpd_query=None): pass # TODO We don't have genre in our internal data structures yet -def _list_build_query(field, mpd_query): - """Converts a ``list`` query to a Mopidy query.""" - if mpd_query is None: - return {} - try: - # shlex does not seem to be friends with unicode objects - tokens = shlex.split(mpd_query.encode('utf-8')) - except ValueError as error: - if str(error) == 'No closing quotation': - raise MpdArgError('Invalid unquoted character', command='list') - else: - raise - tokens = [t.decode('utf-8') for t in tokens] - if len(tokens) == 1: - if field == 'album': - if not tokens[0]: - raise ValueError - return {'artist': [tokens[0]]} - else: - raise MpdArgError( - 'should be "Album" for 3 arguments', command='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 ('artist', 'album', 'date', 'genre'): - raise MpdArgError('not able to parse args', command='list') - if not value: - raise ValueError - if key in query: - query[key].append(value) - else: - query[key] = [value] - return query - else: - raise MpdArgError('not able to parse args', command='list') - - def _list_artist(context, query): artists = set() - tracks = context.core.library.find_exact(**query).get() - for track in tracks: + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): for artist in track.artists: if artist.name: artists.add(('Artist', artist.name)) @@ -257,8 +224,8 @@ def _list_artist(context, query): def _list_album(context, query): albums = set() - tracks = context.core.library.find_exact(**query).get() - for track in tracks: + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): if track.album and track.album.name: albums.add(('Album', track.album.name)) return albums @@ -266,8 +233,8 @@ def _list_album(context, query): def _list_date(context, query): dates = set() - tracks = context.core.library.find_exact(**query).get() - for track in tracks: + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): if track.date: dates.add(('Date', track.date)) return dates @@ -333,18 +300,15 @@ def rescan(context, uri=None): return update(context, uri, rescan_unmodified_files=True) -@handle_request( - r'^search (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +@handle_request(r'^search ' + QUERY_RE) def search(context, mpd_query): """ *musicpd.org, music database section:* - ``search {TYPE} {WHAT}`` + ``search {TYPE} {WHAT} [...]`` - Searches for any song that contains ``WHAT``. ``TYPE`` can be - ``title``, ``artist``, ``album`` or ``filename``. Search is not - case sensitive. + Searches for any song that contains ``WHAT``. Parameters have the same + meaning as for ``find``, except that search is not case sensitive. *GMPC:* @@ -365,11 +329,66 @@ def search(context, mpd_query): - uses "file" instead of "filename". """ try: - query = _build_query(mpd_query) + query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - return tracks_to_mpd_format( - context.core.library.search(**query).get()) + results = context.core.library.search(**query).get() + artists = [_artist_as_track(a) for a in _get_artists(results)] + albums = [_album_as_track(a) for a in _get_albums(results)] + tracks = _get_tracks(results) + return translator.tracks_to_mpd_format(artists + albums + tracks) + + +@handle_request(r'^searchadd ' + QUERY_RE) +def searchadd(context, mpd_query): + """ + *musicpd.org, music database section:* + + ``searchadd {TYPE} {WHAT} [...]`` + + Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds + them to current playlist. + + Parameters have the same meaning as for ``find``, except that search is + not case sensitive. + """ + try: + query = translator.query_from_mpd_search_format(mpd_query) + except ValueError: + return + results = context.core.library.search(**query).get() + context.core.tracklist.add(_get_tracks(results)) + + +@handle_request(r'^searchaddpl "(?P[^"]+)" ' + QUERY_RE) +def searchaddpl(context, playlist_name, mpd_query): + """ + *musicpd.org, music database section:* + + ``searchaddpl {NAME} {TYPE} {WHAT} [...]`` + + Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds + them to the playlist named ``NAME``. + + If a playlist by that name doesn't exist it is created. + + Parameters have the same meaning as for ``find``, except that search is + not case sensitive. + """ + try: + query = translator.query_from_mpd_search_format(mpd_query) + except ValueError: + return + results = context.core.library.search(**query).get() + + playlists = context.core.playlists.filter(name=playlist_name).get() + if playlists: + playlist = playlists[0] + else: + playlist = context.core.playlists.create(playlist_name).get() + tracks = list(playlist.tracks) + _get_tracks(results) + playlist = playlist.copy(tracks=tracks) + context.core.playlists.save(playlist) @handle_request(r'^update( "(?P[^"]+)")*$') diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 5a4569e1..8e08585f 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -329,7 +329,7 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - if context.core.playback.tracklist_position.get() != songpos: + if context.core.playback.tracklist_position.get() != int(songpos): playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000).get() @@ -344,11 +344,31 @@ def seekid(context, tlid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ tl_track = context.core.playback.current_tl_track.get() - if not tl_track or tl_track.tlid != tlid: + if not tl_track or tl_track.tlid != int(tlid): playid(context, tlid) context.core.playback.seek(int(seconds) * 1000).get() +@handle_request(r'^seekcur "(?P\d+)"$') +@handle_request(r'^seekcur "(?P[-+]\d+)"$') +def seekcur(context, position=None, diff=None): + """ + *musicpd.org, playback section:* + + ``seekcur {TIME}`` + + Seeks to the position ``TIME`` within the current song. If prefixed by + '+' or '-', then the time is relative to the current playing position. + """ + if position is not None: + position = int(position) * 1000 + context.core.playback.seek(position).get() + elif diff is not None: + position = context.core.playback.time_position.get() + position += int(diff) * 1000 + context.core.playback.seek(position).get() + + @handle_request(r'^setvol (?P[-+]*\d+)$') @handle_request(r'^setvol "(?P[-+]*\d+)"$') def setvol(context, volume): diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index d9c35743..cc1c7222 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -1,8 +1,23 @@ from __future__ import unicode_literals +from mopidy.frontends.mpd.exceptions import MpdPermissionError from mopidy.frontends.mpd.protocol import handle_request, mpd_commands +@handle_request(r'^config$', auth_required=False) +def config(context): + """ + *musicpd.org, reflection section:* + + ``config`` + + Dumps configuration values that may be interesting for the client. This + command is only permitted to "local" clients (connected via UNIX domain + socket). + """ + raise MpdPermissionError(command='config') + + @handle_request(r'^commands$', auth_required=False) def commands(context): """ @@ -19,10 +34,10 @@ def commands(context): command.name for command in mpd_commands if not command.auth_required]) - # No one is permited to use kill, rest of commands are not listed by MPD, - # so we shouldn't either. + # No one is permited to use 'config' or 'kill', rest of commands are not + # listed by MPD, so we shouldn't either. command_names = command_names - set([ - 'kill', 'command_list_begin', 'command_list_ok_begin', + 'config', 'kill', 'command_list_begin', 'command_list_ok_begin', 'command_list_ok_begin', 'command_list_end', 'idle', 'noidle', 'sticker']) @@ -73,6 +88,7 @@ def notcommands(context): command.name for command in mpd_commands if command.auth_required] # No permission to use + command_names.append('config') command_names.append('kill') return [ diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index eef1f3d1..b1fe87de 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -82,33 +82,45 @@ def listplaylists(context): continue result.append(('playlist', playlist.name)) last_modified = ( - playlist.last_modified or dt.datetime.now()).isoformat() + playlist.last_modified or dt.datetime.utcnow()).isoformat() # Remove microseconds last_modified = last_modified.split('.')[0] # Add time zone information - # TODO Convert to UTC before adding Z last_modified = last_modified + 'Z' result.append(('Last-Modified', last_modified)) return result -@handle_request(r'^load "(?P[^"]+)"$') -def load(context, name): +@handle_request(r'^load "(?P[^"]+)"( "(?P\d+):(?P\d+)*")*$') +def load(context, name, start=None, end=None): """ *musicpd.org, stored playlists section:* - ``load {NAME}`` + ``load {NAME} [START:END]`` - Loads the playlist ``NAME.m3u`` from the playlist directory. + Loads the playlist into the current queue. Playlist plugins are + supported. A range may be specified to load only a part of the + playlist. *Clarifications:* - ``load`` appends the given playlist to the current playlist. + + - MPD 0.17.1 does not support open-ended ranges, i.e. without end + specified, for the ``load`` command, even though MPD's general range docs + allows open-ended ranges. + + - MPD 0.17.1 does not fail if the specified range is outside the playlist, + in either or both ends. """ playlists = context.core.playlists.filter(name=name).get() if not playlists: raise MpdNoExistError('No such playlist', command='load') - context.core.tracklist.add(playlists[0].tracks) + if start is not None: + start = int(start) + if end is not None: + end = int(end) + context.core.tracklist.add(playlists[0].tracks[start:end]) @handle_request(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 3b77f929..e26d7dce 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -2,9 +2,12 @@ from __future__ import unicode_literals import os import re +import shlex +import urllib from mopidy import settings from mopidy.frontends.mpd import protocol +from mopidy.frontends.mpd.exceptions import MpdArgError from mopidy.models import TlTrack from mopidy.utils.path import mtime as get_mtime, uri_to_path, split_path @@ -133,6 +136,85 @@ def playlist_to_mpd_format(playlist, *args, **kwargs): return tracks_to_mpd_format(playlist.tracks, *args, **kwargs) +def query_from_mpd_list_format(field, mpd_query): + """ + Converts an MPD ``list`` query to a Mopidy query. + """ + if mpd_query is None: + return {} + try: + # shlex does not seem to be friends with unicode objects + tokens = shlex.split(mpd_query.encode('utf-8')) + except ValueError as error: + if str(error) == 'No closing quotation': + raise MpdArgError('Invalid unquoted character', command='list') + else: + raise + tokens = [t.decode('utf-8') for t in tokens] + if len(tokens) == 1: + if field == 'album': + if not tokens[0]: + raise ValueError + return {'artist': [tokens[0]]} + else: + raise MpdArgError( + 'should be "Album" for 3 arguments', command='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 ('artist', 'album', 'date', 'genre'): + raise MpdArgError('not able to parse args', command='list') + if not value: + raise ValueError + if key in query: + query[key].append(value) + else: + query[key] = [value] + return query + else: + raise MpdArgError('not able to parse args', command='list') + + +def query_from_mpd_search_format(mpd_query): + """ + Parses an MPD ``search`` or ``find`` query and converts it to the Mopidy + query format. + + :param mpd_query: the MPD search query + :type mpd_query: string + """ + # XXX The regexps below should be refactored to reuse common patterns here + # and in mopidy.frontends.mpd.protocol.music_db. + query_pattern = ( + r'"?(?:[Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' + r'[Tt]itle|[Aa]ny)"? "[^"]+"') + query_parts = re.findall(query_pattern, mpd_query) + query_part_pattern = ( + r'"?(?P([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' + r'[Tt]itle|[Aa]ny))"? "(?P[^"]+)"') + query = {} + for query_part in query_parts: + m = re.match(query_part_pattern, query_part) + field = m.groupdict()['field'].lower() + if field == 'title': + field = 'track' + elif field in ('file', 'filename'): + field = 'uri' + field = str(field) # Needed for kwargs keys on OS X and Windows + what = m.groupdict()['what'] + if not what: + raise ValueError + if field in query: + query[field].append(what) + else: + query[field] = [what] + return query + + def tracks_to_tag_cache_format(tracks): """ Format list of tracks for output to MPD tag cache @@ -153,40 +235,56 @@ def tracks_to_tag_cache_format(tracks): def _add_to_tag_cache(result, folders, files): - music_folder = settings.LOCAL_MUSIC_PATH + base_path = settings.LOCAL_MUSIC_PATH.encode('utf-8') for path, entry in folders.items(): - name = os.path.split(path)[1] - mtime = get_mtime(os.path.join(music_folder, path)) - result.append(('directory', path)) - result.append(('mtime', mtime)) + try: + text_path = path.decode('utf-8') + except UnicodeDecodeError: + text_path = urllib.quote(path).decode('utf-8') + name = os.path.split(text_path)[1] + result.append(('directory', text_path)) + result.append(('mtime', get_mtime(os.path.join(base_path, path)))) result.append(('begin', name)) _add_to_tag_cache(result, *entry) result.append(('end', name)) result.append(('songList begin',)) + for track in files: track_result = dict(track_to_mpd_format(track)) - track_result['mtime'] = get_mtime(uri_to_path(track_result['file'])) - track_result['file'] = track_result['file'] - track_result['key'] = os.path.basename(track_result['file']) + + path = uri_to_path(track_result['file']) + try: + text_path = path.decode('utf-8') + except UnicodeDecodeError: + text_path = urllib.quote(path).decode('utf-8') + relative_path = os.path.relpath(path, base_path) + relative_uri = urllib.quote(relative_path) + + track_result['file'] = relative_uri + track_result['mtime'] = get_mtime(path) + track_result['key'] = os.path.basename(text_path) track_result = order_mpd_track_info(track_result.items()) + result.extend(track_result) + result.append(('songList end',)) def tracks_to_directory_tree(tracks): directories = ({}, []) + for track in tracks: - path = '' + path = b'' current = directories - local_folder = settings.LOCAL_MUSIC_PATH - track_path = uri_to_path(track.uri) - track_path = re.sub('^' + re.escape(local_folder), '', track_path) - track_dir = os.path.dirname(track_path) + absolute_track_dir_path = os.path.dirname(uri_to_path(track.uri)) + relative_track_dir_path = re.sub( + '^' + re.escape(settings.LOCAL_MUSIC_PATH), b'', + absolute_track_dir_path) - for part in split_path(track_dir): + for part in split_path(relative_track_dir_path): path = os.path.join(path, part) if path not in current[0]: current[0][path] = ({}, []) diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index 795b2694..5e171826 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -66,25 +66,25 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): self.mpris_object.PropertiesChanged( interface, dict(props_with_new_values), []) - def track_playback_paused(self, track, time_position): + def track_playback_paused(self, tl_track, time_position): logger.debug('Received track_playback_paused event') self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) - def track_playback_resumed(self, track, time_position): + def track_playback_resumed(self, tl_track, time_position): logger.debug('Received track_playback_resumed event') self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) - def track_playback_started(self, track): + def track_playback_started(self, tl_track): logger.debug('Received track_playback_started event') self._emit_properties_changed( objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) - def track_playback_ended(self, track, time_position): + def track_playback_ended(self, tl_track, time_position): logger.debug('Received track_playback_ended event') self._emit_properties_changed( objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) - def volume_changed(self): + def volume_changed(self, volume): logger.debug('Received volume_changed event') self._emit_properties_changed(objects.PLAYER_IFACE, ['Volume']) diff --git a/mopidy/models.py b/mopidy/models.py index a4ed1b4f..73209b6e 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -290,7 +290,7 @@ class Playlist(ImmutableObject): :type name: string :param tracks: playlist's tracks :type tracks: list of :class:`Track` elements - :param last_modified: playlist's modification time + :param last_modified: playlist's modification time in UTC :type last_modified: :class:`datetime.datetime` """ @@ -303,7 +303,7 @@ class Playlist(ImmutableObject): #: The playlist's tracks. Read-only. tracks = tuple() - #: The playlist modification time. Read-only. + #: The playlist modification time in UTC. Read-only. #: #: :class:`datetime.datetime`, or :class:`None` if unknown. last_modified = None @@ -318,3 +318,34 @@ class Playlist(ImmutableObject): def length(self): """The number of tracks in the playlist. Read-only.""" return len(self.tracks) + + +class SearchResult(ImmutableObject): + """ + :param uri: search result URI + :type uri: string + :param tracks: matching tracks + :type tracks: list of :class:`Track` elements + :param artists: matching artists + :type artists: list of :class:`Artist` elements + :param albums: matching albums + :type albums: list of :class:`Album` elements + """ + + # The search result URI. Read-only. + uri = None + + # The tracks matching the search query. Read-only. + tracks = tuple() + + # The artists matching the search query. Read-only. + artists = tuple() + + # The albums matching the search query. Read-only. + albums = tuple() + + def __init__(self, *args, **kwargs): + self.__dict__['tracks'] = tuple(kwargs.pop('tracks', [])) + self.__dict__['artists'] = tuple(kwargs.pop('artists', [])) + self.__dict__['albums'] = tuple(kwargs.pop('albums', [])) + super(SearchResult, self).__init__(*args, **kwargs) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 0b10d061..9f8c12f7 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -79,12 +79,15 @@ def main(): def parse_options(): parser = optparse.OptionParser( version='Mopidy %s' % versioning.get_version()) + # NOTE Python 2.6: To support Python versions < 2.6.2rc1 we must use + # bytestrings for the first argument to ``add_option`` + # See https://github.com/mopidy/mopidy/issues/302 for details parser.add_option( - '-q', '--quiet', + b'-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') parser.add_option( - '-v', '--verbose', + b'-v', '--verbose', action='count', default=1, dest='verbosity_level', help='more output (debug level)') return parser.parse_args(args=mopidy_args)[0] @@ -96,9 +99,13 @@ def translator(data): artist_kwargs = {} track_kwargs = {} + # NOTE: kwargs are explicitly made bytestrings to work on Python + # 2.6.0/2.6.1. See https://github.com/mopidy/mopidy/issues/302 for + # details. + def _retrieve(source_key, target_key, target): if source_key in data: - target[target_key] = data[source_key] + target[str(target_key)] = data[source_key] _retrieve(gst.TAG_ALBUM, 'name', album_kwargs) _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) @@ -111,7 +118,7 @@ def translator(data): except ValueError: pass # Ignore invalid dates else: - track_kwargs['date'] = date.isoformat() + track_kwargs[b'date'] = date.isoformat() _retrieve(gst.TAG_TITLE, 'name', track_kwargs) _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs) @@ -125,12 +132,12 @@ def translator(data): 'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) if albumartist_kwargs: - album_kwargs['artists'] = [Artist(**albumartist_kwargs)] + album_kwargs[b'artists'] = [Artist(**albumartist_kwargs)] - track_kwargs['uri'] = data['uri'] - track_kwargs['length'] = data[gst.TAG_DURATION] - track_kwargs['album'] = Album(**album_kwargs) - track_kwargs['artists'] = [Artist(**artist_kwargs)] + track_kwargs[b'uri'] = data['uri'] + track_kwargs[b'length'] = data[gst.TAG_DURATION] + track_kwargs[b'album'] = Album(**album_kwargs) + track_kwargs[b'artists'] = [Artist(**artist_kwargs)] return Track(**track_kwargs) diff --git a/mopidy/settings.py b/mopidy/settings.py index 259bc645..fd3dfd6f 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -20,10 +20,12 @@ from __future__ import unicode_literals #: BACKENDS = ( #: u'mopidy.backends.local.LocalBackend', #: u'mopidy.backends.spotify.SpotifyBackend', +#: u'mopidy.backends.spotify.StreamBackend', #: ) BACKENDS = ( 'mopidy.backends.local.LocalBackend', 'mopidy.backends.spotify.SpotifyBackend', + 'mopidy.backends.stream.StreamBackend', ) #: The log format used for informational logging. @@ -103,10 +105,10 @@ HTTP_SERVER_HOSTNAME = u'127.0.0.1' #: Default: 6680 HTTP_SERVER_PORT = 6680 -#: Which directory Mopidy's HTTP server should serve at /. +#: Which directory Mopidy's HTTP server should serve at ``/``. #: #: Change this to have Mopidy serve e.g. files for your JavaScript client. -#: /api and /ws will continue to work as usual even if you change this setting. +#: ``/mopidy`` will continue to work as usual even if you change this setting. #: #: Used by :mod:`mopidy.frontends.http`. #: @@ -174,6 +176,16 @@ MIXER = 'autoaudiomixer' #: MIXER_TRACK = None MIXER_TRACK = None +#: Number of seconds an MPD client can stay inactive before the connection is +#: closed by the server. +#: +#: Used by :mod:`mopidy.frontends.mpd`. +#: +#: Default:: +#: +#: MPD_SERVER_CONNECTION_TIMEOUT = 60 +MPD_SERVER_CONNECTION_TIMEOUT = 60 + #: Which address Mopidy's MPD server should bind to. #: #: Used by :mod:`mopidy.frontends.mpd`. @@ -276,9 +288,41 @@ SPOTIFY_PROXY_USERNAME = None #: Spotify proxy password. #: -#: Used by :mod:`mopidy.backends.spotify` +#: Used by :mod:`mopidy.backends.spotify`. #: #: Default:: #: #: SPOTIFY_PROXY_PASSWORD = None SPOTIFY_PROXY_PASSWORD = None + +#: Max number of seconds to wait for Spotify operations to complete. +#: +#: Used by :mod:`mopidy.backends.spotify`. +#: +#: Default:: +#: +#: SPOTIFY_TIMEOUT = 10 +SPOTIFY_TIMEOUT = 10 + +#: Whitelist of URIs to support streaming from. +#: +#: Used by :mod:`mopidy.backends.stream`. +#: +#: Default:: +#: +#: STREAM_PROTOCOLS = ( +#: u'http', +#: u'https', +#: u'mms', +#: u'rtmp', +#: u'rtmps', +#: u'rtsp', +#: ) +STREAM_PROTOCOLS = ( + 'http', + 'https', + 'mms', + 'rtmp', + 'rtmps', + 'rtsp', +) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 604350d1..1ffb12d6 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -291,7 +291,7 @@ class Connection(object): return True def timeout_callback(self): - self.stop('Client timeout out after %s seconds' % self.timeout) + self.stop('Client inactive for %ds; closing connection' % self.timeout) return False diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 73063183..7d988a90 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -51,19 +51,40 @@ def get_or_create_file(filename): def path_to_uri(*paths): + """ + Convert OS specific path to file:// URI. + + Accepts either unicode strings or bytestrings. The encoding of any + bytestring will be maintained so that :func:`uri_to_path` can return the + same bytestring. + + Returns a file:// URI as an unicode string. + """ path = os.path.join(*paths) - path = path.encode('utf-8') + if isinstance(path, unicode): + path = path.encode('utf-8') if sys.platform == 'win32': - return 'file:' + urllib.pathname2url(path) - return 'file://' + urllib.pathname2url(path) + return 'file:' + urllib.quote(path) + return 'file://' + urllib.quote(path) def uri_to_path(uri): + """ + Convert the file:// to a OS specific path. + + Returns a bytestring, since the file path can contain chars with other + encoding than UTF-8. + + If we had returned these paths as unicode strings, you wouldn't be able to + look up the matching dir or file on your file system because the exact path + would be lost by ignoring its encoding. + """ + if isinstance(uri, unicode): + uri = uri.encode('utf-8') if sys.platform == 'win32': - path = urllib.url2pathname(re.sub('^file:', '', uri)) + return urllib.unquote(re.sub(b'^file:', b'', uri)) else: - path = urllib.url2pathname(re.sub('^file://', '', uri)) - return path.encode('latin1').decode('utf-8') # Undo double encoding + return urllib.unquote(re.sub(b'^file://', b'', uri)) def split_path(path): @@ -72,7 +93,7 @@ def split_path(path): path, part = os.path.split(path) if part: parts.insert(0, part) - if not path or path == '/': + if not path or path == b'/': break return parts @@ -85,30 +106,32 @@ def expand_path(path): def find_files(path): + """ + Finds all files within a path. + + Directories and files with names starting with ``.`` is ignored. + + :returns: yields the full path to files as bytestrings + """ + if isinstance(path, unicode): + path = path.encode('utf-8') + if os.path.isfile(path): - if not isinstance(path, unicode): - path = path.decode('utf-8') - if not os.path.basename(path).startswith('.'): + if not os.path.basename(path).startswith(b'.'): yield path else: - for dirpath, dirnames, filenames in os.walk(path): - # Filter out hidden folders by modifying dirnames in place. + for dirpath, dirnames, filenames in os.walk(path, followlinks=True): for dirname in dirnames: - if dirname.startswith('.'): + if dirname.startswith(b'.'): + # Skip hidden folders by modifying dirnames inplace dirnames.remove(dirname) for filename in filenames: - # Skip hidden files. - if filename.startswith('.'): + if filename.startswith(b'.'): + # Skip hidden files continue - filename = os.path.join(dirpath, filename) - if not isinstance(filename, unicode): - try: - filename = filename.decode('utf-8') - except UnicodeDecodeError: - filename = filename.decode('latin1') - yield filename + yield os.path.join(dirpath, filename) def check_file_path_is_inside_base_dir(file_path, base_path): diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 5edf287e..6be8937c 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -101,7 +101,7 @@ class DebugThread(threading.Thread): stack = ''.join(traceback.format_stack(frame)) logger.debug( 'Current state of %s (%s):\n%s', - threads[ident], ident, stack) + threads.get(ident, '?'), ident, stack) del frame self.event.clear() diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index fee5252d..8ae61e5b 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -142,7 +142,13 @@ def validate_settings(defaults, settings): 'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH', } - list_of_one_or_more = [ + must_be_iterable = [ + 'BACKENDS', + 'FRONTENDS', + 'STREAM_PROTOCOLS', + ] + + must_have_value_set = [ 'BACKENDS', 'FRONTENDS', ] @@ -171,9 +177,13 @@ def validate_settings(defaults, settings): 'Deprecated setting, please set the value via the GStreamer ' 'bin in OUTPUT.') - elif setting in list_of_one_or_more: - if not value: - errors[setting] = 'Must contain at least one value.' + elif setting in must_be_iterable and not hasattr(value, '__iter__'): + errors[setting] = ( + 'Must be a tuple. ' + "Remember the comma after single values: (u'value',)") + + elif setting in must_have_value_set and not value: + errors[setting] = 'Must be set.' elif setting not in defaults and not setting.startswith('CUSTOM_'): errors[setting] = 'Unknown setting.' diff --git a/requirements/README.rst b/requirements/README.rst index cc061a7b..e1a6d757 100644 --- a/requirements/README.rst +++ b/requirements/README.rst @@ -3,8 +3,8 @@ pip requirement files ********************* The files found here are `requirement files -`_ that may be used with `pip -`_. +`_ that may be used +with `pip `_. To install the dependencies found in one of these files, simply run e.g.:: diff --git a/requirements/core.txt b/requirements/core.txt index 7f83e251..7a28564f 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1 +1,2 @@ Pykka >= 1.0 +# Available as python-pykka from apt.mopidy.com diff --git a/requirements/external_mixers.txt b/requirements/external_mixers.txt index f6c1a1f5..20cb7864 100644 --- a/requirements/external_mixers.txt +++ b/requirements/external_mixers.txt @@ -1 +1,2 @@ pyserial +# Available as python-serial in Debian/Ubuntu diff --git a/requirements/http.txt b/requirements/http.txt index d8757e29..aea7c1a8 100644 --- a/requirements/http.txt +++ b/requirements/http.txt @@ -1,2 +1,5 @@ cherrypy >= 3.2.2 +# Available as python-cherrypy3 in Debian/Ubuntu + ws4py >= 0.2.3 +# Available as python-ws4py from apt.mopidy.com diff --git a/requirements/lastfm.txt b/requirements/lastfm.txt index 314c4223..c52256c3 100644 --- a/requirements/lastfm.txt +++ b/requirements/lastfm.txt @@ -1 +1,3 @@ pylast >= 0.5.7 +# Available as python-pylast in newer Debian/Ubuntu and from apt.mopidy.com for +# older releases of Debian/Ubuntu diff --git a/requirements/spotify.txt b/requirements/spotify.txt index c37d4674..333e55c8 100644 --- a/requirements/spotify.txt +++ b/requirements/spotify.txt @@ -1 +1,8 @@ -pyspotify >= 1.9, < 1.10 +pyspotify >= 1.9, < 1.11 +# The libspotify Python wrapper +# Available as the python-spotify package from apt.mopidy.com + +# libspotify >= 12, < 13 +# The libspotify C library from +# https://developer.spotify.com/technologies/libspotify/ +# Available as the libspotify12 package from apt.mopidy.com diff --git a/requirements/tests.txt b/requirements/tests.txt index 20aff929..74fe7595 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -5,4 +5,3 @@ nose pylint tox unittest2 -yappi diff --git a/setup.py b/setup.py index 6135df31..5840ca53 100644 --- a/setup.py +++ b/setup.py @@ -94,7 +94,7 @@ setup( scripts=['bin/mopidy', 'bin/mopidy-scan'], url='http://www.mopidy.com/', license='Apache License, Version 2.0', - description='MPD server with Spotify support', + description='Music server with MPD and Spotify support', long_description=open('README.rst').read(), classifiers=[ 'Development Status :: 4 - Beta', diff --git a/tests/__main__.py b/tests/__main__.py index 11757cbb..164f1e66 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -1,10 +1,5 @@ from __future__ import unicode_literals import nose -import yappi -try: - yappi.start() - nose.main() -finally: - yappi.print_stats() +nose.main() diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index 64666d9d..73c8c165 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -4,6 +4,8 @@ import pygst pygst.require('0.10') import gst +import pykka + from mopidy import audio, settings from mopidy.utils.path import path_to_uri @@ -18,7 +20,7 @@ class AudioTest(unittest.TestCase): self.audio = audio.Audio.start().proxy() def tearDown(self): - self.audio.stop() + pykka.ActorRegistry.stop_all() settings.runtime.clear() def prepare_uri(self, uri): @@ -56,6 +58,14 @@ class AudioTest(unittest.TestCase): self.assertTrue(self.audio.set_volume(value).get()) self.assertEqual(value, self.audio.get_volume().get()) + def test_set_volume_with_mixer_max_below_100(self): + settings.MIXER = 'fakemixer track_max_volume=40' + self.audio = audio.Audio.start().proxy() + + for value in range(0, 101): + self.assertTrue(self.audio.set_volume(value).get()) + self.assertEqual(value, self.audio.get_volume().get()) + @unittest.SkipTest def test_set_state_encapsulation(self): pass # TODO diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 4e9232e5..c75bec74 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -16,11 +16,12 @@ class LibraryControllerTest(object): Album()] tracks = [ Track( - name='track1', length=4000, artists=artists[:1], - album=albums[0], uri='file://' + path_to_data_dir('uri1')), + uri='file://' + path_to_data_dir('uri1'), name='track1', + artists=artists[:1], album=albums[0], date='2001-02-03', + length=4000), Track( - name='track2', length=4000, artists=artists[1:2], - album=albums[1], uri='file://' + path_to_data_dir('uri2')), + uri='file://' + path_to_data_dir('uri2'), name='track2', + artists=artists[1:2], album=albums[1], date='2002', length=4000), Track()] def setUp(self): @@ -52,43 +53,53 @@ class LibraryControllerTest(object): def test_find_exact_no_hits(self): result = self.library.find_exact(track=['unknown track']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.find_exact(artist=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.find_exact(album=['unknown artist']) - self.assertEqual(result, []) - - def test_find_exact_artist(self): - result = self.library.find_exact(artist=['artist1']) - self.assertEqual(result, self.tracks[:1]) - - result = self.library.find_exact(artist=['artist2']) - self.assertEqual(result, self.tracks[1:2]) - - def test_find_exact_track(self): - result = self.library.find_exact(track=['track1']) - self.assertEqual(result, self.tracks[:1]) - - result = self.library.find_exact(track=['track2']) - self.assertEqual(result, self.tracks[1:2]) - - def test_find_exact_album(self): - result = self.library.find_exact(album=['album1']) - self.assertEqual(result, self.tracks[:1]) - - result = self.library.find_exact(album=['album2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), []) def test_find_exact_uri(self): track_1_uri = 'file://' + path_to_data_dir('uri1') result = self.library.find_exact(uri=track_1_uri) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) track_2_uri = 'file://' + path_to_data_dir('uri2') result = self.library.find_exact(uri=track_2_uri) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_track(self): + result = self.library.find_exact(track=['track1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(track=['track2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_artist(self): + result = self.library.find_exact(artist=['artist1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(artist=['artist2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_album(self): + result = self.library.find_exact(album=['album1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(album=['album2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_date(self): + result = self.library.find_exact(date=['2001']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(date=['2001-02-03']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(date=['2002']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) @@ -106,57 +117,70 @@ class LibraryControllerTest(object): def test_search_no_hits(self): result = self.library.search(track=['unknown track']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(artist=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(album=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(uri=['unknown']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(any=['unknown']) - self.assertEqual(result, []) - - def test_search_artist(self): - result = self.library.search(artist=['Tist1']) - self.assertEqual(result, self.tracks[:1]) - - result = self.library.search(artist=['Tist2']) - self.assertEqual(result, self.tracks[1:2]) - - def test_search_track(self): - result = self.library.search(track=['Rack1']) - self.assertEqual(result, self.tracks[:1]) - - result = self.library.search(track=['Rack2']) - self.assertEqual(result, self.tracks[1:2]) - - def test_search_album(self): - result = self.library.search(album=['Bum1']) - self.assertEqual(result, self.tracks[:1]) - - result = self.library.search(album=['Bum2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), []) def test_search_uri(self): result = self.library.search(uri=['RI1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(uri=['RI2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_track(self): + result = self.library.search(track=['Rack1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(track=['Rack2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_artist(self): + result = self.library.search(artist=['Tist1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(artist=['Tist2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_album(self): + result = self.library.search(album=['Bum1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(album=['Bum2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_date(self): + result = self.library.search(date=['2001']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(date=['2001-02-03']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(date=['2001-02-04']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(date=['2002']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_any(self): result = self.library.search(any=['Tist1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['Rack1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['Bum1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['RI1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): test = lambda: self.library.search(wrong=['test']) diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 90ee849d..61a86672 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -99,8 +99,8 @@ expected_tracks = [] def generate_track(path, ident): uri = path_to_uri(path_to_data_dir(path)) track = Track( - name='trackname', artists=expected_artists, track_no=1, - album=expected_albums[0], length=4000, uri=uri) + uri=uri, name='trackname', artists=expected_artists, + album=expected_albums[0], track_no=1, date='2006', length=4000) expected_tracks.append(track) @@ -126,8 +126,8 @@ class MPDTagCacheToTracksTest(unittest.TestCase): path_to_data_dir('simple_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) track = Track( - name='trackname', artists=expected_artists, track_no=1, - album=expected_albums[0], length=4000, uri=uri) + uri=uri, name='trackname', artists=expected_artists, track_no=1, + album=expected_albums[0], date='2006', length=4000) self.assertEqual(set([track]), tracks) def test_advanced_cache(self): @@ -182,6 +182,6 @@ class MPDTagCacheToTracksTest(unittest.TestCase): artist = Artist(name='albumartistname') album = expected_albums[0].copy(artists=[artist]) track = Track( - name='trackname', artists=expected_artists, track_no=1, - album=album, length=4000, uri=uri) + uri=uri, name='trackname', artists=expected_artists, track_no=1, + album=album, date='2006', length=4000) self.assertEqual(track, list(tracks)[0]) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 88f07de6..11881db7 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import mock import pykka -from mopidy import audio, core +from mopidy import core from mopidy.backends import dummy from mopidy.models import Track @@ -13,97 +13,130 @@ from tests import unittest @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.audio = mock.Mock(spec=audio.Audio) - self.backend = dummy.DummyBackend.start(audio=audio).proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): pykka.ActorRegistry.stop_all() def test_backends_playlists_loaded_forwards_event_to_frontends(self, send): - send.reset_mock() self.core.playlists_loaded().get() + self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_pause_sends_track_playback_paused_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]) + tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() self.core.playback.play().get() send.reset_mock() + self.core.playback.pause().get() + self.assertEqual(send.call_args[0][0], 'track_playback_paused') + self.assertEqual(send.call_args[1]['tl_track'], tl_tracks[0]) + self.assertEqual(send.call_args[1]['time_position'], 0) def test_resume_sends_track_playback_resumed(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]) + tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() self.core.playback.play() self.core.playback.pause().get() send.reset_mock() + self.core.playback.resume().get() + self.assertEqual(send.call_args[0][0], 'track_playback_resumed') + self.assertEqual(send.call_args[1]['tl_track'], tl_tracks[0]) + self.assertEqual(send.call_args[1]['time_position'], 0) def test_play_sends_track_playback_started_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]) + tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() send.reset_mock() + self.core.playback.play().get() + self.assertEqual(send.call_args[0][0], 'track_playback_started') + self.assertEqual(send.call_args[1]['tl_track'], tl_tracks[0]) def test_stop_sends_track_playback_ended_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]) + tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() self.core.playback.play().get() send.reset_mock() + self.core.playback.stop().get() + self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended') + self.assertEqual(send.call_args_list[0][1]['tl_track'], tl_tracks[0]) + self.assertEqual(send.call_args_list[0][1]['time_position'], 0) def test_seek_sends_seeked_event(self, send): self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play().get() send.reset_mock() + self.core.playback.seek(1000).get() + self.assertEqual(send.call_args[0][0], 'seeked') + self.assertEqual(send.call_args[1]['time_position'], 1000) def test_tracklist_add_sends_tracklist_changed_event(self, send): send.reset_mock() + self.core.tracklist.add([Track(uri='dummy:a')]).get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_clear_sends_tracklist_changed_event(self, send): self.core.tracklist.add([Track(uri='dummy:a')]).get() send.reset_mock() + self.core.tracklist.clear().get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_move_sends_tracklist_changed_event(self, send): self.core.tracklist.add( [Track(uri='dummy:a'), Track(uri='dummy:b')]).get() send.reset_mock() + self.core.tracklist.move(0, 1, 1).get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_remove_sends_tracklist_changed_event(self, send): self.core.tracklist.add([Track(uri='dummy:a')]).get() send.reset_mock() + self.core.tracklist.remove(uri='dummy:a').get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_shuffle_sends_tracklist_changed_event(self, send): self.core.tracklist.add( [Track(uri='dummy:a'), Track(uri='dummy:b')]).get() send.reset_mock() + self.core.tracklist.shuffle().get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_playlists_refresh_sends_playlists_loaded_event(self, send): send.reset_mock() + self.core.playlists.refresh().get() + self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_playlists_refresh_uri_sends_playlists_loaded_event(self, send): send.reset_mock() + self.core.playlists.refresh(uri_scheme='dummy').get() + self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_playlists_create_sends_playlist_changed_event(self, send): send.reset_mock() + self.core.playlists.create('foo').get() + self.assertEqual(send.call_args[0][0], 'playlist_changed') @unittest.SkipTest @@ -113,7 +146,18 @@ class BackendEventsTest(unittest.TestCase): def test_playlists_save_sends_playlist_changed_event(self, send): playlist = self.core.playlists.create('foo').get() - send.reset_mock() playlist = playlist.copy(name='bar') + send.reset_mock() + self.core.playlists.save(playlist).get() + self.assertEqual(send.call_args[0][0], 'playlist_changed') + + def test_set_volume_sends_volume_changed_event(self, send): + self.core.playback.set_volume(10).get() + send.reset_mock() + + self.core.playback.set_volume(20).get() + + self.assertEqual(send.call_args[0][0], 'volume_changed') + self.assertEqual(send.call_args[1]['volume'], 20) diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 1bd481de..e01696c7 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -4,7 +4,7 @@ import mock from mopidy.backends import base from mopidy.core import Core -from mopidy.models import Track +from mopidy.models import SearchResult, Track from tests import unittest @@ -75,29 +75,103 @@ class CoreLibraryTest(unittest.TestCase): def test_find_exact_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') - self.library1.find_exact().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.find_exact().get.return_value = result1 self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = [track2] + self.library2.find_exact().get.return_value = result2 self.library2.find_exact.reset_mock() result = self.core.library.find_exact(any=['a']) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, result) + self.library1.find_exact.assert_called_once_with(any=['a']) + self.library2.find_exact.assert_called_once_with(any=['a']) + + def test_find_exact_filters_out_none(self): + track1 = Track(uri='dummy1:a') + result1 = SearchResult(tracks=[track1]) + + self.library1.find_exact().get.return_value = result1 + self.library1.find_exact.reset_mock() + self.library2.find_exact().get.return_value = None + self.library2.find_exact.reset_mock() + + result = self.core.library.find_exact(any=['a']) + + self.assertIn(result1, result) + self.assertNotIn(None, result) + self.library1.find_exact.assert_called_once_with(any=['a']) + self.library2.find_exact.assert_called_once_with(any=['a']) + + def test_find_accepts_query_dict_instead_of_kwargs(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.find_exact().get.return_value = result1 + self.library1.find_exact.reset_mock() + self.library2.find_exact().get.return_value = result2 + self.library2.find_exact.reset_mock() + + result = self.core.library.find_exact(dict(any=['a'])) + + self.assertIn(result1, result) + self.assertIn(result2, result) self.library1.find_exact.assert_called_once_with(any=['a']) self.library2.find_exact.assert_called_once_with(any=['a']) def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') - self.library1.search().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.search().get.return_value = result1 self.library1.search.reset_mock() - self.library2.search().get.return_value = [track2] + self.library2.search().get.return_value = result2 self.library2.search.reset_mock() result = self.core.library.search(any=['a']) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, result) + self.library1.search.assert_called_once_with(any=['a']) + self.library2.search.assert_called_once_with(any=['a']) + + def test_search_filters_out_none(self): + track1 = Track(uri='dummy1:a') + result1 = SearchResult(tracks=[track1]) + + self.library1.search().get.return_value = result1 + self.library1.search.reset_mock() + self.library2.search().get.return_value = None + self.library2.search.reset_mock() + + result = self.core.library.search(any=['a']) + + self.assertIn(result1, result) + self.assertNotIn(None, result) + self.library1.search.assert_called_once_with(any=['a']) + self.library2.search.assert_called_once_with(any=['a']) + + def test_search_accepts_query_dict_instead_of_kwargs(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.search().get.return_value = result1 + self.library1.search.reset_mock() + self.library2.search().get.return_value = result2 + self.library2.search.reset_mock() + + result = self.core.library.search(dict(any=['a'])) + + self.assertIn(result1, result) + self.assertIn(result2, result) self.library1.search.assert_called_once_with(any=['a']) self.library2.search.assert_called_once_with(any=['a']) diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index 8aaf1234..2d7182d9 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import mock from mopidy.core import CoreListener, PlaybackState -from mopidy.models import Playlist, Track +from mopidy.models import Playlist, TlTrack from tests import unittest @@ -16,22 +16,22 @@ class CoreListenerTest(unittest.TestCase): self.listener.track_playback_paused = mock.Mock() self.listener.on_event( - 'track_playback_paused', track=Track(), position=0) + 'track_playback_paused', track=TlTrack(), position=0) self.listener.track_playback_paused.assert_called_with( - track=Track(), position=0) + track=TlTrack(), position=0) def test_listener_has_default_impl_for_track_playback_paused(self): - self.listener.track_playback_paused(Track(), 0) + self.listener.track_playback_paused(TlTrack(), 0) def test_listener_has_default_impl_for_track_playback_resumed(self): - self.listener.track_playback_resumed(Track(), 0) + self.listener.track_playback_resumed(TlTrack(), 0) def test_listener_has_default_impl_for_track_playback_started(self): - self.listener.track_playback_started(Track()) + self.listener.track_playback_started(TlTrack()) def test_listener_has_default_impl_for_track_playback_ended(self): - self.listener.track_playback_ended(Track(), 0) + self.listener.track_playback_ended(TlTrack(), 0) def test_listener_has_default_impl_for_playback_state_changed(self): self.listener.playback_state_changed( diff --git a/tests/core/playlists.py b/tests/core/playlists_test.py similarity index 91% rename from tests/core/playlists.py rename to tests/core/playlists_test.py index 949625fe..cea93c5b 100644 --- a/tests/core/playlists.py +++ b/tests/core/playlists_test.py @@ -27,12 +27,12 @@ class PlaylistsTest(unittest.TestCase): self.backend3.has_playlists().get.return_value = False self.backend3.playlists = None - self.pl1a = Playlist(tracks=[Track(uri='dummy1:a')]) - self.pl1b = Playlist(tracks=[Track(uri='dummy1:b')]) + self.pl1a = Playlist(name='A', tracks=[Track(uri='dummy1:a')]) + self.pl1b = Playlist(name='B', tracks=[Track(uri='dummy1:b')]) self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] - self.pl2a = Playlist(tracks=[Track(uri='dummy2:a')]) - self.pl2b = Playlist(tracks=[Track(uri='dummy2:b')]) + self.pl2a = Playlist(name='A', tracks=[Track(uri='dummy2:a')]) + self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:b')]) self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] self.core = Core(audio=None, backends=[ @@ -103,6 +103,16 @@ class PlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) + def test_filter_returns_matching_playlists(self): + result = self.core.playlists.filter(name='A') + + self.assertEqual(2, len(result)) + + def test_filter_accepts_dict_instead_of_kwargs(self): + result = self.core.playlists.filter({'name': 'A'}) + + self.assertEqual(2, len(result)) + def test_lookup_selects_the_dummy1_backend(self): self.core.playlists.lookup('dummy1:a') diff --git a/tests/core/tracklist_test.py b/tests/core/tracklist_test.py new file mode 100644 index 00000000..550cfe63 --- /dev/null +++ b/tests/core/tracklist_test.py @@ -0,0 +1,49 @@ +from __future__ import unicode_literals + +from mopidy.core import Core +from mopidy.models import Track + +from tests import unittest + + +class TracklistTest(unittest.TestCase): + def setUp(self): + self.tracks = [ + Track(uri='a', name='foo'), + Track(uri='b', name='foo'), + Track(uri='c', name='bar') + ] + self.core = Core(audio=None, backends=[]) + self.tl_tracks = self.core.tracklist.add(self.tracks) + + def test_remove_removes_tl_tracks_matching_query(self): + tl_tracks = self.core.tracklist.remove(name='foo') + + self.assertEqual(2, len(tl_tracks)) + self.assertListEqual(self.tl_tracks[:2], tl_tracks) + + self.assertEqual(1, self.core.tracklist.length) + self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks) + + def test_remove_works_with_dict_instead_of_kwargs(self): + tl_tracks = self.core.tracklist.remove({'name': 'foo'}) + + self.assertEqual(2, len(tl_tracks)) + self.assertListEqual(self.tl_tracks[:2], tl_tracks) + + self.assertEqual(1, self.core.tracklist.length) + self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks) + + def test_filter_returns_tl_tracks_matching_query(self): + tl_tracks = self.core.tracklist.filter(name='foo') + + self.assertEqual(2, len(tl_tracks)) + self.assertListEqual(self.tl_tracks[:2], tl_tracks) + + def test_filter_works_with_dict_instead_of_kwargs(self): + tl_tracks = self.core.tracklist.filter({'name': 'foo'}) + + self.assertEqual(2, len(tl_tracks)) + self.assertListEqual(self.tl_tracks[:2], tl_tracks) + + # TODO Extract tracklist tests from the base backend tests diff --git a/tests/data/library_tag_cache b/tests/data/library_tag_cache index e090fcbd..50771a0a 100644 --- a/tests/data/library_tag_cache +++ b/tests/data/library_tag_cache @@ -8,12 +8,14 @@ file: /uri1 Artist: artist1 Title: track1 Album: album1 +Date: 2001-02-03 Time: 4 key: uri2 file: /uri2 Artist: artist2 Title: track2 Album: album2 +Date: 2002 Time: 4 key: uri3 file: /uri3 diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index 11cd249e..560e935f 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -15,6 +15,6 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): def test_outputs(self): self.sendRequest('outputs') self.assertInResponse('outputid: 0') - self.assertInResponse('outputname: None') + self.assertInResponse('outputname: Default') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/channels_test.py b/tests/frontends/mpd/protocol/channels_test.py new file mode 100644 index 00000000..86cf8197 --- /dev/null +++ b/tests/frontends/mpd/protocol/channels_test.py @@ -0,0 +1,25 @@ +from __future__ import unicode_literals + +from tests.frontends.mpd import protocol + + +class ChannelsHandlerTest(protocol.BaseTestCase): + def test_subscribe(self): + self.sendRequest('subscribe "topic"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_unsubscribe(self): + self.sendRequest('unsubscribe "topic"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_channels(self): + self.sendRequest('channels') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_readmessages(self): + self.sendRequest('readmessages') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_sendmessage(self): + self.sendRequest('sendmessage "topic" "a message"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 4539eb4c..d16a636b 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from mopidy.models import Album, Artist, Track +from mopidy.models import Album, Artist, SearchResult, Track from tests.frontends.mpd import protocol @@ -13,7 +13,61 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_findadd(self): - self.sendRequest('findadd "album" "what"') + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) + self.assertEqual(self.core.tracklist.length.get(), 0) + + self.sendRequest('findadd "title" "A"') + + self.assertEqual(self.core.tracklist.length.get(), 1) + self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') + self.assertInResponse('OK') + + def test_searchadd(self): + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) + self.assertEqual(self.core.tracklist.length.get(), 0) + + self.sendRequest('searchadd "title" "a"') + + self.assertEqual(self.core.tracklist.length.get(), 1) + self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') + self.assertInResponse('OK') + + def test_searchaddpl_appends_to_existing_playlist(self): + playlist = self.core.playlists.create('my favs').get() + playlist = playlist.copy(tracks=[ + Track(uri='dummy:x', name='X'), + Track(uri='dummy:y', name='y'), + ]) + self.core.playlists.save(playlist) + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) + playlists = self.core.playlists.filter(name='my favs').get() + self.assertEqual(len(playlists), 1) + self.assertEqual(len(playlists[0].tracks), 2) + + self.sendRequest('searchaddpl "my favs" "title" "a"') + + playlists = self.core.playlists.filter(name='my favs').get() + self.assertEqual(len(playlists), 1) + self.assertEqual(len(playlists[0].tracks), 3) + self.assertEqual(playlists[0].tracks[0].uri, 'dummy:x') + self.assertEqual(playlists[0].tracks[1].uri, 'dummy:y') + self.assertEqual(playlists[0].tracks[2].uri, 'dummy:a') + self.assertInResponse('OK') + + def test_searchaddpl_creates_missing_playlist(self): + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) + self.assertEqual( + len(self.core.playlists.filter(name='my favs').get()), 0) + + self.sendRequest('searchaddpl "my favs" "title" "a"') + + playlists = self.core.playlists.filter(name='my favs').get() + self.assertEqual(len(playlists), 1) + self.assertEqual(playlists[0].tracks[0].uri, 'dummy:a') self.assertInResponse('OK') def test_listall(self): @@ -61,6 +115,66 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): class MusicDatabaseFindTest(protocol.BaseTestCase): + def test_find_includes_fake_artist_and_album_tracks(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A', date='2001')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "any" "foo"') + + self.assertInResponse('file: dummy:artist:b') + self.assertInResponse('Title: Artist: B') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('Date: 2001') + + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + + def test_find_artist_does_not_include_fake_artist_tracks(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A', date='2001')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "artist" "foo"') + + self.assertNotInResponse('file: dummy:artist:b') + self.assertNotInResponse('Title: Artist: B') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('Date: 2001') + + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + + def test_find_artist_and_album_does_not_include_fake_tracks(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A', date='2001')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "artist" "foo" "album" "bar"') + + self.assertNotInResponse('file: dummy:artist:b') + self.assertNotInResponse('Title: Artist: B') + + self.assertNotInResponse('file: dummy:album:a') + self.assertNotInResponse('Title: Album: A') + self.assertNotInResponse('Date: 2001') + + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + def test_find_album(self): self.sendRequest('find "album" "what"') self.assertInResponse('OK') @@ -127,6 +241,17 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): class MusicDatabaseListTest(protocol.BaseTestCase): + def test_list(self): + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[ + Track(uri='dummy:a', name='A', artists=[ + Artist(name='A Artist')])]) + + self.sendRequest('list "artist" "artist" "foo"') + + self.assertInResponse('Artist: A Artist') + self.assertInResponse('OK') + def test_list_foo_returns_ack(self): self.sendRequest('list "foo"') self.assertEqualResponse('ACK [2@0] {list} incorrect arguments') @@ -184,8 +309,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_artist_should_not_return_artists_without_names(self): - self.backend.library.dummy_find_exact_result = [ - Track(artists=[Artist(name='')])] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(artists=[Artist(name='')])]) self.sendRequest('list "artist"') self.assertNotInResponse('Artist: ') @@ -243,8 +368,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_album_should_not_return_albums_without_names(self): - self.backend.library.dummy_find_exact_result = [ - Track(album=Album(name=''))] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(album=Album(name=''))]) self.sendRequest('list "album"') self.assertNotInResponse('Album: ') @@ -298,7 +423,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_date_should_not_return_blank_dates(self): - self.backend.library.dummy_find_exact_result = [Track(date='')] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(date='')]) self.sendRequest('list "date"') self.assertNotInResponse('Date: ') @@ -354,6 +480,23 @@ class MusicDatabaseListTest(protocol.BaseTestCase): class MusicDatabaseSearchTest(protocol.BaseTestCase): + def test_search(self): + self.backend.library.dummy_search_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('search "any" "foo"') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('file: dummy:artist:b') + self.assertInResponse('Title: Artist: B') + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + def test_search_album(self): self.sendRequest('search "album" "analbum"') self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 14168a35..cc49a8cd 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -371,49 +371,93 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest('previous') self.assertInResponse('OK') - def test_seek(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + def test_seek_in_current_track(self): + seek_track = Track(uri='dummy:a', length=40000) + self.core.tracklist.add([seek_track]) + self.core.playback.play() - self.sendRequest('seek "0"') self.sendRequest('seek "0" "30"') + + self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertGreaterEqual(self.core.playback.time_position, 30000) self.assertInResponse('OK') - def test_seek_with_songpos(self): + def test_seek_in_another_track(self): seek_track = Track(uri='dummy:b', length=40000) self.core.tracklist.add( [Track(uri='dummy:a', length=40000), seek_track]) + self.core.playback.play() + self.assertNotEqual(self.core.playback.current_track.get(), seek_track) self.sendRequest('seek "1" "30"') + self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertInResponse('OK') def test_seek_without_quotes(self): self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() - self.sendRequest('seek 0') self.sendRequest('seek 0 30') self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') - def test_seekid(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + def test_seekid_in_current_track(self): + seek_track = Track(uri='dummy:a', length=40000) + self.core.tracklist.add([seek_track]) + self.core.playback.play() + self.sendRequest('seekid "0" "30"') + + self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') - def test_seekid_with_tlid(self): + def test_seekid_in_another_track(self): seek_track = Track(uri='dummy:b', length=40000) self.core.tracklist.add( [Track(uri='dummy:a', length=40000), seek_track]) + self.core.playback.play() self.sendRequest('seekid "1" "30"') + self.assertEqual(1, self.core.playback.current_tl_track.get().tlid) self.assertEqual(seek_track, self.core.playback.current_track.get()) self.assertInResponse('OK') + def test_seekcur_absolute_value(self): + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + + self.sendRequest('seekcur "30"') + + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) + self.assertInResponse('OK') + + def test_seekcur_positive_diff(self): + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(10000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 10000) + + self.sendRequest('seekcur "+20"') + + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) + self.assertInResponse('OK') + + def test_seekcur_negative_diff(self): + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(30000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) + + self.sendRequest('seekcur "-20"') + + self.assertLessEqual(self.core.playback.time_position.get(), 15000) + self.assertInResponse('OK') + def test_stop(self): self.sendRequest('stop') self.assertEqual(STOPPED, self.core.playback.state.get()) diff --git a/tests/frontends/mpd/protocol/reflection_test.py b/tests/frontends/mpd/protocol/reflection_test.py index 9c07f104..f2720473 100644 --- a/tests/frontends/mpd/protocol/reflection_test.py +++ b/tests/frontends/mpd/protocol/reflection_test.py @@ -6,6 +6,11 @@ from tests.frontends.mpd import protocol class ReflectionHandlerTest(protocol.BaseTestCase): + def test_config_is_not_allowed_across_the_network(self): + self.sendRequest('config') + self.assertEqualResponse( + 'ACK [4@0] {config} you don\'t have permission for "config"') + def test_commands_returns_list_of_all_commands(self): self.sendRequest('commands') # Check if some random commands are included @@ -13,6 +18,7 @@ class ReflectionHandlerTest(protocol.BaseTestCase): self.assertInResponse('command: play') self.assertInResponse('command: status') # Check if commands you do not have access to are not present + self.assertNotInResponse('command: config') self.assertNotInResponse('command: kill') # Check if the blacklisted commands are not present self.assertNotInResponse('command: command_list_begin') @@ -40,9 +46,10 @@ class ReflectionHandlerTest(protocol.BaseTestCase): self.sendRequest('decoders') self.assertInResponse('OK') - def test_notcommands_returns_only_kill_and_ok(self): + def test_notcommands_returns_only_config_and_kill_and_ok(self): response = self.sendRequest('notcommands') - self.assertEqual(2, len(response)) + self.assertEqual(3, len(response)) + self.assertInResponse('command: config') self.assertInResponse('command: kill') self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index be2afd4c..49da5d0b 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -73,7 +73,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertNotInResponse('playlist: ') self.assertInResponse('OK') - def test_load_known_playlist_appends_to_tracklist(self): + def test_load_appends_to_tracklist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.playlists.playlists = [ @@ -81,6 +81,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): Track(uri='c'), Track(uri='d'), Track(uri='e')])] self.sendRequest('load "A-list"') + tracks = self.core.tracklist.tracks.get() self.assertEqual(5, len(tracks)) self.assertEqual('a', tracks[0].uri) @@ -90,6 +91,39 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertEqual('e', tracks[4].uri) self.assertInResponse('OK') + def test_load_with_range_loads_part_of_playlist(self): + self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) + self.backend.playlists.playlists = [ + Playlist(name='A-list', tracks=[ + Track(uri='c'), Track(uri='d'), Track(uri='e')])] + + self.sendRequest('load "A-list" "1:2"') + + tracks = self.core.tracklist.tracks.get() + self.assertEqual(3, len(tracks)) + self.assertEqual('a', tracks[0].uri) + self.assertEqual('b', tracks[1].uri) + self.assertEqual('d', tracks[2].uri) + self.assertInResponse('OK') + + def test_load_with_range_without_end_loads_rest_of_playlist(self): + self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) + self.backend.playlists.playlists = [ + Playlist(name='A-list', tracks=[ + Track(uri='c'), Track(uri='d'), Track(uri='e')])] + + self.sendRequest('load "A-list" "1:"') + + tracks = self.core.tracklist.tracks.get() + self.assertEqual(4, len(tracks)) + self.assertEqual('a', tracks[0].uri) + self.assertEqual('b', tracks[1].uri) + self.assertEqual('d', tracks[2].uri) + self.assertEqual('e', tracks[3].uri) + self.assertInResponse('OK') + def test_load_unknown_playlist_acks(self): self.sendRequest('load "unknown playlist"') self.assertEqual(0, len(self.core.tracklist.tracks.get())) diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/translator_test.py similarity index 95% rename from tests/frontends/mpd/serializer_test.py rename to tests/frontends/mpd/translator_test.py index 211db600..088ae137 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/translator_test.py @@ -4,7 +4,7 @@ import datetime import os from mopidy import settings -from mopidy.utils.path import mtime +from mopidy.utils.path import mtime, uri_to_path from mopidy.frontends.mpd import translator, protocol from mopidy.models import Album, Artist, TlTrack, Playlist, Track @@ -121,6 +121,20 @@ class PlaylistMpdFormatTest(unittest.TestCase): self.assertEqual(dict(result[0])['Track'], 2) +class QueryFromMpdSearchFormatTest(unittest.TestCase): + def test_dates_are_extracted(self): + result = translator.query_from_mpd_search_format( + 'Date "1974-01-02" Date "1975"') + self.assertEqual(result['date'][0], '1974-01-02') + self.assertEqual(result['date'][1], '1975') + + # TODO Test more mappings + + +class QueryFromMpdListFormatTest(unittest.TestCase): + pass # TODO + + class TracksToTagCacheFormatTest(unittest.TestCase): def setUp(self): settings.LOCAL_MUSIC_PATH = '/dir/subdir' @@ -131,7 +145,9 @@ class TracksToTagCacheFormatTest(unittest.TestCase): mtime.undo_fake() def translate(self, track): + base_path = settings.LOCAL_MUSIC_PATH.encode('utf-8') result = dict(translator.track_to_mpd_format(track)) + result['file'] = uri_to_path(result['file'])[len(base_path) + 1:] result['key'] = os.path.basename(result['file']) result['mtime'] = mtime('') return translator.order_mpd_track_info(result.items()) diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 18a9de6f..f1add1b3 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -5,7 +5,7 @@ import sys import mock from mopidy.exceptions import OptionalDependencyError -from mopidy.models import Playlist, Track +from mopidy.models import Playlist, TlTrack try: from mopidy.frontends.mpris import MprisFrontend, objects @@ -25,7 +25,7 @@ class BackendEventsTest(unittest.TestCase): def test_track_playback_paused_event_changes_playback_status(self): self.mpris_object.Get.return_value = 'Paused' - self.mpris_frontend.track_playback_paused(Track(), 0) + self.mpris_frontend.track_playback_paused(TlTrack(), 0) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), ]) @@ -34,7 +34,7 @@ class BackendEventsTest(unittest.TestCase): def test_track_playback_resumed_event_changes_playback_status(self): self.mpris_object.Get.return_value = 'Playing' - self.mpris_frontend.track_playback_resumed(Track(), 0) + self.mpris_frontend.track_playback_resumed(TlTrack(), 0) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), ]) @@ -43,7 +43,7 @@ class BackendEventsTest(unittest.TestCase): def test_track_playback_started_changes_playback_status_and_metadata(self): self.mpris_object.Get.return_value = '...' - self.mpris_frontend.track_playback_started(Track()) + self.mpris_frontend.track_playback_started(TlTrack()) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), ((objects.PLAYER_IFACE, 'Metadata'), {}), @@ -54,7 +54,7 @@ class BackendEventsTest(unittest.TestCase): def test_track_playback_ended_changes_playback_status_and_metadata(self): self.mpris_object.Get.return_value = '...' - self.mpris_frontend.track_playback_ended(Track(), 0) + self.mpris_frontend.track_playback_ended(TlTrack(), 0) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), ((objects.PLAYER_IFACE, 'Metadata'), {}), @@ -65,7 +65,7 @@ class BackendEventsTest(unittest.TestCase): def test_volume_changed_event_changes_volume(self): self.mpris_object.Get.return_value = 1.0 - self.mpris_frontend.volume_changed() + self.mpris_frontend.volume_changed(volume=100) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'Volume'), {}), ]) diff --git a/tests/models_test.py b/tests/models_test.py index 9a3062fc..89d0b132 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -4,7 +4,7 @@ import datetime import json from mopidy.models import ( - Artist, Album, TlTrack, Track, Playlist, + Artist, Album, TlTrack, Track, Playlist, SearchResult, ModelJSONEncoder, model_json_decoder) from tests import unittest @@ -707,7 +707,7 @@ class PlaylistTest(unittest.TestCase): self.assertEqual(playlist.length, 3) def test_last_modified(self): - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() playlist = Playlist(last_modified=last_modified) self.assertEqual(playlist.last_modified, last_modified) self.assertRaises( @@ -715,7 +715,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_uri(self): tracks = [Track()] - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) @@ -727,7 +727,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_name(self): tracks = [Track()] - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) @@ -739,7 +739,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_tracks(self): tracks = [Track()] - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) @@ -752,7 +752,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_last_modified(self): tracks = [Track()] - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() new_last_modified = last_modified + datetime.timedelta(1) playlist = Playlist( uri='an uri', name='a name', tracks=tracks, @@ -862,10 +862,56 @@ class PlaylistTest(unittest.TestCase): def test_ne(self): playlist1 = Playlist( - uri='uri1', name='name2', tracks=[Track(uri='uri1')], + uri='uri1', name='name1', tracks=[Track(uri='uri1')], last_modified=1) playlist2 = Playlist( uri='uri2', name='name2', tracks=[Track(uri='uri2')], last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) + + +class SearchResultTest(unittest.TestCase): + def test_uri(self): + uri = 'an_uri' + result = SearchResult(uri=uri) + self.assertEqual(result.uri, uri) + self.assertRaises(AttributeError, setattr, result, 'uri', None) + + def test_tracks(self): + tracks = [Track(), Track(), Track()] + result = SearchResult(tracks=tracks) + self.assertEqual(list(result.tracks), tracks) + self.assertRaises(AttributeError, setattr, result, 'tracks', None) + + def test_artists(self): + artists = [Artist(), Artist(), Artist()] + result = SearchResult(artists=artists) + self.assertEqual(list(result.artists), artists) + self.assertRaises(AttributeError, setattr, result, 'artists', None) + + def test_albums(self): + albums = [Album(), Album(), Album()] + result = SearchResult(albums=albums) + self.assertEqual(list(result.albums), albums) + self.assertRaises(AttributeError, setattr, result, 'albums', None) + + def test_invalid_kwarg(self): + test = lambda: SearchResult(foo='baz') + self.assertRaises(TypeError, test) + + def test_repr_without_results(self): + self.assertEquals( + "SearchResult(albums=[], artists=[], tracks=[], uri=u'uri')", + repr(SearchResult(uri='uri'))) + + def test_serialize_without_results(self): + self.assertDictEqual( + {'__model__': 'SearchResult', 'uri': 'uri'}, + SearchResult(uri='uri').serialize()) + + def test_to_json_and_back(self): + result1 = SearchResult(uri='uri') + serialized = json.dumps(result1, cls=ModelJSONEncoder) + result2 = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(result1, result2) diff --git a/tests/scanner_test.py b/tests/scanner_test.py index 92e9a269..d8466e26 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -32,36 +32,40 @@ class TranslatorTest(unittest.TestCase): 'musicbrainz-albumartistid': 'mbalbumartistid', } + # NOTE: kwargs are explicitly made bytestrings to work on Python + # 2.6.0/2.6.1. See https://github.com/mopidy/mopidy/issues/302 for + # details. + self.album = { - 'name': 'albumname', - 'num_tracks': 2, - 'musicbrainz_id': 'mbalbumid', + b'name': 'albumname', + b'num_tracks': 2, + b'musicbrainz_id': 'mbalbumid', } self.artist = { - 'name': 'name', - 'musicbrainz_id': 'mbartistid', + b'name': 'name', + b'musicbrainz_id': 'mbartistid', } self.albumartist = { - 'name': 'albumartistname', - 'musicbrainz_id': 'mbalbumartistid', + b'name': 'albumartistname', + b'musicbrainz_id': 'mbalbumartistid', } self.track = { - 'uri': 'uri', - 'name': 'trackname', - 'date': '2006-01-01', - 'track_no': 1, - 'length': 4531, - 'musicbrainz_id': 'mbtrackid', + b'uri': 'uri', + b'name': 'trackname', + b'date': '2006-01-01', + b'track_no': 1, + b'length': 4531, + b'musicbrainz_id': 'mbtrackid', } def build_track(self): if self.albumartist: - self.album['artists'] = [Artist(**self.albumartist)] - self.track['album'] = Album(**self.album) - self.track['artists'] = [Artist(**self.artist)] + self.album[b'artists'] = [Artist(**self.albumartist)] + self.track[b'album'] = Album(**self.album) + self.track[b'artists'] = [Artist(**self.artist)] return Track(**self.track) def check(self): diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 7c8a0a9b..59cb89b5 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -609,4 +609,6 @@ class JsonRpcInspectorTest(JsonRpcTestBase): self.assertEquals( methods['core.tracklist.filter']['params'][0]['name'], 'criteria') self.assertEquals( - methods['core.tracklist.filter']['params'][0]['kwargs'], True) + methods['core.tracklist.filter']['params'][1]['name'], 'kwargs') + self.assertEquals( + methods['core.tracklist.filter']['params'][1]['kwargs'], True) diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 512a3ba1..461f0809 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -90,31 +90,55 @@ class PathToFileURITest(unittest.TestCase): result = path.path_to_uri('/tmp/æøå') self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') + def test_utf8_in_path(self): + if sys.platform == 'win32': + result = path.path_to_uri('C:/æøå'.encode('utf-8')) + self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') + else: + result = path.path_to_uri('/tmp/æøå'.encode('utf-8')) + self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') + + def test_latin1_in_path(self): + if sys.platform == 'win32': + result = path.path_to_uri('C:/æøå'.encode('latin-1')) + self.assertEqual(result, 'file:///C://%E6%F8%E5') + else: + result = path.path_to_uri('/tmp/æøå'.encode('latin-1')) + self.assertEqual(result, 'file:///tmp/%E6%F8%E5') + class UriToPathTest(unittest.TestCase): def test_simple_uri(self): if sys.platform == 'win32': result = path.uri_to_path('file:///C://WINDOWS/clock.avi') - self.assertEqual(result, 'C:/WINDOWS/clock.avi') + self.assertEqual(result, 'C:/WINDOWS/clock.avi'.encode('utf-8')) else: result = path.uri_to_path('file:///etc/fstab') - self.assertEqual(result, '/etc/fstab') + self.assertEqual(result, '/etc/fstab'.encode('utf-8')) def test_space_in_uri(self): if sys.platform == 'win32': result = path.uri_to_path('file:///C://test%20this') - self.assertEqual(result, 'C:/test this') + self.assertEqual(result, 'C:/test this'.encode('utf-8')) else: result = path.uri_to_path('file:///tmp/test%20this') - self.assertEqual(result, '/tmp/test this') + self.assertEqual(result, '/tmp/test this'.encode('utf-8')) def test_unicode_in_uri(self): if sys.platform == 'win32': result = path.uri_to_path('file:///C://%C3%A6%C3%B8%C3%A5') - self.assertEqual(result, 'C:/æøå') + self.assertEqual(result, 'C:/æøå'.encode('utf-8')) else: result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5') - self.assertEqual(result, '/tmp/æøå') + self.assertEqual(result, '/tmp/æøå'.encode('utf-8')) + + def test_latin1_in_uri(self): + if sys.platform == 'win32': + result = path.uri_to_path('file:///C://%E6%F8%E5') + self.assertEqual(result, 'C:/æøå'.encode('latin-1')) + else: + result = path.uri_to_path('file:///tmp/%E6%F8%E5') + self.assertEqual(result, '/tmp/æøå'.encode('latin-1')) class SplitPathTest(unittest.TestCase): @@ -177,11 +201,11 @@ class FindFilesTest(unittest.TestCase): self.assertEqual(len(files), 1) self.assert_(files[0], path_to_data_dir('blank.mp3')) - def test_names_are_unicode(self): - is_unicode = lambda f: isinstance(f, unicode) + def test_names_are_bytestrings(self): + is_bytes = lambda f: isinstance(f, bytes) for name in self.find(''): self.assert_( - is_unicode(name), '%s is not unicode object' % repr(name)) + is_bytes(name), '%s is not bytes object' % repr(name)) def test_ignores_hidden_folders(self): self.assertEqual(self.find('.hidden'), []) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 0ecbb90f..51f0d89c 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -79,13 +79,21 @@ class ValidateSettingsTest(unittest.TestCase): result = setting_utils.validate_settings( self.defaults, {'FRONTENDS': []}) self.assertEqual( - result['FRONTENDS'], 'Must contain at least one value.') + result['FRONTENDS'], 'Must be set.') def test_empty_backends_list_returns_error(self): result = setting_utils.validate_settings( self.defaults, {'BACKENDS': []}) self.assertEqual( - result['BACKENDS'], 'Must contain at least one value.') + result['BACKENDS'], 'Must be set.') + + def test_noniterable_multivalue_setting_returns_error(self): + result = setting_utils.validate_settings( + self.defaults, {'FRONTENDS': ('this is not a tuple')}) + self.assertEqual( + result['FRONTENDS'], + 'Must be a tuple. ' + "Remember the comma after single values: (u'value',)") class SettingsProxyTest(unittest.TestCase): diff --git a/tests/version_test.py b/tests/version_test.py index 966b8b94..f353f201 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -31,5 +31,7 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.7.2'), SV('0.7.3')) self.assertLess(SV('0.7.3'), SV('0.8.0')) self.assertLess(SV('0.8.0'), SV('0.8.1')) - self.assertLess(SV('0.8.1'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.9.1')) + self.assertLess(SV('0.8.1'), SV('0.9.0')) + self.assertLess(SV('0.9.0'), SV('0.10.0')) + self.assertLess(SV('0.10.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.11.1'))