Merge branch 'develop' into fix/310-persist-mopidy-state-between-runs

Conflicts:
	docs/changelog.rst

Fixed conflict in doc/changelog.rst
This commit is contained in:
Jens Luetjen 2016-01-05 08:16:51 +01:00
commit 0b0cbc87d4
46 changed files with 717 additions and 279 deletions

View File

@ -1,18 +1,11 @@
sudo: false sudo: required
dist: trusty
language: python language: python
python: python:
- "2.7_with_system_site_packages" - "2.7_with_system_site_packages"
addons:
apt:
sources:
- mopidy-stable
packages:
- graphviz-dev
- mopidy
env: env:
- TOX_ENV=py27 - TOX_ENV=py27
- TOX_ENV=py27-tornado23 - TOX_ENV=py27-tornado23
@ -20,6 +13,11 @@ env:
- TOX_ENV=docs - TOX_ENV=docs
- TOX_ENV=flake8 - TOX_ENV=flake8
before_install:
- "sudo sed -i '/127.0.1.1/d' /etc/hosts" # Workaround tornadoweb/tornado#1573
- "sudo apt-get update -qq"
- "sudo apt-get install -y graphviz-dev gstreamer0.10-plugins-good python-gst0.10"
install: install:
- "pip install tox" - "pip install tox"
@ -27,7 +25,7 @@ script:
- "tox -e $TOX_ENV" - "tox -e $TOX_ENV"
after_success: after_success:
- "if [ $TOX_ENV == 'py27' ]; then pip install coveralls==1.0b1; coveralls; fi" - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi"
branches: branches:
except: except:

View File

@ -75,3 +75,4 @@
- kozec <kozec@kozec.com> - kozec <kozec@kozec.com>
- Jelle van der Waa <jelle@vdwaa.nl> - Jelle van der Waa <jelle@vdwaa.nl>
- Alex Malone <jalexmalone@gmail.com> - Alex Malone <jalexmalone@gmail.com>
- Daniel Hahler <git@thequod.de>

View File

@ -164,6 +164,8 @@ Playlists controller
.. class:: mopidy.core.PlaylistsController .. class:: mopidy.core.PlaylistsController
.. automethod:: mopidy.core.PlaylistsController.get_uri_schemes
Fetching Fetching
-------- --------
@ -229,8 +231,8 @@ TracklistController
.. autoattribute:: mopidy.core.TracklistController.repeat .. autoattribute:: mopidy.core.TracklistController.repeat
.. autoattribute:: mopidy.core.TracklistController.single .. autoattribute:: mopidy.core.TracklistController.single
PlaylistsController PlaybackController
------------------- ------------------
.. automethod:: mopidy.core.PlaybackController.get_mute .. automethod:: mopidy.core.PlaybackController.get_mute
.. automethod:: mopidy.core.PlaybackController.get_volume .. automethod:: mopidy.core.PlaybackController.get_volume
@ -247,8 +249,8 @@ LibraryController
.. automethod:: mopidy.core.LibraryController.find_exact .. automethod:: mopidy.core.LibraryController.find_exact
PlaybackController PlaylistsController
------------------ -------------------
.. automethod:: mopidy.core.PlaylistsController.filter .. automethod:: mopidy.core.PlaylistsController.filter
.. automethod:: mopidy.core.PlaylistsController.get_playlists .. automethod:: mopidy.core.PlaylistsController.get_playlists

View File

@ -16,15 +16,41 @@ Core API
- Start ``tlid`` counting at 1 instead of 0 to keep in sync with MPD's - Start ``tlid`` counting at 1 instead of 0 to keep in sync with MPD's
``songid``. ``songid``.
- :meth:`~mopidy.core.PlaybackController.get_time_position` now returns the
seek target while a seek is in progress. This gives better results than just
failing the position query. (Fixes: :issue:`312` PR: :issue:`1346`)
- Add :meth:`mopidy.core.PlaylistsController.get_uri_schemes`. (PR:
:issue:`1362`)
- Persist state between runs. The amount of data to persist can be - Persist state between runs. The amount of data to persist can be
controlled by config value :confval:`core/restore_state` controlled by config value :confval:`core/restore_state`
Models
------
- **Deprecated:** :attr:`mopidy.models.Album.images` is deprecated. Use
:meth:`mopidy.core.LibraryController.get_images` instead. (Fixes:
:issue:`1325`)
Extension support
-----------------
- Log exception and continue if an extension crashes during setup. Previously,
we let Mopidy crash if an extension's setup crashed. (PR: :issue:`1337`)
Local backend Local backend
-------------- --------------
- Made :confval:`local/data_dir` really deprecated. This change breaks older - Made :confval:`local/data_dir` really deprecated. This change breaks older
versions of Mopidy-Local-SQLite and Mopidy-Local-Images. versions of Mopidy-Local-SQLite and Mopidy-Local-Images.
M3U backend
-----------
- Derive track name from file name for non-extended M3U
playlists. (Fixes: :issue:`1364`, PR: :issue:`1369`)
MPD frontend MPD frontend
------------ ------------
@ -42,6 +68,18 @@ MPD frontend
- Start ``songid`` counting at 1 instead of 0 to match the original MPD server. - Start ``songid`` counting at 1 instead of 0 to match the original MPD server.
- Idle events are now emitted on ``seekeded`` events. This fix means that
clients relying on ``idle`` events now get notified about seeks.
(Fixes: :issue:`1331` :issue:`1347`)
- Idle events are now emitted on ``playlists_loaded`` events. This fix means
that clients relying on ``idle`` events now get notified about playlist loads.
(Fixes: :issue:`1331` PR: :issue:`1347`)
- Event handler for ``playlist_deleted`` has been unbroken. This unreported bug
would cause the MPD Frontend to crash preventing any further communication
via the MPD protocol. (PR: :issue:`1347`)
Zeroconf Zeroconf
-------- --------
@ -61,6 +99,12 @@ Cleanups
- Removed warning if :file:`~/.config/mopidy/settings.py` exists. We stopped - Removed warning if :file:`~/.config/mopidy/settings.py` exists. We stopped
using this settings file in 0.14, released in April 2013. using this settings file in 0.14, released in April 2013.
- The ``on_event`` handler in our listener helper now catches exceptions. This
means that any errors in event handling won't crash the actor in question.
- Catch errors when loading :confval:`logging/config_file`.
(Fixes: :issue:`1320`)
Gapless Gapless
------- -------
@ -73,6 +117,11 @@ Gapless
- Tests have been updated to always use a core actor so async state changes - Tests have been updated to always use a core actor so async state changes
don't trip us up. don't trip us up.
- Seek events are now triggered when the seek completes. Previously the event
was emitted when the seek was requested, not when it completed. Further
changes have been made to make seek work correctly for gapless related corner
cases. (Fixes: :issue:`1305` PR: :issue:`1346`)
v1.1.2 (UNRELEASED) v1.1.2 (UNRELEASED)
=================== ===================

View File

@ -167,5 +167,5 @@ projects are a real match made in heaven."
Partify Partify
------- -------
`Partify <https://github.com/fhats/partify>`_ is a web based MPD client focusing on `Partify <https://github.com/fhats/partify>`_ is a web based MPD client focussing on
making music playing collaborative and social. making music playing collaborative and social.

View File

@ -111,11 +111,7 @@ modindex_common_prefix = ['mopidy.']
# -- Options for HTML output -------------------------------------------------- # -- Options for HTML output --------------------------------------------------
# 'sphinx_rtd_theme' is bundled with Sphinx 1.3, which we don't have when html_theme = 'sphinx_rtd_theme'
# building the docs as part of the Debian packages on e.g. Debian wheezy.
# html_theme = 'sphinx_rtd_theme'
html_theme = 'default'
html_theme_path = ['_themes']
html_static_path = ['_static'] html_static_path = ['_static']
html_use_modindex = True html_use_modindex = True

View File

@ -217,7 +217,7 @@ Proxy configuration
------------------- -------------------
Not all parts of Mopidy or all Mopidy extensions respect the proxy Not all parts of Mopidy or all Mopidy extensions respect the proxy
server configuration when connecting to the Internt. Currently, this is at server configuration when connecting to the Internet. Currently, this is at
least used when Mopidy's audio subsystem reads media directly from the network, least used when Mopidy's audio subsystem reads media directly from the network,
like when listening to Internet radio streams, and by the Mopidy-Spotify like when listening to Internet radio streams, and by the Mopidy-Spotify
extension. With time, we hope that more of the Mopidy ecosystem will respect extension. With time, we hope that more of the Mopidy ecosystem will respect

View File

@ -126,3 +126,11 @@ Pull request guidelines
#. Send a pull request to the ``develop`` branch. See the `GitHub pull request #. Send a pull request to the ``develop`` branch. See the `GitHub pull request
docs <https://help.github.com/articles/using-pull-requests>`_ for help. docs <https://help.github.com/articles/using-pull-requests>`_ for help.
.. note::
If you are contributing a bug fix for a specific minor version of Mopidy
you should create the branch based on ``release-x.y`` instead of
``develop``. When the release is done the changes will be merged back into
``develop`` automatically as part of the normal release process. See
:ref:`creating-releases`.

View File

@ -81,4 +81,4 @@ Mopidy-Webhooks
https://github.com/paddycarey/mopidy-webhooks https://github.com/paddycarey/mopidy-webhooks
Extension for sending HTTP POST requests with JSON payloads to a remote server Extension for sending HTTP POST requests with JSON payloads to a remote server
on when Mopidy core triggers an event and on regular intervals. when Mopidy core triggers an event and on regular intervals.

View File

@ -17,7 +17,7 @@ Mopidy-ALSAMixer
https://github.com/mopidy/mopidy-alsamixer https://github.com/mopidy/mopidy-alsamixer
Extension for controlling volume one a Linux system using ALSA. Extension for controlling volume on a Linux system using ALSA.
Mopidy-Arcam Mopidy-Arcam

View File

@ -35,8 +35,8 @@ Mopidy-Local-Images
https://github.com/tkem/mopidy-local-images https://github.com/tkem/mopidy-local-images
Not a full-featured Web client, but rather a local library and Web Not a full-featured web client, but rather a local library and web
extension which allows other Web clients access to album art embedded extension which allows other web clients access to album art embedded
in local media files. in local media files.
.. image:: /ext/local_images.jpg .. image:: /ext/local_images.jpg
@ -69,7 +69,7 @@ Mopidy-Mobile
https://github.com/tkem/mopidy-mobile https://github.com/tkem/mopidy-mobile
A Mopidy Web client extension and hybrid mobile app, made with Ionic, A Mopidy web client extension and hybrid mobile app, made with Ionic,
AngularJS and Apache Cordova by Thomas Kemmer. AngularJS and Apache Cordova by Thomas Kemmer.
.. image:: /ext/mobile.png .. image:: /ext/mobile.png
@ -132,18 +132,6 @@ To install, run::
pip install Mopidy-MusicBox-Webclient pip install Mopidy-MusicBox-Webclient
Mopidy-Party
============
https://github.com/Lesterpig/mopidy-party
Minimal web client designed for collaborative music management during parties.
.. image:: /ext/mopidy_party.png
To install, run::
pip install Mopidy-Party
Mopidy-Party Mopidy-Party
============ ============

View File

