Merge branch 'develop' into feature/end-of-track
Conflicts: mopidy/audio/actor.py mopidy/backends/spotify/playback.py
This commit is contained in:
commit
b78d5eddac
2
.mailmap
2
.mailmap
@ -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)>
|
||||
|
||||
@ -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
16
AUTHORS
Normal 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>
|
||||
BIN
docs/_static/woutervanwijk-mopidy-webclient.png
vendored
Normal file
BIN
docs/_static/woutervanwijk-mopidy-webclient.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@ -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`
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
173
docs/changes.rst
173
docs/changes.rst
@ -4,9 +4,176 @@ Changes
|
||||
|
||||
This change log is used to track all major changes to Mopidy.
|
||||
|
||||
v0.10.0 (in development)
|
||||
|
||||
v0.12.0 (in development)
|
||||
========================
|
||||
|
||||
(in development)
|
||||
|
||||
- Make Mopidy work on early Python 2.6 versions. (Fixes: :issue:`302`)
|
||||
|
||||
- ``optparse`` fails if the first argument to ``add_option`` is a unicode
|
||||
string on Python < 2.6.2rc1.
|
||||
|
||||
- ``foo(**data)`` fails if the keys in ``data`` is unicode strings on Python
|
||||
< 2.6.5rc1.
|
||||
|
||||
**Spotify backend**
|
||||
|
||||
- Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`)
|
||||
|
||||
**Local backend**
|
||||
|
||||
- Make ``mopidy-scan`` support symlinks.
|
||||
|
||||
**Stream backend**
|
||||
|
||||
We've added a new backend for playing audio streams, the :mod:`stream backend
|
||||
<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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
7
docs/modules/backends/stream.rst
Normal file
7
docs/modules/backends/stream.rst
Normal file
@ -0,0 +1,7 @@
|
||||
***********************************************
|
||||
:mod:`mopidy.backends.stream` -- Stream backend
|
||||
***********************************************
|
||||
|
||||
.. automodule:: mopidy.backends.stream
|
||||
:synopsis: Backend for playing audio streams
|
||||
:members:
|
||||
@ -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
21
fabfile.py
vendored
Normal 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")
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
50
mopidy/audio/utils.py
Normal 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
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:**
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
23
mopidy/backends/stream/__init__.py
Normal file
23
mopidy/backends/stream/__init__.py
Normal 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
|
||||
37
mopidy/backends/stream/actor.py
Normal file
37
mopidy/backends/stream/actor.py
Normal 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)]
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
"""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -39,6 +39,6 @@ def outputs(context):
|
||||
"""
|
||||
return [
|
||||
('outputid', 0),
|
||||
('outputname', None),
|
||||
('outputname', 'Default'),
|
||||
('outputenabled', 1),
|
||||
]
|
||||
|
||||
69
mopidy/frontends/mpd/protocol/channels.py
Normal file
69
mopidy/frontends/mpd/protocol/channels.py
Normal 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
|
||||
@ -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>[^"]+)")*$')
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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>[^"]+)"$')
|
||||
|
||||
@ -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] = ({}, [])
|
||||
|
||||
@ -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'])
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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',
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.'
|
||||
|
||||
@ -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.::
|
||||
|
||||
|
||||
@ -1 +1,2 @@
|
||||
Pykka >= 1.0
|
||||
# Available as python-pykka from apt.mopidy.com
|
||||
|
||||
@ -1 +1,2 @@
|
||||
pyserial
|
||||
# Available as python-serial in Debian/Ubuntu
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -5,4 +5,3 @@ nose
|
||||
pylint
|
||||
tox
|
||||
unittest2
|
||||
yappi
|
||||
|
||||
2
setup.py
2
setup.py
@ -94,7 +94,7 @@ setup(
|
||||
scripts=['bin/mopidy', 'bin/mopidy-scan'],
|
||||
url='http://www.mopidy.com/',
|
||||
license='Apache License, Version 2.0',
|
||||
description='MPD server with Spotify support',
|
||||
description='Music server with MPD and Spotify support',
|
||||
long_description=open('README.rst').read(),
|
||||
classifiers=[
|
||||
'Development Status :: 4 - Beta',
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import nose
|
||||
import yappi
|
||||
|
||||
try:
|
||||
yappi.start()
|
||||
nose.main()
|
||||
finally:
|
||||
yappi.print_stats()
|
||||
nose.main()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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')
|
||||
|
||||
49
tests/core/tracklist_test.py
Normal file
49
tests/core/tracklist_test.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
25
tests/frontends/mpd/protocol/channels_test.py
Normal file
25
tests/frontends/mpd/protocol/channels_test.py
Normal 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')
|
||||
@ -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')
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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()))
|
||||
|
||||
@ -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())
|
||||
@ -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'), {}),
|
||||
])
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'), [])
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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'))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user