Merge branch 'develop' into feature/end-of-track

Conflicts:
	mopidy/audio/actor.py
	mopidy/backends/spotify/playback.py
This commit is contained in:
Thomas Adamcik 2013-01-03 16:12:09 +01:00
commit b78d5eddac
86 changed files with 2067 additions and 618 deletions

View File

@ -1,3 +1,5 @@
Thomas Adamcik <thomas@adamcik.no> <adamcik@samfundet.no>
Thomas Adamcik <thomas@adamcik.no> <thomas+github@adamcik.no>
Kristian Klette <klette@samfundet.no>
Johannes Knutsen <johannes@knutseninfo.no> <johannes@iterate.no>
Johannes Knutsen <johannes@knutseninfo.no> <johannes@barbarmaclin.(none)>

View File

@ -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"

16
AUTHORS Normal file
View File

@ -0,0 +1,16 @@
- Stein Magnus Jodal <stein.magnus@jodal.no>
- Johannes Knutsen <johannes@knutseninfo.no>
- Thomas Adamcik <thomas@adamcik.no>
- Kristian Klette <klette@samfundet.no>
- Martins Grunskis <martins@grunskis.com>
- Henrik Olsson <henrik@fixme.se>
- Antoine Pierlot-Garcin <antoine@bokbox.com>
- John Bäckstrand <sopues@gmail.com>
- Fred Hatfull <fred.hatfull@gmail.com>
- Erling Børresen <erling@fenicore.net>
- David C <dav@dav.com>
- Christian Johansen <christian@cjohansen.no>
- Matt Bray <mattjbray@gmail.com>
- Trygve Aaberge <trygveaa@gmail.com>
- Wouter van Wijk <woutervanwijk@gmail.com>
- Jeremy B. Merrill <jeremybmerrill@gmail.com>

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -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`

View File

@ -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`

View File

@ -4,13 +4,7 @@ Authors
Contributors to Mopidy in the order of appearance:
- Stein Magnus Jodal <stein.magnus@jodal.no>
- Johannes Knutsen <johannes@knutsen.me>
- Thomas Adamcik <adamcik@samfundet.no>
- Kristian Klette <klette@klette.us>
A complete list of persons with commits accepted into the Mopidy repo can be
found at `GitHub <https://github.com/mopidy/mopidy/graphs/contributors>`_.
.. include:: ../AUTHORS
Showing your appreciation

View File

@ -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
<mopidy.backends.stream>`. 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

View File

@ -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 <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.

View File

@ -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.

View File

@ -0,0 +1,7 @@
***********************************************
:mod:`mopidy.backends.stream` -- Stream backend
***********************************************
.. automodule:: mopidy.backends.stream
:synopsis: Backend for playing audio streams
:members:

View File

@ -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
------------

21
fabfile.py vendored Normal file
View File

@ -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")

View File

@ -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

View File

@ -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]

View File

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

View File

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

View File

@ -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:**

50
mopidy/audio/utils.py Normal file
View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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():

View File

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

View File

@ -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:**

View File

@ -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

View File

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

View File

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

View File

@ -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

View File

@ -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)]

View File

@ -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]

View File

@ -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
"""

View File

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

View File

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

View File

@ -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]

View File

@ -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

View File

@ -8,7 +8,7 @@ Frontend which scrobbles the music you play to your `Last.fm
**Dependencies:**
- `pylast <http://code.google.com/p/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

View File

@ -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

View File

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

View File

@ -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

View File

@ -39,6 +39,6 @@ def outputs(context):
"""
return [
('outputid', 0),
('outputname', None),
('outputname', 'Default'),
('outputenabled', 1),
]

View File

@ -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<channel>[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<channel>[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<channel>[A-Za-z0-9:._-]+)" "(?P<text>[^"]*)"$')
def sendmessage(context, channel, text):
"""
*musicpd.org, client to client section:*
``sendmessage {CHANNEL} {TEXT}``
Send a message to the specified channel.
"""
raise MpdNotImplemented # TODO

View File

@ -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<field>([Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny))"? '
r'"(?P<what>[^"]+)"')
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<mpd_query>("?([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<tag>[^"]+)" "(?P<needle>[^"]*)"$')
@ -50,17 +52,17 @@ def count(context, tag, needle):
return [('songs', 0), ('playtime', 0)] # TODO
@handle_request(
r'^find (?P<mpd_query>("?([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<query>("?([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<mpd_query>("?([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<playlist_name>[^"]+)" ' + 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<uri>[^"]+)")*$')

View File

@ -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<position>\d+)"$')
@handle_request(r'^seekcur "(?P<diff>[-+]\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<volume>[-+]*\d+)$')
@handle_request(r'^setvol "(?P<volume>[-+]*\d+)"$')
def setvol(context, volume):

View File

@ -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 [

View File

@ -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<name>[^"]+)"$')
def load(context, name):
@handle_request(r'^load "(?P<name>[^"]+)"( "(?P<start>\d+):(?P<end>\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<name>[^"]+)" "(?P<uri>[^"]+)"$')

View File

@ -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<field>([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|'
r'[Tt]itle|[Aa]ny))"? "(?P<what>[^"]+)"')
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] = ({}, [])

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -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):

View File

@ -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()

View File

@ -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.'

View File

@ -3,8 +3,8 @@ pip requirement files
*********************
The files found here are `requirement files
<http://pip.openplans.org/requirement-format.html>`_ that may be used with `pip
<http://pip.openplans.org/>`_.
<http://www.pip-installer.org/en/latest/requirements.html>`_ that may be used
with `pip <http://www.pip-installer.org/>`_.
To install the dependencies found in one of these files, simply run e.g.::

View File

@ -1 +1,2 @@
Pykka >= 1.0
# Available as python-pykka from apt.mopidy.com

View File

@ -1 +1,2 @@
pyserial
# Available as python-serial in Debian/Ubuntu

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -5,4 +5,3 @@ nose
pylint
tox
unittest2
yappi

View File

@ -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',

View File

@ -1,10 +1,5 @@
from __future__ import unicode_literals
import nose
import yappi
try:
yappi.start()
nose.main()
finally:
yappi.print_stats()
nose.main()

View File

@ -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

View File

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

View File

@ -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])

View File

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

View File

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

View File

@ -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(

View File

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

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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())

View File

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

View File

@ -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()))

View File

@ -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())

View File

@ -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'), {}),
])

View File

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

View File

@ -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):

View File

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

View File

@ -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'), [])

View File

@ -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):

View File

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