@ -181,7 +181,7 @@ Appendix C: Installation on XBian
Similar to the Raspbmc issue outlined in Appendix B, it's not possible to Similar to the Raspbmc issue outlined in Appendix B, it's not possible to
install Mopidy on XBian without first resolving a dependency problem between install Mopidy on XBian without first resolving a dependency problem between
``gstreamer0.10-plugins-good`` and ``libtag1c2a``. More information can be ``gstreamer0.10-plugins-good`` and ``libtag1c2a``. More information can be
found in `this post found in `this issue
<https://github.com/xbianonpi/xbian/issues/378#issuecomment-37723392>`_. <https://github.com/xbianonpi/xbian/issues/378#issuecomment-37723392>`_.
Run the following commands to remedy this and then install Mopidy as normal:: Run the following commands to remedy this and then install Mopidy as normal::

View File

@ -6,6 +6,7 @@ Here we try to keep an up to date record of how Mopidy releases are made. This
documentation serves both as a checklist, to reduce the project's dependency on documentation serves both as a checklist, to reduce the project's dependency on
key individuals, and as a stepping stone to more automation. key individuals, and as a stepping stone to more automation.
.. _creating-releases:
Creating releases Creating releases
================= =================

View File

@ -92,6 +92,9 @@ class Core(
def stream_changed(self, uri): def stream_changed(self, uri):
self.playback._on_stream_changed(uri) self.playback._on_stream_changed(uri)
def position_changed(self, position):
self.playback._on_position_changed(position)
def state_changed(self, old_state, new_state, target_state): def state_changed(self, old_state, new_state, target_state):
# XXX: This is a temporary fix for issue #232 while we wait for a more # XXX: This is a temporary fix for issue #232 while we wait for a more
# permanent solution with the implementation of issue #234. When the # permanent solution with the implementation of issue #234. When the

View File

@ -149,7 +149,7 @@ class LibraryController(object):
"""Lookup the images for the given URIs """Lookup the images for the given URIs
Backends can use this to return image URIs for any URI they know about Backends can use this to return image URIs for any URI they know about
be it tracks, albums, playlists... The lookup result is a dictionary be it tracks, albums, playlists. The lookup result is a dictionary
mapping the provided URIs to lists of images. mapping the provided URIs to lists of images.
Unknown URIs or URIs the corresponding backend couldn't find anything Unknown URIs or URIs the corresponding backend couldn't find anything

View File

@ -31,7 +31,8 @@ class CoreListener(listener.Listener):
:type event: string :type event: string
:param kwargs: any other arguments to the specific event handlers :param kwargs: any other arguments to the specific event handlers
""" """
getattr(self, event)(**kwargs) # Just delegate to parent, entry mostly for docs.
super(CoreListener, self).on_event(event, **kwargs)
def track_playback_paused(self, tl_track, time_position): def track_playback_paused(self, tl_track, time_position):
""" """

View File

@ -26,6 +26,10 @@ class PlaybackController(object):
self._current_tl_track = None self._current_tl_track = None
self._pending_tl_track = None self._pending_tl_track = None
self._pending_position = None
self._last_position = None
self._previous = False
if self._audio: if self._audio:
self._audio.set_about_to_finish_callback( self._audio.set_about_to_finish_callback(
self._on_about_to_finish_callback) self._on_about_to_finish_callback)
@ -127,6 +131,8 @@ class PlaybackController(object):
def get_time_position(self): def get_time_position(self):
"""Get time position in milliseconds.""" """Get time position in milliseconds."""
if self._pending_position is not None:
return self._pending_position
backend = self._get_backend(self.get_current_tl_track()) backend = self._get_backend(self.get_current_tl_track())
if backend: if backend:
return backend.playback.get_time_position().get() return backend.playback.get_time_position().get()
@ -197,15 +203,35 @@ class PlaybackController(object):
def _on_end_of_stream(self): def _on_end_of_stream(self):
self.set_state(PlaybackState.STOPPED) self.set_state(PlaybackState.STOPPED)
if self._current_tl_track:
self._trigger_track_playback_ended(self.get_time_position())
self._set_current_tl_track(None) self._set_current_tl_track(None)
# TODO: self._trigger_track_playback_ended?
def _on_stream_changed(self, uri): def _on_stream_changed(self, uri):
if self._last_position is None:
position = self.get_time_position()
else:
# This code path handles the stop() case, uri should be none.
position, self._last_position = self._last_position, None
if self._pending_position is None:
self._trigger_track_playback_ended(position)
self._stream_title = None self._stream_title = None
if self._pending_tl_track: if self._pending_tl_track:
self._set_current_tl_track(self._pending_tl_track) self._set_current_tl_track(self._pending_tl_track)
self._pending_tl_track = None self._pending_tl_track = None
self._trigger_track_playback_started()
if self._pending_position is None:
self.set_state(PlaybackState.PLAYING)
self._trigger_track_playback_started()
else:
self._seek(self._pending_position)
def _on_position_changed(self, position):
if self._pending_position == position:
self._trigger_seeked(position)
self._pending_position = None
def _on_about_to_finish_callback(self): def _on_about_to_finish_callback(self):
"""Callback that performs a blocking actor call to the real callback. """Callback that performs a blocking actor call to the real callback.
@ -221,7 +247,8 @@ class PlaybackController(object):
}) })
def _on_about_to_finish(self): def _on_about_to_finish(self):
self._trigger_track_playback_ended(self.get_time_position()) if self._state == PlaybackState.STOPPED:
return
# TODO: check that we always have a current track # TODO: check that we always have a current track
original_tl_track = self.get_current_tl_track() original_tl_track = self.get_current_tl_track()
@ -235,8 +262,6 @@ class PlaybackController(object):
if backend: if backend:
backend.playback.change_track(next_tl_track.track).get() backend.playback.change_track(next_tl_track.track).get()
self.core.tracklist._mark_played(original_tl_track)
def _on_tracklist_change(self): def _on_tracklist_change(self):
""" """
Tell the playback controller that the current playlist has changed. Tell the playback controller that the current playlist has changed.
@ -259,10 +284,6 @@ class PlaybackController(object):
state = self.get_state() state = self.get_state()
current = self._pending_tl_track or self._current_tl_track current = self._pending_tl_track or self._current_tl_track
# TODO: move to pending track?
self._trigger_track_playback_ended(self.get_time_position())
self.core.tracklist._mark_played(self._current_tl_track)
while current: while current:
pending = self.core.tracklist.next_track(current) pending = self.core.tracklist.next_track(current)
if self._change(pending, state): if self._change(pending, state):
@ -321,17 +342,9 @@ class PlaybackController(object):
self.resume() self.resume()
return return
original = self._current_tl_track
current = self._pending_tl_track or self._current_tl_track current = self._pending_tl_track or self._current_tl_track
pending = tl_track or current or self.core.tracklist.next_track(None) pending = tl_track or current or self.core.tracklist.next_track(None)
if original != pending and self.get_state() != PlaybackState.STOPPED:
self._trigger_track_playback_ended(self.get_time_position())
if pending:
# TODO: remove?
self.set_state(PlaybackState.PLAYING)
while pending: while pending:
# TODO: should we consume unplayable tracks in this loop? # TODO: should we consume unplayable tracks in this loop?
if self._change(pending, PlaybackState.PLAYING): if self._change(pending, PlaybackState.PLAYING):
@ -341,8 +354,6 @@ class PlaybackController(object):
current = pending current = pending
pending = self.core.tracklist.next_track(current) pending = self.core.tracklist.next_track(current)
# TODO: move to top and get rid of original?
self.core.tracklist._mark_played(original)
# TODO return result? # TODO return result?
def _change(self, pending_tl_track, state): def _change(self, pending_tl_track, state):
@ -387,8 +398,7 @@ class PlaybackController(object):
The current playback state will be kept. If it was playing, playing The current playback state will be kept. If it was playing, playing
will continue. If it was paused, it will still be paused, etc. will continue. If it was paused, it will still be paused, etc.
""" """
self._trigger_track_playback_ended(self.get_time_position()) self._previous = True
state = self.get_state() state = self.get_state()
current = self._pending_tl_track or self._current_tl_track current = self._pending_tl_track or self._current_tl_track
@ -440,11 +450,6 @@ class PlaybackController(object):
if self.get_state() == PlaybackState.STOPPED: if self.get_state() == PlaybackState.STOPPED:
self.play() self.play()
# TODO: uncomment once we have tests for this. Should fix seek after
# about to finish doing wrong track.
# if self._current_tl_track and self._pending_tl_track:
# self.play(self._current_tl_track)
# We need to prefer the still playing track, but if nothing is playing # We need to prefer the still playing track, but if nothing is playing
# we fall back to the pending one. # we fall back to the pending one.
tl_track = self._current_tl_track or self._pending_tl_track tl_track = self._current_tl_track or self._pending_tl_track
@ -458,23 +463,29 @@ class PlaybackController(object):
self.next() self.next()
return True return True
# Store our target position.
self._pending_position = time_position
# Make sure we switch back to previous track if we get a seek while we
# have a pending track.
if self._current_tl_track and self._pending_tl_track:
self._change(self._current_tl_track, self.get_state())
else:
return self._seek(time_position)
def _seek(self, time_position):
backend = self._get_backend(self.get_current_tl_track()) backend = self._get_backend(self.get_current_tl_track())
if not backend: if not backend:
return False return False
return backend.playback.seek(time_position).get()
success = backend.playback.seek(time_position).get()
if success:
self._trigger_seeked(time_position)
return success
def stop(self): def stop(self):
"""Stop playing.""" """Stop playing."""
if self.get_state() != PlaybackState.STOPPED: if self.get_state() != PlaybackState.STOPPED:
self._last_position = self.get_time_position()
backend = self._get_backend(self.get_current_tl_track()) backend = self._get_backend(self.get_current_tl_track())
time_position_before_stop = self.get_time_position()
if not backend or backend.playback.stop().get(): if not backend or backend.playback.stop().get():
self.set_state(PlaybackState.STOPPED) self.set_state(PlaybackState.STOPPED)
self._trigger_track_playback_ended(time_position_before_stop)
def _trigger_track_playback_paused(self): def _trigger_track_playback_paused(self):
logger.debug('Triggering track playback paused event') logger.debug('Triggering track playback paused event')
@ -495,20 +506,26 @@ class PlaybackController(object):
time_position=self.get_time_position()) time_position=self.get_time_position())
def _trigger_track_playback_started(self): def _trigger_track_playback_started(self):
# TODO: replace with stream-changed
logger.debug('Triggering track playback started event')
if self.get_current_tl_track() is None: if self.get_current_tl_track() is None:
return return
logger.debug('Triggering track playback started event')
tl_track = self.get_current_tl_track() tl_track = self.get_current_tl_track()
self.core.tracklist._mark_playing(tl_track) self.core.tracklist._mark_playing(tl_track)
self.core.history._add_track(tl_track.track) self.core.history._add_track(tl_track.track)
listener.CoreListener.send('track_playback_started', tl_track=tl_track) listener.CoreListener.send('track_playback_started', tl_track=tl_track)
def _trigger_track_playback_ended(self, time_position_before_stop): def _trigger_track_playback_ended(self, time_position_before_stop):
logger.debug('Triggering track playback ended event')
if self.get_current_tl_track() is None: if self.get_current_tl_track() is None:
return return
logger.debug('Triggering track playback ended event')
if not self._previous:
self.core.tracklist._mark_played(self._current_tl_track)
self._previous = False
# TODO: Use the lowest of track duration and position.
listener.CoreListener.send( listener.CoreListener.send(
'track_playback_ended', 'track_playback_ended',
tl_track=self.get_current_tl_track(), tl_track=self.get_current_tl_track(),
@ -521,6 +538,7 @@ class PlaybackController(object):
old_state=old_state, new_state=new_state) old_state=old_state, new_state=new_state)
def _trigger_seeked(self, time_position): def _trigger_seeked(self, time_position):
# TODO: Trigger this from audio events?
logger.debug('Triggering seeked event') logger.debug('Triggering seeked event')
listener.CoreListener.send('seeked', time_position=time_position) listener.CoreListener.send('seeked', time_position=time_position)

View File

@ -33,6 +33,16 @@ class PlaylistsController(object):
self.backends = backends self.backends = backends
self.core = core self.core = core
def get_uri_schemes(self):
"""
Get the list of URI schemes that support playlists.
:rtype: list of string
.. versionadded:: 1.2
"""
return list(sorted(self.backends.with_playlists.keys()))
def as_list(self): def as_list(self):
""" """
Get a list of the currently available playlists. Get a list of the currently available playlists.

View File

@ -54,7 +54,7 @@ class Extension(object):
def get_config_schema(self): def get_config_schema(self):
"""The extension's config validation schema """The extension's config validation schema
:returns: :class:`~mopidy.config.schema.ExtensionConfigSchema` :returns: :class:`~mopidy.config.schemas.ConfigSchema`
""" """
schema = config_lib.ConfigSchema(self.ext_name) schema = config_lib.ConfigSchema(self.ext_name)
schema['enabled'] = config_lib.Boolean() schema['enabled'] = config_lib.Boolean()
@ -198,7 +198,12 @@ def load_extensions():
for entry_point in pkg_resources.iter_entry_points('mopidy.ext'): for entry_point in pkg_resources.iter_entry_points('mopidy.ext'):
logger.debug('Loading entry point: %s', entry_point) logger.debug('Loading entry point: %s', entry_point)
extension_class = entry_point.load(require=False) try:
extension_class = entry_point.load(require=False)
except Exception as e:
logger.exception("Failed to load extension %s: %s" % (
entry_point.name, e))
continue
try: try:
if not issubclass(extension_class, Extension): if not issubclass(extension_class, Extension):

View File

@ -21,8 +21,8 @@ def format_proxy(proxy_config, auth=True):
if not proxy_config.get('hostname'): if not proxy_config.get('hostname'):
return None return None
port = proxy_config.get('port', 80) port = proxy_config.get('port')
if port < 0: if not port or port < 0:
port = 80 port = 80
if proxy_config.get('username') and proxy_config.get('password') and auth: if proxy_config.get('username') and proxy_config.get('password') and auth:

View File

@ -19,6 +19,8 @@ LOG_LEVELS = {
TRACE_LOG_LEVEL = 5 TRACE_LOG_LEVEL = 5
logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE') logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE')
logger = logging.getLogger(__name__)
class DelayedHandler(logging.Handler): class DelayedHandler(logging.Handler):
@ -54,8 +56,12 @@ def setup_logging(config, verbosity_level, save_debug_log):
if config['logging']['config_file']: if config['logging']['config_file']:
# Logging config from file must be read before other handlers are # Logging config from file must be read before other handlers are
# added. If not, the other handlers will have no effect. # added. If not, the other handlers will have no effect.
logging.config.fileConfig(config['logging']['config_file'], try:
disable_existing_loggers=False) path = config['logging']['config_file']
logging.config.fileConfig(path, disable_existing_loggers=False)
except Exception as e:
# Catch everything as logging does not specify what can go wrong.
logger.error('Loading logging config %r failed. %s', path, e)
setup_console_logging(config, verbosity_level) setup_console_logging(config, verbosity_level)
if save_debug_log: if save_debug_log:

View File

@ -41,4 +41,9 @@ class Listener(object):
:type event: string :type event: string
:param kwargs: any other arguments to the specific event handlers :param kwargs: any other arguments to the specific event handlers
""" """
getattr(self, event)(**kwargs) try:
getattr(self, event)(**kwargs)
except Exception:
# Ensure we don't crash the actor due to "bad" events.
logger.exception(
'Triggering event failed: %s(%s)', event, ', '.join(kwargs))

View File

@ -99,7 +99,9 @@ def parse_m3u(file_path, media_dir=None):
if extended and line.startswith('#EXTINF'): if extended and line.startswith('#EXTINF'):
track = m3u_extinf_to_track(line) track = m3u_extinf_to_track(line)
continue continue
if not track.name:
name = os.path.basename(os.path.splitext(line)[0])
track = track.replace(name=urllib.parse.unquote(name))
if urllib.parse.urlsplit(line).scheme: if urllib.parse.urlsplit(line).scheme:
tracks.append(track.replace(uri=line)) tracks.append(track.replace(uri=line))
elif os.path.normpath(line) == os.path.abspath(line): elif os.path.normpath(line) == os.path.abspath(line):

View File

@ -148,6 +148,10 @@ class Album(ValidatedImmutableObject):
:type musicbrainz_id: string :type musicbrainz_id: string
:param images: album image URIs :param images: album image URIs
:type images: list of strings :type images: list of strings
.. deprecated:: 1.2
The ``images`` field is deprecated.
Use :meth:`mopidy.core.LibraryController.get_images` instead.
""" """
#: The album URI. Read-only. #: The album URI. Read-only.
@ -172,10 +176,10 @@ class Album(ValidatedImmutableObject):
musicbrainz_id = fields.Identifier() musicbrainz_id = fields.Identifier()
#: The album image URIs. Read-only. #: The album image URIs. Read-only.
#:
#: .. deprecated:: 1.2
#: Use :meth:`mopidy.core.LibraryController.get_images` instead.
images = fields.Collection(type=compat.string_types, container=frozenset) images = fields.Collection(type=compat.string_types, container=frozenset)
# XXX If we want to keep the order of images we shouldn't use frozenset()
# as it doesn't preserve order. I'm deferring this issue until we got
# actual usage of this field with more than one image.
class Track(ValidatedImmutableObject): class Track(ValidatedImmutableObject):

View File

@ -112,7 +112,7 @@ class ImmutableObject(object):
for key, value in kwargs.items(): for key, value in kwargs.items():
if not self._is_valid_field(key): if not self._is_valid_field(key):
raise TypeError( raise TypeError(
'copy() got an unexpected keyword argument "%s"' % key) 'replace() got an unexpected keyword argument "%s"' % key)
other._set_field(key, value) other._set_field(key, value)
return other return other

View File

@ -4,13 +4,30 @@ import logging
import pykka import pykka
from mopidy import exceptions, zeroconf from mopidy import exceptions, listener, zeroconf
from mopidy.core import CoreListener from mopidy.core import CoreListener
from mopidy.internal import encoding, network, process from mopidy.internal import encoding, network, process
from mopidy.mpd import session, uri_mapper from mopidy.mpd import session, uri_mapper
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_CORE_EVENTS_TO_IDLE_SUBSYSTEMS = {
'track_playback_paused': None,
'track_playback_resumed': None,
'track_playback_started': None,
'track_playback_ended': None,
'playback_state_changed': 'player',
'tracklist_changed': 'playlist',
'playlists_loaded': 'stored_playlist',
'playlist_changed': 'stored_playlist',
'playlist_deleted': 'stored_playlist',
'options_changed': 'options',
'volume_changed': 'mixer',
'mute_changed': 'output',
'seeked': 'player',
'stream_title_changed': 'playlist',
}
class MpdFrontend(pykka.ThreadingActor, CoreListener): class MpdFrontend(pykka.ThreadingActor, CoreListener):
@ -24,6 +41,9 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
self.zeroconf_name = config['mpd']['zeroconf'] self.zeroconf_name = config['mpd']['zeroconf']
self.zeroconf_service = None self.zeroconf_service = None
self._setup_server(config, core)
def _setup_server(self, config, core):
try: try:
network.Server( network.Server(
self.hostname, self.port, self.hostname, self.port,
@ -56,31 +76,13 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
process.stop_actors_by_class(session.MpdSession) process.stop_actors_by_class(session.MpdSession)
def on_event(self, event, **kwargs):
if event not in _CORE_EVENTS_TO_IDLE_SUBSYSTEMS:
logger.warning(
'Got unexpected event: %s(%s)', event, ', '.join(kwargs))
else:
self.send_idle(_CORE_EVENTS_TO_IDLE_SUBSYSTEMS[event])
def send_idle(self, subsystem): def send_idle(self, subsystem):
listeners = pykka.ActorRegistry.get_by_class(session.MpdSession) if subsystem:
for listener in listeners: listener.send(session.MpdSession, subsystem)
getattr(listener.proxy(), 'on_idle')(subsystem)
def playback_state_changed(self, old_state, new_state):
self.send_idle('player')
def tracklist_changed(self):
self.send_idle('playlist')
def playlist_changed(self, playlist):
self.send_idle('stored_playlist')
def playlist_deleted(self, playlist):
self.send_idle('stored_playlist')
def options_changed(self):
self.send_idle('options')
def volume_changed(self, volume):
self.send_idle('mixer')
def mute_changed(self, mute):
self.send_idle('output')
def stream_title_changed(self, title):
self.send_idle('playlist')

View File

@ -47,6 +47,7 @@ class MpdDispatcher(object):
return self._call_next_filter(request, response, filter_chain) return self._call_next_filter(request, response, filter_chain)
def handle_idle(self, subsystem): def handle_idle(self, subsystem):
# TODO: validate against mopidy/mpd/protocol/status.SUBSYSTEMS
self.context.events.add(subsystem) self.context.events.add(subsystem)
subsystems = self.context.subscriptions.intersection( subsystems = self.context.subscriptions.intersection(

View File

@ -80,10 +80,23 @@ class MpdNoExistError(MpdAckError):
error_code = MpdAckError.ACK_ERROR_NO_EXIST error_code = MpdAckError.ACK_ERROR_NO_EXIST
class MpdExistError(MpdAckError):
error_code = MpdAckError.ACK_ERROR_EXIST
class MpdSystemError(MpdAckError): class MpdSystemError(MpdAckError):
error_code = MpdAckError.ACK_ERROR_SYSTEM error_code = MpdAckError.ACK_ERROR_SYSTEM
class MpdInvalidPlaylistName(MpdAckError):
error_code = MpdAckError.ACK_ERROR_ARG
def __init__(self, *args, **kwargs):
super(MpdInvalidPlaylistName, self).__init__(*args, **kwargs)
self.message = ('playlist name is invalid: playlist names may not '
'contain slashes, newlines or carriage returns')
class MpdNotImplemented(MpdAckError): class MpdNotImplemented(MpdAckError):
error_code = 0 error_code = 0

View File

@ -2,6 +2,7 @@ from __future__ import absolute_import, division, unicode_literals
import datetime import datetime
import logging import logging
import re
import warnings import warnings
from mopidy.compat import urllib from mopidy.compat import urllib
@ -10,6 +11,11 @@ from mopidy.mpd import exceptions, protocol, translator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _check_playlist_name(name):
if re.search('[/\n\r]', name):
raise exceptions.MpdInvalidPlaylistName()
@protocol.commands.add('listplaylist') @protocol.commands.add('listplaylist')
def listplaylist(context, name): def listplaylist(context, name):
""" """
@ -149,6 +155,7 @@ def playlistadd(context, name, track_uri):
``NAME.m3u`` will be created if it does not exist. ``NAME.m3u`` will be created if it does not exist.
""" """
_check_playlist_name(name)
uri = context.lookup_playlist_uri_from_name(name) uri = context.lookup_playlist_uri_from_name(name)
old_playlist = uri is not None and context.core.playlists.lookup(uri).get() old_playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not old_playlist: if not old_playlist:
@ -219,6 +226,7 @@ def playlistclear(context, name):
The playlist will be created if it does not exist. The playlist will be created if it does not exist.
""" """
_check_playlist_name(name)
uri = context.lookup_playlist_uri_from_name(name) uri = context.lookup_playlist_uri_from_name(name)
playlist = uri is not None and context.core.playlists.lookup(uri).get() playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not playlist: if not playlist:
@ -240,14 +248,18 @@ def playlistdelete(context, name, songpos):
Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. Deletes ``SONGPOS`` from the playlist ``NAME.m3u``.
""" """
_check_playlist_name(name)
uri = context.lookup_playlist_uri_from_name(name) uri = context.lookup_playlist_uri_from_name(name)
playlist = uri is not None and context.core.playlists.lookup(uri).get() playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not playlist: if not playlist:
raise exceptions.MpdNoExistError('No such playlist') raise exceptions.MpdNoExistError('No such playlist')
# Convert tracks to list and remove requested try:
tracks = list(playlist.tracks) # Convert tracks to list and remove requested
tracks.pop(songpos) tracks = list(playlist.tracks)
tracks.pop(songpos)
except IndexError:
raise exceptions.MpdArgError('Bad song index')
# Replace tracks and save playlist # Replace tracks and save playlist
playlist = playlist.replace(tracks=tracks) playlist = playlist.replace(tracks=tracks)
@ -274,6 +286,10 @@ def playlistmove(context, name, from_pos, to_pos):
documentation, but just the ``SONGPOS`` to move *from*, i.e. documentation, but just the ``SONGPOS`` to move *from*, i.e.
``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``.
""" """
if from_pos == to_pos:
return
_check_playlist_name(name)
uri = context.lookup_playlist_uri_from_name(name) uri = context.lookup_playlist_uri_from_name(name)
playlist = uri is not None and context.core.playlists.lookup(uri).get() playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not playlist: if not playlist:
@ -281,10 +297,13 @@ def playlistmove(context, name, from_pos, to_pos):
if from_pos == to_pos: if from_pos == to_pos:
return # Nothing to do return # Nothing to do
# Convert tracks to list and perform move try:
tracks = list(playlist.tracks) # Convert tracks to list and perform move
track = tracks.pop(from_pos) tracks = list(playlist.tracks)
tracks.insert(to_pos, track) track = tracks.pop(from_pos)
tracks.insert(to_pos, track)
except IndexError:
raise exceptions.MpdArgError('Bad song index')
# Replace tracks and save playlist # Replace tracks and save playlist
playlist = playlist.replace(tracks=tracks) playlist = playlist.replace(tracks=tracks)
@ -303,16 +322,28 @@ def rename(context, old_name, new_name):
Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``.
""" """
uri = context.lookup_playlist_uri_from_name(old_name) _check_playlist_name(old_name)
uri_scheme = urllib.parse.urlparse(uri).scheme _check_playlist_name(new_name)
old_playlist = uri is not None and context.core.playlists.lookup(uri).get()
old_uri = context.lookup_playlist_uri_from_name(old_name)
if not old_uri:
raise exceptions.MpdNoExistError('No such playlist')
old_playlist = context.core.playlists.lookup(old_uri).get()
if not old_playlist: if not old_playlist:
raise exceptions.MpdNoExistError('No such playlist') raise exceptions.MpdNoExistError('No such playlist')
new_uri = context.lookup_playlist_uri_from_name(new_name)
if new_uri and context.core.playlists.lookup(new_uri).get():
raise exceptions.MpdExistError('Playlist already exists')
# TODO: should we purge the mapping in an else?
# Create copy of the playlist and remove original # Create copy of the playlist and remove original
uri_scheme = urllib.parse.urlparse(old_uri).scheme
new_playlist = context.core.playlists.create(new_name, uri_scheme).get() new_playlist = context.core.playlists.create(new_name, uri_scheme).get()
new_playlist = new_playlist.replace(tracks=old_playlist.tracks) new_playlist = new_playlist.replace(tracks=old_playlist.tracks)
saved_playlist = context.core.playlists.save(new_playlist).get() saved_playlist = context.core.playlists.save(new_playlist).get()
if saved_playlist is None: if saved_playlist is None:
raise exceptions.MpdFailedToSavePlaylist(uri_scheme) raise exceptions.MpdFailedToSavePlaylist(uri_scheme)
context.core.playlists.delete(old_playlist.uri).get() context.core.playlists.delete(old_playlist.uri).get()
@ -327,7 +358,10 @@ def rm(context, name):
Removes the playlist ``NAME.m3u`` from the playlist directory. Removes the playlist ``NAME.m3u`` from the playlist directory.
""" """
_check_playlist_name(name)
uri = context.lookup_playlist_uri_from_name(name) uri = context.lookup_playlist_uri_from_name(name)
if not uri:
raise exceptions.MpdNoExistError('No such playlist')
context.core.playlists.delete(uri).get() context.core.playlists.delete(uri).get()
@ -341,6 +375,7 @@ def save(context, name):
Saves the current playlist to ``NAME.m3u`` in the playlist Saves the current playlist to ``NAME.m3u`` in the playlist
directory. directory.
""" """
_check_playlist_name(name)
tracks = context.core.tracklist.get_tracks().get() tracks = context.core.tracklist.get_tracks().get()
uri = context.lookup_playlist_uri_from_name(name) uri = context.lookup_playlist_uri_from_name(name)
playlist = uri is not None and context.core.playlists.lookup(uri).get() playlist = uri is not None and context.core.playlists.lookup(uri).get()

View File

@ -41,7 +41,7 @@ class MpdSession(network.LineProtocol):
self.send_lines(response) self.send_lines(response)
def on_idle(self, subsystem): def on_event(self, subsystem):
self.dispatcher.handle_idle(subsystem) self.dispatcher.handle_idle(subsystem)
def decode(self, line): def decode(self, line):

View File

@ -71,7 +71,7 @@ class MpdUriMapper(object):
""" """
Helper function to retrieve a playlist URI from its unique MPD name. Helper function to retrieve a playlist URI from its unique MPD name.
""" """
if not self._uri_from_name: if name not in self._uri_from_name:
self.refresh_playlists_mapping() self.refresh_playlists_mapping()
return self._uri_from_name.get(name) return self._uri_from_name.get(name)

View File

@ -55,17 +55,20 @@ class Zeroconf(object):
self.bus = None self.bus = None
self.server = None self.server = None
self.group = None self.group = None
try: self.display_hostname = None
self.bus = dbus.SystemBus() self.name = None
self.server = dbus.Interface(
self.bus.get_object('org.freedesktop.Avahi', '/'),
'org.freedesktop.Avahi.Server')
except dbus.exceptions.DBusException as e:
logger.debug('%s: Server failed: %s', self, e)
self.display_hostname = '%s' % self.server.GetHostName() if dbus:
self.name = string.Template(name).safe_substitute( try:
hostname=self.display_hostname, port=port) self.bus = dbus.SystemBus()
self.server = dbus.Interface(
self.bus.get_object('org.freedesktop.Avahi', '/'),
'org.freedesktop.Avahi.Server')
self.display_hostname = '%s' % self.server.GetHostName()
self.name = string.Template(name).safe_substitute(
hostname=self.display_hostname, port=port)
except dbus.exceptions.DBusException as e:
logger.debug('%s: Server failed: %s', self, e)
def __str__(self): def __str__(self):
return 'Zeroconf service "%s" (%s at [%s]:%d)' % ( return 'Zeroconf service "%s" (%s at [%s]:%d)' % (

View File

@ -163,7 +163,7 @@ class AudioEventTest(BaseTest):
self.listener = DummyAudioListener.start().proxy() self.listener = DummyAudioListener.start().proxy()
def tearDown(self): # noqa: N802 def tearDown(self): # noqa: N802
super(AudioEventTest, self).setUp() super(AudioEventTest, self).tearDown()
def assertEvent(self, event, **kwargs): # noqa: N802 def assertEvent(self, event, **kwargs): # noqa: N802
self.assertIn((event, kwargs), self.listener.get_events().get()) self.assertIn((event, kwargs), self.listener.get_events().get())

View File

@ -115,6 +115,23 @@ class TestPlayHandling(BaseTest):
current_tl_track = self.core.playback.get_current_tl_track() current_tl_track = self.core.playback.get_current_tl_track()
self.assertEqual(tl_tracks[1], current_tl_track) self.assertEqual(tl_tracks[1], current_tl_track)
def test_resume_skips_to_next_on_unplayable_track(self):
"""Checks that we handle backend.change_track failing when
resuming playback."""
tl_tracks = self.core.tracklist.get_tl_tracks()
self.core.playback.play(tl_tracks[0])
self.core.playback.pause()
self.audio.trigger_fake_playback_failure(tl_tracks[1].track.uri)
self.core.playback.next()
self.core.playback.resume()
self.replay_events()
current_tl_track = self.core.playback.get_current_tl_track()
self.assertEqual(tl_tracks[2], current_tl_track)
def test_play_tlid(self): def test_play_tlid(self):
tl_tracks = self.core.tracklist.get_tl_tracks() tl_tracks = self.core.tracklist.get_tl_tracks()
@ -314,6 +331,8 @@ class TestCurrentAndPendingTlTrack(BaseTest):
'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener)
class EventEmissionTest(BaseTest): class EventEmissionTest(BaseTest):
maxDiff = None
def test_play_when_stopped_emits_events(self, listener_mock): def test_play_when_stopped_emits_events(self, listener_mock):
tl_tracks = self.core.tracklist.get_tl_tracks() tl_tracks = self.core.tracklist.get_tl_tracks()
@ -321,14 +340,14 @@ class EventEmissionTest(BaseTest):
self.replay_events() self.replay_events()
self.assertListEqual( self.assertListEqual(
listener_mock.send.mock_calls,
[ [
mock.call( mock.call(
'playback_state_changed', 'playback_state_changed',
old_state='stopped', new_state='playing'), old_state='stopped', new_state='playing'),
mock.call( mock.call(
'track_playback_started', tl_track=tl_tracks[0]), 'track_playback_started', tl_track=tl_tracks[0]),
]) ],
listener_mock.send.mock_calls)
def test_play_when_paused_emits_events(self, listener_mock): def test_play_when_paused_emits_events(self, listener_mock):
tl_tracks = self.core.tracklist.get_tl_tracks() tl_tracks = self.core.tracklist.get_tl_tracks()
@ -344,7 +363,6 @@ class EventEmissionTest(BaseTest):
self.replay_events() self.replay_events()
self.assertListEqual( self.assertListEqual(
listener_mock.send.mock_calls,
[ [
mock.call( mock.call(
'track_playback_ended', 'track_playback_ended',
@ -354,7 +372,8 @@ class EventEmissionTest(BaseTest):
old_state='paused', new_state='playing'), old_state='paused', new_state='playing'),
mock.call( mock.call(
'track_playback_started', tl_track=tl_tracks[1]), 'track_playback_started', tl_track=tl_tracks[1]),
]) ],
listener_mock.send.mock_calls)
def test_play_when_playing_emits_events(self, listener_mock): def test_play_when_playing_emits_events(self, listener_mock):
tl_tracks = self.core.tracklist.get_tl_tracks() tl_tracks = self.core.tracklist.get_tl_tracks()
@ -366,9 +385,7 @@ class EventEmissionTest(BaseTest):
self.core.playback.play(tl_tracks[2]) self.core.playback.play(tl_tracks[2])
self.replay_events() self.replay_events()
# TODO: Do we want to emit playing->playing for this case?
self.assertListEqual( self.assertListEqual(
listener_mock.send.mock_calls,
[ [
mock.call( mock.call(
'track_playback_ended', 'track_playback_ended',
@ -378,7 +395,8 @@ class EventEmissionTest(BaseTest):
new_state='playing'), new_state='playing'),
mock.call( mock.call(
'track_playback_started', tl_track=tl_tracks[2]), 'track_playback_started', tl_track=tl_tracks[2]),
]) ],
listener_mock.send.mock_calls)
def test_pause_emits_events(self, listener_mock): def test_pause_emits_events(self, listener_mock):
tl_tracks = self.core.tracklist.get_tl_tracks() tl_tracks = self.core.tracklist.get_tl_tracks()
@ -392,7 +410,6 @@ class EventEmissionTest(BaseTest):
self.core.playback.pause() self.core.playback.pause()
self.assertListEqual( self.assertListEqual(
listener_mock.send.mock_calls,
[ [
mock.call( mock.call(
'playback_state_changed', 'playback_state_changed',
@ -400,7 +417,8 @@ class EventEmissionTest(BaseTest):
mock.call( mock.call(
'track_playback_paused', 'track_playback_paused',
tl_track=tl_tracks[0], time_position=1000), tl_track=tl_tracks[0], time_position=1000),
]) ],
listener_mock.send.mock_calls)
def test_resume_emits_events(self, listener_mock): def test_resume_emits_events(self, listener_mock):
tl_tracks = self.core.tracklist.get_tl_tracks() tl_tracks = self.core.tracklist.get_tl_tracks()
@ -415,7 +433,6 @@ class EventEmissionTest(BaseTest):
self.core.playback.resume() self.core.playback.resume()
self.assertListEqual( self.assertListEqual(
listener_mock.send.mock_calls,
[ [
mock.call( mock.call(
'playback_state_changed', 'playback_state_changed',
@ -423,7 +440,8 @@ class EventEmissionTest(BaseTest):
mock.call( mock.call(
'track_playback_resumed', 'track_playback_resumed',
tl_track=tl_tracks[0], time_position=1000), tl_track=tl_tracks[0], time_position=1000),
]) ],
listener_mock.send.mock_calls)
def test_stop_emits_events(self, listener_mock): def test_stop_emits_events(self, listener_mock):
tl_tracks = self.core.tracklist.get_tl_tracks() tl_tracks = self.core.tracklist.get_tl_tracks()
@ -431,13 +449,13 @@ class EventEmissionTest(BaseTest):
self.core.playback.play(tl_tracks[0]) self.core.playback.play(tl_tracks[0])
self.replay_events() self.replay_events()
self.core.playback.seek(1000) self.core.playback.seek(1000)
self.replay_events()
listener_mock.reset_mock() listener_mock.reset_mock()
self.core.playback.stop() self.core.playback.stop()
self.replay_events() self.replay_events()
self.assertListEqual( self.assertListEqual(
listener_mock.send.mock_calls,
[ [
mock.call( mock.call(
'playback_state_changed', 'playback_state_changed',
@ -445,7 +463,8 @@ class EventEmissionTest(BaseTest):
mock.call( mock.call(
'track_playback_ended', 'track_playback_ended',
tl_track=tl_tracks[0], time_position=1000), tl_track=tl_tracks[0], time_position=1000),
]) ],
listener_mock.send.mock_calls)
def test_next_emits_events(self, listener_mock): def test_next_emits_events(self, listener_mock):
tl_tracks = self.core.tracklist.get_tl_tracks() tl_tracks = self.core.tracklist.get_tl_tracks()
@ -453,23 +472,26 @@ class EventEmissionTest(BaseTest):
self.core.playback.play(tl_tracks[0]) self.core.playback.play(tl_tracks[0])
self.replay_events() self.replay_events()
self.core.playback.seek(1000) self.core.playback.seek(1000)
self.replay_events()
listener_mock.reset_mock() listener_mock.reset_mock()
self.core.playback.next() self.core.playback.next()
self.replay_events() self.replay_events()
# TODO: should we be emitting playing -> playing?
self.assertListEqual( self.assertListEqual(
listener_mock.send.mock_calls,
[ [
mock.call( mock.call(
'track_playback_ended', 'track_playback_ended',
tl_track=tl_tracks[0], time_position=mock.ANY), tl_track=tl_tracks[0], time_position=mock.ANY),
mock.call(
'playback_state_changed',
old_state='playing', new_state='playing'),
mock.call( mock.call(
'track_playback_started', tl_track=tl_tracks[1]), 'track_playback_started', tl_track=tl_tracks[1]),
]) ],
listener_mock.send.mock_calls)
def test_on_end_of_track_emits_events(self, listener_mock): def test_gapless_track_change_emits_events(self, listener_mock):
tl_tracks = self.core.tracklist.get_tl_tracks() tl_tracks = self.core.tracklist.get_tl_tracks()
self.core.playback.play(tl_tracks[0]) self.core.playback.play(tl_tracks[0])
@ -479,14 +501,17 @@ class EventEmissionTest(BaseTest):
self.trigger_about_to_finish() self.trigger_about_to_finish()
self.assertListEqual( self.assertListEqual(
listener_mock.send.mock_calls,
[ [
mock.call( mock.call(
'track_playback_ended', 'track_playback_ended',
tl_track=tl_tracks[0], time_position=mock.ANY), tl_track=tl_tracks[0], time_position=mock.ANY),
mock.call(
'playback_state_changed',
old_state='playing', new_state='playing'),
mock.call( mock.call(
'track_playback_started', tl_track=tl_tracks[1]), 'track_playback_started', tl_track=tl_tracks[1]),
]) ],
listener_mock.send.mock_calls)
def test_seek_emits_seeked_event(self, listener_mock): def test_seek_emits_seeked_event(self, listener_mock):
tl_tracks = self.core.tracklist.get_tl_tracks() tl_tracks = self.core.tracklist.get_tl_tracks()
@ -496,6 +521,7 @@ class EventEmissionTest(BaseTest):
listener_mock.reset_mock() listener_mock.reset_mock()
self.core.playback.seek(1000) self.core.playback.seek(1000)
self.replay_events()
listener_mock.send.assert_called_once_with( listener_mock.send.assert_called_once_with(
'seeked', time_position=1000) 'seeked', time_position=1000)
@ -511,14 +537,35 @@ class EventEmissionTest(BaseTest):
self.replay_events() self.replay_events()
self.assertListEqual( self.assertListEqual(
listener_mock.send.mock_calls,
[ [
mock.call( mock.call(
'track_playback_ended', 'track_playback_ended',
tl_track=tl_tracks[0], time_position=mock.ANY), tl_track=tl_tracks[0], time_position=mock.ANY),
mock.call(
'playback_state_changed',
old_state='playing', new_state='playing'),
mock.call( mock.call(
'track_playback_started', tl_track=tl_tracks[1]), 'track_playback_started', tl_track=tl_tracks[1]),
]) ],
listener_mock.send.mock_calls)
def test_seek_race_condition_emits_events(self, listener_mock):
tl_tracks = self.core.tracklist.get_tl_tracks()
self.core.playback.play(tl_tracks[0])
self.trigger_about_to_finish(replay_until='stream_changed')
listener_mock.reset_mock()
self.core.playback.seek(1000)
self.replay_events()
# When we trigger seek after an about to finish the other code that
# emits track stopped/started and playback state changed events gets
# triggered as we have to switch back to the previous track.
# The correct behavior would be to only emit seeked.
self.assertListEqual(
[mock.call('seeked', time_position=1000)],
listener_mock.send.mock_calls)
def test_previous_emits_events(self, listener_mock): def test_previous_emits_events(self, listener_mock):
tl_tracks = self.core.tracklist.get_tl_tracks() tl_tracks = self.core.tracklist.get_tl_tracks()
@ -531,14 +578,17 @@ class EventEmissionTest(BaseTest):
self.replay_events() self.replay_events()
self.assertListEqual( self.assertListEqual(
listener_mock.send.mock_calls,
[ [
mock.call( mock.call(
'track_playback_ended', 'track_playback_ended',
tl_track=tl_tracks[1], time_position=mock.ANY), tl_track=tl_tracks[1], time_position=mock.ANY),
mock.call(
'playback_state_changed',
old_state='playing', new_state='playing'),
mock.call( mock.call(
'track_playback_started', tl_track=tl_tracks[0]), 'track_playback_started', tl_track=tl_tracks[0]),
]) ],
listener_mock.send.mock_calls)
class UnplayableURITest(BaseTest): class UnplayableURITest(BaseTest):
@ -612,12 +662,27 @@ class SeekTest(BaseTest):
tl_tracks = self.core.tracklist.get_tl_tracks() tl_tracks = self.core.tracklist.get_tl_tracks()
self.core.playback.play(tl_tracks[0]) self.core.playback.play(tl_tracks[0])
self.replay_events()
self.core.playback.pause() self.core.playback.pause()
self.replay_events() self.replay_events()
self.core.playback.seek(1000) self.core.playback.seek(1000)
self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED)
def test_seek_race_condition_after_about_to_finish(self):
tl_tracks = self.core.tracklist.get_tl_tracks()
self.core.playback.play(tl_tracks[0])
self.replay_events()
self.trigger_about_to_finish(replay_until='stream_changed')
self.core.playback.seek(1000)
self.replay_events()
current_tl_track = self.core.playback.get_current_tl_track()
self.assertEqual(current_tl_track, tl_tracks[0])
class TestStream(BaseTest): class TestStream(BaseTest):
@ -807,7 +872,6 @@ class BackendSelectionTest(unittest.TestCase):
self.core.playback.play(self.tl_tracks[0]) self.core.playback.play(self.tl_tracks[0])
self.trigger_stream_changed() self.trigger_stream_changed()
self.core.playback.seek(10000)
self.core.playback.time_position self.core.playback.time_position
self.playback1.get_time_position.assert_called_once_with() self.playback1.get_time_position.assert_called_once_with()
@ -817,7 +881,6 @@ class BackendSelectionTest(unittest.TestCase):
self.core.playback.play(self.tl_tracks[1]) self.core.playback.play(self.tl_tracks[1])
self.trigger_stream_changed() self.trigger_stream_changed()
self.core.playback.seek(10000)
self.core.playback.time_position self.core.playback.time_position
self.assertFalse(self.playback1.get_time_position.called) self.assertFalse(self.playback1.get_time_position.called)

View File

@ -248,6 +248,10 @@ class PlaylistTest(BasePlaylistsTest):
self.assertFalse(self.sp1.save.called) self.assertFalse(self.sp1.save.called)
self.assertFalse(self.sp2.save.called) self.assertFalse(self.sp2.save.called)
def test_get_uri_schemes(self):
result = self.core.playlists.get_uri_schemes()
self.assertEquals(result, ['dummy1', 'dummy2'])
class DeprecatedFilterPlaylistsTest(BasePlaylistsTest): class DeprecatedFilterPlaylistsTest(BasePlaylistsTest):

View File

@ -1,5 +1,5 @@
#EXTM3U #EXTM3U
# test # test
#EXTINF:-1,song1 #EXTINF:-1,Song #1
# test # test
song1.mp3 song1.mp3

View File

@ -1,3 +1,3 @@
#EXTM3U #EXTM3U
#EXTINF:-1,song1 #EXTINF:-1,Song #1
song1.mp3 song1.mp3

View File

@ -1,5 +1,5 @@
#EXTM3U #EXTM3U
#EXTINF:-1,song1 #EXTINF:-1,Song #1
song1.mp3 song1.mp3
#EXTINF:60,song2 #EXTINF:60,Song #2
song2.mp3 song2.mp3

View File

@ -998,7 +998,7 @@ class LocalPlaybackProviderTest(unittest.TestCase):
self.playback.next().get() self.playback.next().get()
self.assert_next_tl_track_is_not(None) self.assert_next_tl_track_is_not(None)
self.assert_state_is(PlaybackState.STOPPED) self.assert_state_is(PlaybackState.STOPPED)
self.playback.play() self.playback.play().get()
self.assert_state_is(PlaybackState.PLAYING) self.assert_state_is(PlaybackState.PLAYING)
@populate_tracklist @populate_tracklist

View File

@ -38,7 +38,9 @@ class LocalTracklistProviderTest(unittest.TestCase):
self.audio = dummy_audio.create_proxy() self.audio = dummy_audio.create_proxy()
self.backend = actor.LocalBackend.start( self.backend = actor.LocalBackend.start(
config=self.config, audio=self.audio).proxy() config=self.config, audio=self.audio).proxy()
self.core = core.Core(self.config, mixer=None, backends=[self.backend]) self.core = core.Core.start(audio=self.audio,
backends=[self.backend],
config=self.config).proxy()
self.controller = self.core.tracklist self.controller = self.core.tracklist
self.playback = self.core.playback self.playback = self.core.playback
@ -47,216 +49,254 @@ class LocalTracklistProviderTest(unittest.TestCase):
def tearDown(self): # noqa: N802 def tearDown(self): # noqa: N802
pykka.ActorRegistry.stop_all() pykka.ActorRegistry.stop_all()
def assert_state_is(self, state):
self.assertEqual(self.playback.get_state().get(), state)
def assert_current_track_is(self, track):
self.assertEqual(self.playback.get_current_track().get(), track)
def test_length(self): def test_length(self):
self.assertEqual(0, len(self.controller.tl_tracks)) self.assertEqual(0, len(self.controller.get_tl_tracks().get()))
self.assertEqual(0, self.controller.length) self.assertEqual(0, self.controller.get_length().get())
self.controller.add(self.tracks) self.controller.add(self.tracks)
self.assertEqual(3, len(self.controller.tl_tracks)) self.assertEqual(3, len(self.controller.get_tl_tracks().get()))
self.assertEqual(3, self.controller.length) self.assertEqual(3, self.controller.get_length().get())
def test_add(self): def test_add(self):
for track in self.tracks: for track in self.tracks:
tl_tracks = self.controller.add([track]) added = self.controller.add([track]).get()
self.assertEqual(track, self.controller.tracks[-1]) tracks = self.controller.get_tracks().get()
self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) tl_tracks = self.controller.get_tl_tracks().get()
self.assertEqual(track, tl_tracks[0].track)
self.assertEqual(track, tracks[-1])
self.assertEqual(added[0], tl_tracks[-1])
self.assertEqual(track, added[0].track)
def test_add_at_position(self): def test_add_at_position(self):
for track in self.tracks[:-1]: for track in self.tracks[:-1]:
tl_tracks = self.controller.add([track], 0) added = self.controller.add([track], 0).get()
self.assertEqual(track, self.controller.tracks[0]) tracks = self.controller.get_tracks().get()
self.assertEqual(tl_tracks[0], self.controller.tl_tracks[0]) tl_tracks = self.controller.get_tl_tracks().get()
self.assertEqual(track, tl_tracks[0].track)
self.assertEqual(track, tracks[0])
self.assertEqual(added[0], tl_tracks[0])
self.assertEqual(track, added[0].track)
@populate_tracklist @populate_tracklist
def test_add_at_position_outside_of_playlist(self): def test_add_at_position_outside_of_playlist(self):
for track in self.tracks: for track in self.tracks:
tl_tracks = self.controller.add([track], len(self.tracks) + 2) added = self.controller.add([track], len(self.tracks) + 2).get()
self.assertEqual(track, self.controller.tracks[-1]) tracks = self.controller.get_tracks().get()
self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) tl_tracks = self.controller.get_tl_tracks().get()
self.assertEqual(track, tl_tracks[0].track)
self.assertEqual(track, tracks[-1])
self.assertEqual(added[0], tl_tracks[-1])
self.assertEqual(track, added[0].track)
@populate_tracklist @populate_tracklist
def test_filter_by_tlid(self): def test_filter_by_tlid(self):
tl_track = self.controller.tl_tracks[1] tl_track = self.controller.get_tl_tracks().get()[1]
self.assertEqual( result = self.controller.filter({'tlid': [tl_track.tlid]}).get()
[tl_track], self.controller.filter({'tlid': [tl_track.tlid]})) self.assertEqual([tl_track], result)
@populate_tracklist @populate_tracklist
def test_filter_by_uri(self): def test_filter_by_uri(self):
tl_track = self.controller.tl_tracks[1] tl_track = self.controller.get_tl_tracks().get()[1]
self.assertEqual( result = self.controller.filter({'uri': [tl_track.track.uri]}).get()
[tl_track], self.controller.filter({'uri': [tl_track.track.uri]})) self.assertEqual([tl_track], result)
@populate_tracklist @populate_tracklist
def test_filter_by_uri_returns_nothing_for_invalid_uri(self): def test_filter_by_uri_returns_nothing_for_invalid_uri(self):
self.assertEqual([], self.controller.filter({'uri': ['foobar']})) self.assertEqual([], self.controller.filter({'uri': ['foobar']}).get())
def test_filter_by_uri_returns_single_match(self): def test_filter_by_uri_returns_single_match(self):
t = Track(uri='a') t = Track(uri='a')
self.controller.add([Track(uri='z'), t, Track(uri='y')]) self.controller.add([Track(uri='z'), t, Track(uri='y')])
self.assertEqual(t, self.controller.filter({'uri': ['a']})[0].track)
result = self.controller.filter({'uri': ['a']}).get()
self.assertEqual(t, result[0].track)
def test_filter_by_uri_returns_multiple_matches(self): def test_filter_by_uri_returns_multiple_matches(self):
track = Track(uri='a') track = Track(uri='a')
self.controller.add([Track(uri='z'), track, track]) self.controller.add([Track(uri='z'), track, track])
tl_tracks = self.controller.filter({'uri': ['a']}) tl_tracks = self.controller.filter({'uri': ['a']}).get()
self.assertEqual(track, tl_tracks[0].track) self.assertEqual(track, tl_tracks[0].track)
self.assertEqual(track, tl_tracks[1].track) self.assertEqual(track, tl_tracks[1].track)
def test_filter_by_uri_returns_nothing_if_no_match(self): def test_filter_by_uri_returns_nothing_if_no_match(self):
self.controller.playlist = Playlist( self.controller.playlist = Playlist(
tracks=[Track(uri='z'), Track(uri='y')]) tracks=[Track(uri='z'), Track(uri='y')])
self.assertEqual([], self.controller.filter({'uri': ['a']})) self.assertEqual([], self.controller.filter({'uri': ['a']}).get())
def test_filter_by_multiple_criteria_returns_elements_matching_all(self): def test_filter_by_multiple_criteria_returns_elements_matching_all(self):
t1 = Track(uri='a', name='x') t1 = Track(uri='a', name='x')
t2 = Track(uri='b', name='x') t2 = Track(uri='b', name='x')
t3 = Track(uri='b', name='y') t3 = Track(uri='b', name='y')
self.controller.add([t1, t2, t3]) self.controller.add([t1, t2, t3])
self.assertEqual(
t1, self.controller.filter({'uri': ['a'], 'name': ['x']})[0].track) result1 = self.controller.filter({'uri': ['a'], 'name': ['x']}).get()
self.assertEqual( self.assertEqual(t1, result1[0].track)
t2, self.controller.filter({'uri': ['b'], 'name': ['x']})[0].track)
self.assertEqual( result2 = self.controller.filter({'uri': ['b'], 'name': ['x']}).get()
t3, self.controller.filter({'uri': ['b'], 'name': ['y']})[0].track) self.assertEqual(t2, result2[0].track)
result3 = self.controller.filter({'uri': ['b'], 'name': ['y']}).get()
self.assertEqual(t3, result3[0].track)
def test_filter_by_criteria_that_is_not_present_in_all_elements(self): def test_filter_by_criteria_that_is_not_present_in_all_elements(self):
track1 = Track() track1 = Track()
track2 = Track(uri='b') track2 = Track(uri='b')
track3 = Track() track3 = Track()
self.controller.add([track1, track2, track3]) self.controller.add([track1, track2, track3])
self.assertEqual( result = self.controller.filter({'uri': ['b']}).get()
track2, self.controller.filter({'uri': ['b']})[0].track) self.assertEqual(track2, result[0].track)
@populate_tracklist @populate_tracklist
def test_clear(self): def test_clear(self):
self.controller.clear() self.controller.clear().get()
self.assertEqual(len(self.controller.tracks), 0) self.assertEqual(len(self.controller.get_tracks().get()), 0)
def test_clear_empty_playlist(self): def test_clear_empty_playlist(self):
self.controller.clear() self.controller.clear().get()
self.assertEqual(len(self.controller.tracks), 0) self.assertEqual(len(self.controller.get_tracks().get()), 0)
@populate_tracklist @populate_tracklist
def test_clear_when_playing(self): def test_clear_when_playing(self):
self.playback.play() self.playback.play().get()
self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assert_state_is(PlaybackState.PLAYING)
self.controller.clear() self.controller.clear().get()
self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assert_state_is(PlaybackState.STOPPED)
def test_add_appends_to_the_tracklist(self): def test_add_appends_to_the_tracklist(self):
self.controller.add([Track(uri='a'), Track(uri='b')]) self.controller.add([Track(uri='a'), Track(uri='b')])
self.assertEqual(len(self.controller.tracks), 2)
tracks = self.controller.get_tracks().get()
self.assertEqual(len(tracks), 2)
self.controller.add([Track(uri='c'), Track(uri='d')]) self.controller.add([Track(uri='c'), Track(uri='d')])
self.assertEqual(len(self.controller.tracks), 4)
self.assertEqual(self.controller.tracks[0].uri, 'a') tracks = self.controller.get_tracks().get()
self.assertEqual(self.controller.tracks[1].uri, 'b') self.assertEqual(len(tracks), 4)
self.assertEqual(self.controller.tracks[2].uri, 'c') self.assertEqual(tracks[0].uri, 'a')
self.assertEqual(self.controller.tracks[3].uri, 'd') self.assertEqual(tracks[1].uri, 'b')
self.assertEqual(tracks[2].uri, 'c')
self.assertEqual(tracks[3].uri, 'd')
def test_add_does_not_reset_version(self): def test_add_does_not_reset_version(self):
version = self.controller.version version = self.controller.get_version().get()
self.controller.add([]) self.controller.add([])
self.assertEqual(self.controller.version, version) self.assertEqual(self.controller.get_version().get(), version)
@populate_tracklist @populate_tracklist
def test_add_preserves_playing_state(self): def test_add_preserves_playing_state(self):
self.playback.play() self.playback.play().get()
track = self.playback.current_track
self.controller.add(self.controller.tracks[1:2]) track = self.playback.get_current_track().get()
self.assertEqual(self.playback.state, PlaybackState.PLAYING) tracks = self.controller.get_tracks().get()
self.assertEqual(self.playback.current_track, track) self.controller.add(tracks[1:2]).get()
self.assert_state_is(PlaybackState.PLAYING)
self.assert_current_track_is(track)
@populate_tracklist @populate_tracklist
def test_add_preserves_stopped_state(self): def test_add_preserves_stopped_state(self):
self.controller.add(self.controller.tracks[1:2]) tracks = self.controller.get_tracks().get()
self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.controller.add(tracks[1:2]).get()
self.assertEqual(self.playback.current_track, None)
self.assert_state_is(PlaybackState.STOPPED)
self.assert_current_track_is(None)
@populate_tracklist @populate_tracklist
def test_add_returns_the_tl_tracks_that_was_added(self): def test_add_returns_the_tl_tracks_that_was_added(self):
tl_tracks = self.controller.add(self.controller.tracks[1:2]) tracks = self.controller.get_tracks().get()
self.assertEqual(tl_tracks[0].track, self.controller.tracks[1])
added = self.controller.add(tracks[1:2]).get()
tracks = self.controller.get_tracks().get()
self.assertEqual(added[0].track, tracks[1])
@populate_tracklist @populate_tracklist
def test_move_single(self): def test_move_single(self):
self.controller.move(0, 0, 2) self.controller.move(0, 0, 2)
tracks = self.controller.tracks tracks = self.controller.get_tracks().get()
self.assertEqual(tracks[2], self.tracks[0]) self.assertEqual(tracks[2], self.tracks[0])
@populate_tracklist @populate_tracklist
def test_move_group(self): def test_move_group(self):
self.controller.move(0, 2, 1) self.controller.move(0, 2, 1)
tracks = self.controller.tracks tracks = self.controller.get_tracks().get()
self.assertEqual(tracks[1], self.tracks[0]) self.assertEqual(tracks[1], self.tracks[0])
self.assertEqual(tracks[2], self.tracks[1]) self.assertEqual(tracks[2], self.tracks[1])
@populate_tracklist @populate_tracklist
def test_moving_track_outside_of_playlist(self): def test_moving_track_outside_of_playlist(self):
tracks = len(self.controller.tracks) num_tracks = len(self.controller.get_tracks().get())
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
self.controller.move(0, 0, tracks + 5) self.controller.move(0, 0, num_tracks + 5).get()
@populate_tracklist @populate_tracklist
def test_move_group_outside_of_playlist(self): def test_move_group_outside_of_playlist(self):
tracks = len(self.controller.tracks) num_tracks = len(self.controller.get_tracks().get())
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
self.controller.move(0, 2, tracks + 5) self.controller.move(0, 2, num_tracks + 5).get()
@populate_tracklist @populate_tracklist
def test_move_group_out_of_range(self): def test_move_group_out_of_range(self):
tracks = len(self.controller.tracks) num_tracks = len(self.controller.get_tracks().get())
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
self.controller.move(tracks + 2, tracks + 3, 0) self.controller.move(num_tracks + 2, num_tracks + 3, 0).get()
@populate_tracklist @populate_tracklist
def test_move_group_invalid_group(self): def test_move_group_invalid_group(self):
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
self.controller.move(2, 1, 0) self.controller.move(2, 1, 0).get()
def test_tracks_attribute_is_immutable(self): def test_tracks_attribute_is_immutable(self):
tracks1 = self.controller.tracks tracks1 = self.controller.tracks.get()
tracks2 = self.controller.tracks tracks2 = self.controller.tracks.get()
self.assertNotEqual(id(tracks1), id(tracks2)) self.assertNotEqual(id(tracks1), id(tracks2))
@populate_tracklist @populate_tracklist
def test_remove(self): def test_remove(self):
track1 = self.controller.tracks[1] track1 = self.controller.get_tracks().get()[1]
track2 = self.controller.tracks[2] track2 = self.controller.get_tracks().get()[2]
version = self.controller.version version = self.controller.get_version().get()
self.controller.remove({'uri': [track1.uri]}) self.controller.remove({'uri': [track1.uri]})
self.assertLess(version, self.controller.version) self.assertLess(version, self.controller.get_version().get())
self.assertNotIn(track1, self.controller.tracks) self.assertNotIn(track1, self.controller.get_tracks().get())
self.assertEqual(track2, self.controller.tracks[1]) self.assertEqual(track2, self.controller.get_tracks().get()[1])
@populate_tracklist @populate_tracklist
def test_removing_track_that_does_not_exist_does_nothing(self): def test_removing_track_that_does_not_exist_does_nothing(self):
self.controller.remove({'uri': ['/nonexistant']}) self.controller.remove({'uri': ['/nonexistant']}).get()
def test_removing_from_empty_playlist_does_nothing(self): def test_removing_from_empty_playlist_does_nothing(self):
self.controller.remove({'uri': ['/nonexistant']}) self.controller.remove({'uri': ['/nonexistant']}).get()
@populate_tracklist @populate_tracklist
def test_remove_lists(self): def test_remove_lists(self):
track0 = self.controller.tracks[0] version = self.controller.get_version().get()
track1 = self.controller.tracks[1] tracks = self.controller.get_tracks().get()
track2 = self.controller.tracks[2] track0 = tracks[0]
version = self.controller.version track1 = tracks[1]
track2 = tracks[2]
self.controller.remove({'uri': [track0.uri, track2.uri]}) self.controller.remove({'uri': [track0.uri, track2.uri]})
self.assertLess(version, self.controller.version)
self.assertNotIn(track0, self.controller.tracks) tracks = self.controller.get_tracks().get()
self.assertNotIn(track2, self.controller.tracks) self.assertLess(version, self.controller.get_version().get())
self.assertEqual(track1, self.controller.tracks[0]) self.assertNotIn(track0, tracks)
self.assertNotIn(track2, tracks)
self.assertEqual(track1, tracks[0])
@populate_tracklist @populate_tracklist
def test_shuffle(self): def test_shuffle(self):
random.seed(1) random.seed(1)
self.controller.shuffle() self.controller.shuffle()
shuffled_tracks = self.controller.tracks shuffled_tracks = self.controller.get_tracks().get()
self.assertNotEqual(self.tracks, shuffled_tracks) self.assertNotEqual(self.tracks, shuffled_tracks)
self.assertEqual(set(self.tracks), set(shuffled_tracks)) self.assertEqual(set(self.tracks), set(shuffled_tracks))
@ -266,7 +306,7 @@ class LocalTracklistProviderTest(unittest.TestCase):
random.seed(1) random.seed(1)
self.controller.shuffle(1, 3) self.controller.shuffle(1, 3)
shuffled_tracks = self.controller.tracks shuffled_tracks = self.controller.get_tracks().get()
self.assertNotEqual(self.tracks, shuffled_tracks) self.assertNotEqual(self.tracks, shuffled_tracks)
self.assertEqual(self.tracks[0], shuffled_tracks[0]) self.assertEqual(self.tracks[0], shuffled_tracks[0])
@ -275,20 +315,20 @@ class LocalTracklistProviderTest(unittest.TestCase):
@populate_tracklist @populate_tracklist
def test_shuffle_invalid_subset(self): def test_shuffle_invalid_subset(self):
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
self.controller.shuffle(3, 1) self.controller.shuffle(3, 1).get()
@populate_tracklist @populate_tracklist
def test_shuffle_superset(self): def test_shuffle_superset(self):
tracks = len(self.controller.tracks) num_tracks = len(self.controller.get_tracks().get())
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
self.controller.shuffle(1, tracks + 5) self.controller.shuffle(1, num_tracks + 5).get()
@populate_tracklist @populate_tracklist
def test_shuffle_open_subset(self): def test_shuffle_open_subset(self):
random.seed(1) random.seed(1)
self.controller.shuffle(1) self.controller.shuffle(1)
shuffled_tracks = self.controller.tracks shuffled_tracks = self.controller.get_tracks().get()
self.assertNotEqual(self.tracks, shuffled_tracks) self.assertNotEqual(self.tracks, shuffled_tracks)
self.assertEqual(self.tracks[0], shuffled_tracks[0]) self.assertEqual(self.tracks[0], shuffled_tracks[0])
@ -296,22 +336,22 @@ class LocalTracklistProviderTest(unittest.TestCase):
@populate_tracklist @populate_tracklist
def test_slice_returns_a_subset_of_tracks(self): def test_slice_returns_a_subset_of_tracks(self):
track_slice = self.controller.slice(1, 3) track_slice = self.controller.slice(1, 3).get()
self.assertEqual(2, len(track_slice)) self.assertEqual(2, len(track_slice))
self.assertEqual(self.tracks[1], track_slice[0].track) self.assertEqual(self.tracks[1], track_slice[0].track)
self.assertEqual(self.tracks[2], track_slice[1].track) self.assertEqual(self.tracks[2], track_slice[1].track)
@populate_tracklist @populate_tracklist
def test_slice_returns_empty_list_if_indexes_outside_tracks_list(self): def test_slice_returns_empty_list_if_indexes_outside_tracks_list(self):
self.assertEqual(0, len(self.controller.slice(7, 8))) self.assertEqual(0, len(self.controller.slice(7, 8).get()))
self.assertEqual(0, len(self.controller.slice(-1, 1))) self.assertEqual(0, len(self.controller.slice(-1, 1).get()))
def test_version_does_not_change_when_adding_nothing(self): def test_version_does_not_change_when_adding_nothing(self):
version = self.controller.version version = self.controller.get_version().get()
self.controller.add([]) self.controller.add([])
self.assertEqual(version, self.controller.version) self.assertEqual(version, self.controller.get_version().get())
def test_version_increases_when_adding_something(self): def test_version_increases_when_adding_something(self):
version = self.controller.version version = self.controller.get_version().get()
self.controller.add([Track()]) self.controller.add([Track()])
self.assertLess(version, self.controller.version) self.assertLess(version, self.controller.get_version().get())

View File

@ -20,13 +20,15 @@ encoded_path = path_to_data_dir('æøå.mp3')
song1_uri = path.path_to_uri(song1_path) song1_uri = path.path_to_uri(song1_path)
song2_uri = path.path_to_uri(song2_path) song2_uri = path.path_to_uri(song2_path)
song3_uri = path.path_to_uri(song3_path) song3_uri = path.path_to_uri(song3_path)
song4_uri = 'http://example.com/foo%20bar.mp3'
encoded_uri = path.path_to_uri(encoded_path) encoded_uri = path.path_to_uri(encoded_path)
song1_track = Track(uri=song1_uri) song1_track = Track(name='song1', uri=song1_uri)
song2_track = Track(uri=song2_uri) song2_track = Track(name='song2', uri=song2_uri)
song3_track = Track(uri=song3_uri) song3_track = Track(name='φοο', uri=song3_uri)
encoded_track = Track(uri=encoded_uri) song4_track = Track(name='foo bar', uri=song4_uri)
song1_ext_track = song1_track.replace(name='song1') encoded_track = Track(name='æøå', uri=encoded_uri)
song2_ext_track = song2_track.replace(name='song2', length=60000) song1_ext_track = song1_track.replace(name='Song #1')
song2_ext_track = song2_track.replace(name='Song #2', length=60000)
encoded_ext_track = encoded_track.replace(name='æøå') encoded_ext_track = encoded_track.replace(name='æøå')
@ -84,9 +86,11 @@ class M3UToUriTest(unittest.TestCase):
def test_file_with_uri(self): def test_file_with_uri(self):
with tempfile.NamedTemporaryFile(delete=False) as tmp: with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp.write(song1_uri) tmp.write(song1_uri)
tmp.write('\n')
tmp.write(song4_uri)
try: try:
tracks = self.parse(tmp.name) tracks = self.parse(tmp.name)
self.assertEqual([song1_track], tracks) self.assertEqual([song1_track, song4_track], tracks)
finally: finally:
if os.path.exists(tmp.name): if os.path.exists(tmp.name):
os.remove(tmp.name) os.remove(tmp.name)

View File

@ -10,7 +10,7 @@ from tests.mpd import protocol
class IdleHandlerTest(protocol.BaseTestCase): class IdleHandlerTest(protocol.BaseTestCase):
def idle_event(self, subsystem): def idle_event(self, subsystem):
self.session.on_idle(subsystem) self.session.on_event(subsystem)
def assertEqualEvents(self, events): # noqa: N802 def assertEqualEvents(self, events): # noqa: N802
self.assertEqual(set(events), self.context.events) self.assertEqual(set(events), self.context.events)

View File

@ -233,3 +233,24 @@ class IssueGH1120RegressionTest(protocol.BaseTestCase):
response2 = self.send_request('lsinfo "/"') response2 = self.send_request('lsinfo "/"')
self.assertEqual(response1, response2) self.assertEqual(response1, response2)
class IssueGH1348RegressionTest(protocol.BaseTestCase):
"""
The issue: http://github.com/mopidy/mopidy/issues/1348
"""
def test(self):
self.backend.library.dummy_library = [Track(uri='dummy:a')]
# Create a dummy playlist and trigger population of mapping
self.send_request('playlistadd "testing1" "dummy:a"')
self.send_request('listplaylists')
# Create an other playlist which isn't in the map
self.send_request('playlistadd "testing2" "dummy:a"')
self.assertEqual(['OK'], self.send_request('rm "testing2"'))
playlists = self.backend.playlists.as_list().get()
self.assertEqual(['testing1'], [ref.name for ref in playlists])

View File

@ -232,6 +232,10 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
self.assertEqual(0, len(self.core.tracklist.tracks.get())) self.assertEqual(0, len(self.core.tracklist.tracks.get()))
self.assertEqualResponse('ACK [50@0] {load} No such playlist') self.assertEqualResponse('ACK [50@0] {load} No such playlist')
# No invalid name check for load.
self.send_request('load "unknown/playlist"')
self.assertEqualResponse('ACK [50@0] {load} No such playlist')
def test_playlistadd(self): def test_playlistadd(self):
tracks = [ tracks = [
Track(uri='dummy:a'), Track(uri='dummy:a'),
@ -259,6 +263,12 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
self.assertInResponse('OK') self.assertInResponse('OK')
self.assertIsNotNone(self.backend.playlists.lookup('dummy:name').get()) self.assertIsNotNone(self.backend.playlists.lookup('dummy:name').get())
def test_playlistadd_invalid_name_acks(self):
self.send_request('playlistadd "foo/bar" "dummy:a"')
self.assertInResponse('ACK [2@0] {playlistadd} playlist name is '
'invalid: playlist names may not contain '
'slashes, newlines or carriage returns')
def test_playlistclear(self): def test_playlistclear(self):
self.backend.playlists.set_dummy_playlists([ self.backend.playlists.set_dummy_playlists([
Playlist( Playlist(
@ -276,6 +286,12 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
self.assertInResponse('OK') self.assertInResponse('OK')
self.assertIsNotNone(self.backend.playlists.lookup('dummy:name').get()) self.assertIsNotNone(self.backend.playlists.lookup('dummy:name').get())
def test_playlistclear_invalid_name_acks(self):
self.send_request('playlistclear "foo/bar"')
self.assertInResponse('ACK [2@0] {playlistclear} playlist name is '
'invalid: playlist names may not contain '
'slashes, newlines or carriage returns')
def test_playlistdelete(self): def test_playlistdelete(self):
tracks = [ tracks = [
Track(uri='dummy:a'), Track(uri='dummy:a'),
@ -292,6 +308,21 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
self.assertEqual( self.assertEqual(
2, len(self.backend.playlists.get_items('dummy:a1').get())) 2, len(self.backend.playlists.get_items('dummy:a1').get()))
def test_playlistdelete_invalid_name_acks(self):
self.send_request('playlistdelete "foo/bar" "0"')
self.assertInResponse('ACK [2@0] {playlistdelete} playlist name is '
'invalid: playlist names may not contain '
'slashes, newlines or carriage returns')
def test_playlistdelete_unknown_playlist_acks(self):
self.send_request('playlistdelete "foobar" "0"')
self.assertInResponse('ACK [50@0] {playlistdelete} No such playlist')
def test_playlistdelete_unknown_index_acks(self):
self.send_request('save "foobar"')
self.send_request('playlistdelete "foobar" "0"')
self.assertInResponse('ACK [2@0] {playlistdelete} Bad song index')
def test_playlistmove(self): def test_playlistmove(self):
tracks = [ tracks = [
Track(uri='dummy:a'), Track(uri='dummy:a'),
@ -309,6 +340,42 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
"dummy:c", "dummy:c",
self.backend.playlists.get_items('dummy:a1').get()[0].uri) self.backend.playlists.get_items('dummy:a1').get()[0].uri)
def test_playlistmove_invalid_name_acks(self):
self.send_request('playlistmove "foo/bar" "0" "1"')
self.assertInResponse('ACK [2@0] {playlistmove} playlist name is '
'invalid: playlist names may not contain '
'slashes, newlines or carriage returns')
def test_playlistmove_unknown_playlist_acks(self):
self.send_request('playlistmove "foobar" "0" "1"')
self.assertInResponse('ACK [50@0] {playlistmove} No such playlist')
def test_playlistmove_unknown_position_acks(self):
self.send_request('save "foobar"')
self.send_request('playlistmove "foobar" "0" "1"')
self.assertInResponse('ACK [2@0] {playlistmove} Bad song index')
def test_playlistmove_same_index_shortcircuits_everything(self):
# Bad indexes on unknown playlist:
self.send_request('playlistmove "foobar" "0" "0"')
self.assertInResponse('OK')
self.send_request('playlistmove "foobar" "100000" "100000"')
self.assertInResponse('OK')
# Bad indexes on known playlist:
self.send_request('save "foobar"')
self.send_request('playlistmove "foobar" "0" "0"')
self.assertInResponse('OK')
self.send_request('playlistmove "foobar" "10" "10"')
self.assertInResponse('OK')
# Invalid playlist name:
self.send_request('playlistmove "foo/bar" "0" "0"')
self.assertInResponse('OK')
def test_rename(self): def test_rename(self):
self.backend.playlists.set_dummy_playlists([ self.backend.playlists.set_dummy_playlists([
Playlist( Playlist(
@ -320,6 +387,31 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
self.assertIsNotNone( self.assertIsNotNone(
self.backend.playlists.lookup('dummy:new_name').get()) self.backend.playlists.lookup('dummy:new_name').get())
def test_rename_unknown_playlist_acks(self):
self.send_request('rename "foo" "bar"')
self.assertInResponse('ACK [50@0] {rename} No such playlist')
def test_rename_to_existing_acks(self):
self.send_request('save "foo"')
self.send_request('save "bar"')
self.send_request('rename "foo" "bar"')
self.assertInResponse('ACK [56@0] {rename} Playlist already exists')
def test_rename_invalid_name_acks(self):
expected = ('ACK [2@0] {rename} playlist name is invalid: playlist '
'names may not contain slashes, newlines or carriage '
'returns')
self.send_request('rename "foo/bar" "bar"')
self.assertInResponse(expected)
self.send_request('rename "foo" "foo/bar"')
self.assertInResponse(expected)
self.send_request('rename "bar/foo" "foo/bar"')
self.assertInResponse(expected)
def test_rm(self): def test_rm(self):
self.backend.playlists.set_dummy_playlists([ self.backend.playlists.set_dummy_playlists([
Playlist( Playlist(
@ -330,8 +422,24 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
self.assertInResponse('OK') self.assertInResponse('OK')
self.assertIsNone(self.backend.playlists.lookup('dummy:a1').get()) self.assertIsNone(self.backend.playlists.lookup('dummy:a1').get())
def test_rm_unknown_playlist_acks(self):
self.send_request('rm "name"')
self.assertInResponse('ACK [50@0] {rm} No such playlist')
def test_rm_invalid_name_acks(self):
self.send_request('rm "foo/bar"')
self.assertInResponse('ACK [2@0] {rm} playlist name is invalid: '
'playlist names may not contain slashes, '
'newlines or carriage returns')
def test_save(self): def test_save(self):
self.send_request('save "name"') self.send_request('save "name"')
self.assertInResponse('OK') self.assertInResponse('OK')
self.assertIsNotNone(self.backend.playlists.lookup('dummy:name').get()) self.assertIsNotNone(self.backend.playlists.lookup('dummy:name').get())
def test_save_invalid_name_acks(self):
self.send_request('save "foo/bar"')
self.assertInResponse('ACK [2@0] {save} playlist name is invalid: '
'playlist names may not contain slashes, '
'newlines or carriage returns')

44
tests/mpd/test_actor.py Normal file
View File

@ -0,0 +1,44 @@
from __future__ import absolute_import, unicode_literals
import mock
import pytest
from mopidy.mpd import actor
# NOTE: Should be kept in sync with all events from mopidy.core.listener
@pytest.mark.parametrize("event,expected", [
(['track_playback_paused', 'tl_track', 'time_position'], None),
(['track_playback_resumed', 'tl_track', 'time_position'], None),
(['track_playback_started', 'tl_track'], None),
(['track_playback_ended', 'tl_track', 'time_position'], None),
(['playback_state_changed', 'old_state', 'new_state'], 'player'),
(['tracklist_changed'], 'playlist'),
(['playlists_loaded'], 'stored_playlist'),
(['playlist_changed', 'playlist'], 'stored_playlist'),
(['playlist_deleted', 'uri'], 'stored_playlist'),
(['options_changed'], 'options'),
(['volume_changed', 'volume'], 'mixer'),
(['mute_changed', 'mute'], 'output'),
(['seeked', 'time_position'], 'player'),
(['stream_title_changed', 'title'], 'playlist'),
])
def test_idle_hooked_up_correctly(event, expected):
config = {'mpd': {'hostname': 'foobar',
'port': 1234,
'zeroconf': None,
'max_connections': None,
'connection_timeout': None}}
with mock.patch.object(actor.MpdFrontend, '_setup_server'):
frontend = actor.MpdFrontend(core=mock.Mock(), config=config)
with mock.patch('mopidy.listener.send') as send_mock:
frontend.on_event(event[0], **{e: None for e in event[1:]})
if expected is None:
assert not send_mock.call_args
else:
send_mock.assert_called_once_with(mock.ANY, expected)

View File

@ -9,6 +9,7 @@ from mopidy import httpclient
@pytest.mark.parametrize("config,expected", [ @pytest.mark.parametrize("config,expected", [
({}, None), ({}, None),
({'hostname': ''}, None),
({'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), ({'hostname': 'proxy.lan'}, 'http://proxy.lan:80'),
({'scheme': None, 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), ({'scheme': None, 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'),
({'scheme': 'https', 'hostname': 'proxy.lan'}, 'https://proxy.lan:80'), ({'scheme': 'https', 'hostname': 'proxy.lan'}, 'https://proxy.lan:80'),
@ -16,6 +17,8 @@ from mopidy import httpclient
({'password': 'pass', 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'), ({'password': 'pass', 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'),
({'hostname': 'proxy.lan', 'port': 8080}, 'http://proxy.lan:8080'), ({'hostname': 'proxy.lan', 'port': 8080}, 'http://proxy.lan:8080'),
({'hostname': 'proxy.lan', 'port': -1}, 'http://proxy.lan:80'), ({'hostname': 'proxy.lan', 'port': -1}, 'http://proxy.lan:80'),
({'hostname': 'proxy.lan', 'port': None}, 'http://proxy.lan:80'),
({'hostname': 'proxy.lan', 'port': ''}, 'http://proxy.lan:80'),
({'username': 'user', 'password': 'pass', 'hostname': 'proxy.lan'}, ({'username': 'user', 'password': 'pass', 'hostname': 'proxy.lan'},
'http://user:pass@proxy.lan:80'), 'http://user:pass@proxy.lan:80'),
]) ])