Release v0.2.0

This commit is contained in:
Stein Magnus Jodal 2010-10-24 22:18:04 +02:00
commit fa8d24add0
58 changed files with 1741 additions and 526 deletions

1
.gitignore vendored
View File

@ -8,4 +8,5 @@ cover/
coverage.xml
dist/
docs/_build/
mopidy.log
nosetests.xml

View File

@ -11,8 +11,9 @@ platforms, including Windows, Mac OS X, Linux, and iPhone and Android phones.
To install Mopidy, check out
`the installation docs <http://www.mopidy.com/docs/master/installation/>`_.
* `Documentation <http://www.mopidy.com/docs/master/>`_
* `Documentation (latest release) <http://www.mopidy.com/docs/master/>`_
* `Documentation (development version) <http://www.mopidy.com/docs/develop/>`_
* `Source code <http://github.com/jodal/mopidy>`_
* `Issue tracker <http://github.com/jodal/mopidy/issues>`_
* IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
* `Download development snapshot <http://github.com/jodal/mopidy/tarball/develop#egg=mopidy-dev>`_

View File

@ -8,11 +8,18 @@ A frontend is responsible for exposing Mopidy for a type of clients.
Frontend API
============
A stable frontend API is not available yet, as we've only implemented a single
frontend module.
.. warning::
A stable frontend API is not available yet, as we've only implemented a
couple of frontend modules.
.. automodule:: mopidy.frontends.base
:synopsis: Base class for frontends
:members:
Frontends
=========
* :mod:`mopidy.frontends.lastfm`
* :mod:`mopidy.frontends.mpd`

View File

@ -0,0 +1,7 @@
******************************
:mod:`mopidy.frontends.lastfm`
******************************
.. automodule:: mopidy.frontends.lastfm
:synopsis: Last.fm scrobbler frontend
:members:

View File

@ -5,6 +5,74 @@ Changes
This change log is used to track all major changes to Mopidy.
0.2.0 (2010-10-24)
==================
In Mopidy 0.2.0 we've added a `Last.fm <http://www.last.fm/>`_ scrobbling
support, which means that Mopidy now can submit meta data about the tracks you
play to your Last.fm profile. See :mod:`mopidy.frontends.lastfm` for
details on new dependencies and settings. If you use Mopidy's Last.fm support,
please join the `Mopidy group at Last.fm <http://www.last.fm/group/Mopidy>`_.
With the exception of the work on the Last.fm scrobbler, there has been a
couple of quiet months in the Mopidy camp. About the only thing going on, has
been stabilization work and bug fixing. All bugs reported on GitHub, plus some,
have been fixed in 0.2.0. Thus, we hope this will be a great release!
We've worked a bit on OS X support, but not all issues are completely solved
yet. :issue:`25` is the one that is currently blocking OS X support. Any help
solving it will be greatly appreciated!
Finally, please :ref:`update your pyspotify installation
<pyspotify_installation>` when upgrading to Mopidy 0.2.0. The latest pyspotify
got a fix for the segmentation fault that occurred when playing music and
searching at the same time, thanks to Valentin David.
**Important changes**
- Added a Last.fm scrobbler. See :mod:`mopidy.frontends.lastfm` for details.
**Changes**
- Logging and command line options:
- Simplify the default log format,
:attr:`mopidy.settings.CONSOLE_LOG_FORMAT`. From a user's point of view:
Less noise, more information.
- Rename the :option:`--dump` command line option to
:option:`--save-debug-log`.
- Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to
:attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for :option:`--verbose`
too.
- Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to
:attr:`mopidy.settings.DEBUG_LOG_FILENAME`.
- MPD frontend:
- MPD command ``list`` now supports queries by artist, album name, and date,
as used by e.g. the Ario client. (Fixes: :issue:`20`)
- MPD command ``add ""`` and ``addid ""`` now behaves as expected. (Fixes
:issue:`16`)
- MPD command ``playid "-1"`` now correctly resumes playback if paused.
- Random mode:
- Fix wrong behavior on end of track and next after random mode has been
used. (Fixes: :issue:`18`)
- Fix infinite recursion loop crash on playback of non-playable tracks when
in random mode. (Fixes :issue:`17`)
- Fix assertion error that happened if one removed tracks from the current
playlist, while in random mode. (Fixes :issue:`22`)
- Switched from using subprocesses to threads. (Fixes: :issue:`14`)
- :mod:`mopidy.outputs.gstreamer`: Set ``caps`` on the ``appsrc`` bin before
use. This makes sound output work with GStreamer >= 0.10.29, which includes
the versions used in Ubuntu 10.10 and on OS X if using Homebrew. (Fixes:
:issue:`21`, :issue:`24`, contributes to :issue:`14`)
- Improved handling of uncaught exceptions in threads. The entire process
should now exit immediately.
0.1.0 (2010-08-23)
==================

8
docs/clients/index.rst Normal file
View File

@ -0,0 +1,8 @@
*******
Clients
*******
.. toctree::
:glob:
**

98
docs/clients/mpd.rst Normal file
View File

@ -0,0 +1,98 @@
************************
MPD client compatability
************************
This is a list of MPD clients we either know works well with Mopidy, or that we
know won't work well. For a more exhaustive list of MPD clients, see
http://mpd.wikia.com/wiki/Clients.
Console clients
===============
mpc
---
A command line client. Version 0.14 had some issues with Mopidy (see
:issue:`5`), but 0.16 seems to work nicely.
ncmpc
-----
A console client. Uses the ``idle`` command heavily, which Mopidy doesn't
support yet. If you want a console client, use ncmpcpp instead.
ncmpcpp
-------
A console client that generally works well with Mopidy, and is regularly used
by Mopidy developers.
Search only works for ncmpcpp versions 0.5.1 and higher, and in two of the
three search modes:
- "Match if tag contains search phrase (regexes supported)" -- Does not work.
The client tries to fetch all known metadata and do the search client side.
- "Match if tag contains searched phrase (no regexes)" -- Works.
- "Match only if both values are the same" -- Works.
If you run Ubuntu 10.04 or older, you can fetch an updated version of ncmpcpp
from `Launchpad <https://launchpad.net/ubuntu/+source/ncmpcpp>`_.
Graphical clients
=================
GMPC
----
A GTK+ client which works well with Mopidy, and is regularly used by Mopidy
developers.
Sonata
------
A GTK+ client. Generally works well with Mopidy.
Search does not work, because they do most of the search on the client side.
See :issue:`1` for details.
Android clients
===============
BitMPC
------
Works well with Mopidy.
Droid MPD
---------
Works well with Mopidy.
MPDroid
-------
Works well with Mopidy, and is regularly used by Mopidy developers.
PMix
----
Works well with Mopidy.
ThreeMPD
--------
Does not work well with Mopidy, because we haven't implemented ``listallinfo``
yet.
iPhone/iPod Touch clients
=========================
MPod
----
Works well with Mopidy as far as we've heard from users.

View File

@ -151,20 +151,25 @@ Then, to generate docs::
Creating releases
=================
1. Update changelog and commit it.
#. Update changelog and commit it.
2. Tag release::
#. Merge the release branch (``develop`` in the example) into master::
git tag -a -m "Release v0.1.0a0" v0.1.0a0
git checkout master
git merge --no-ff -m "Release v0.2.0" develop
3. Push to GitHub::
#. Tag the release::
git tag -a -m "Release v0.2.0" v0.2.0
#. Push to GitHub::
git push
git push --tags
4. Build package and upload to PyPI::
#. Build package and upload to PyPI::
rm MANIFEST # Will be regenerated by setup.py
python setup.py sdist upload
5. Spread the word.
#. Spread the word.

View File

@ -6,26 +6,34 @@ This is the current roadmap and collection of wild ideas for future Mopidy
development. This is intended to be a living document and may change at any
time.
Version 0.1
===========
- Core MPD server functionality working. Gracefully handle clients' use of
non-supported functionality.
- Read-only support for Spotify through :mod:`mopidy.backends.libspotify`.
- Initial support for local file playback through
:mod:`mopidy.backends.local`. The state of local file playback will not
block the release of 0.1.
We intend to have about one timeboxed release every month. Thus, the roadmap is
oriented around "soon" and "later" instead of mapping each feature to a future
release.
Version 0.2 and 0.3
===================
Possible targets for the next version
=====================================
0.2 will be released when we reach one of the following two goals. 0.3 will be
released when we reach the other goal.
- Write-support for Spotify. I.e. playlist management.
- Reintroduce support for OS X. See :issue:`14` for details.
- Support for using multiple Mopidy backends simultaneously. Should make it
possible to have both Spotify tracks and local tracks in the same playlist.
- MPD frontend:
- ``idle`` support.
- Spotify backend:
- Write-support for Spotify, i.e. playlist management.
- Virtual directories with e.g. starred tracks from Spotify.
- Support for 320 kbps audio.
- Local backend:
- Better library support.
- A script for creating a tag cache.
- An alternative to tag cache for caching metadata, i.e. Sqlite.
- **[DONE]** Last.fm scrobbling.
Stuff we want to do, but not right now, and maybe never
@ -45,15 +53,12 @@ Stuff we want to do, but not right now, and maybe never
- Compatability:
- Run frontend tests against a real MPD server to ensure we are in sync.
- Start working with MPD client maintainers to get rid of weird assumptions
like only searching for first two letters and doing the rest of the
filtering locally in the client (:issue:`1`), etc.
- Backends:
- `Last.fm <http://www.last.fm/api>`_
- `WIMP <http://twitter.com/wimp/status/8975885632>`_
- DNLA/UPnP to Mopidy can play music from other DNLA MediaServers.
- DNLA/UPnP so Mopidy can play music from other DNLA MediaServers.
- Frontends:
@ -63,7 +68,7 @@ Stuff we want to do, but not right now, and maybe never
- REST/JSON web service with a jQuery client as example application. Maybe
based upon `Tornado <http://github.com/facebook/tornado>`_ and `jQuery
Mobile <http://jquerymobile.com/>`_.
- DNLA/UPnP to Mopidy can be controlled from i.e. TVs.
- DNLA/UPnP so Mopidy can be controlled from i.e. TVs.
- `XMMS2 <http://www.xmms2.org/>`_
- LIRC frontend for controlling Mopidy with a remote.

View File

@ -7,6 +7,9 @@ User documentation
:maxdepth: 3
installation/index
settings
running
clients/index
changes
authors
licenses

View File

@ -54,6 +54,12 @@ Make sure you got the required dependencies installed.
- No additional dependencies.
- Optional dependencies:
- :mod:`mopidy.frontends.lastfm`
- pylast >= 4.3.0
Install latest release
======================
@ -70,6 +76,9 @@ To later upgrade to the latest release::
If you for some reason can't use ``pip``, try ``easy_install``.
Next, you need to set a couple of :doc:`settings </settings>`, and then you're
ready to :doc:`run Mopidy </running>`.
Install development version
===========================
@ -92,58 +101,5 @@ To later update to the very latest version::
For an introduction to ``git``, please visit `git-scm.com
<http://git-scm.com/>`_.
Settings
========
Mopidy reads settings from the file ``~/.mopidy/settings.py``, where ``~``
means your *home directory*. If your username is ``alice`` and you are running
Linux, the settings file should probably be at
``/home/alice/.mopidy/settings.py``.
You can either create this file yourself, or run the ``mopidy`` command, and it
will create an empty settings file for you.
Music from Spotify
------------------
If you are using the Spotify backend, which is the default, enter your Spotify
Premium account's username and password into the file, like this::
SPOTIFY_USERNAME = u'myusername'
SPOTIFY_PASSWORD = u'mysecret'
Music from local storage
------------------------
If you want use Mopidy to play music you have locally at your machine instead
of using Spotify, you need to change the backend from the default to
:mod:`mopidy.backends.local` by adding the following line to your settings
file::
BACKENDS = (u'mopidy.backends.local.LocalBackend',)
You may also want to change some of the ``LOCAL_*`` settings. See
:mod:`mopidy.settings`, for a full list of available settings.
Connecting from other machines on the network
---------------------------------------------
As a secure default, Mopidy only accepts connections from ``localhost``. If you
want to open it for connections from other machines on your network, see
the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`.
Running Mopidy
==============
To start Mopidy, simply open a terminal and run::
mopidy
When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to
accept connections by any MPD client. You can find tons of MPD clients at
http://mpd.wikia.com/wiki/Clients. We use GMPC and ncmpcpp during development.
The first is a GUI client, and the second is a terminal client.
To stop Mopidy, press ``CTRL+C``.
Next, you need to set a couple of :doc:`settings </settings>`, and then you're
ready to :doc:`run Mopidy </running>`.

13
docs/running.rst Normal file
View File

@ -0,0 +1,13 @@
**************
Running Mopidy
**************
To start Mopidy, simply open a terminal and run::
mopidy
When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to
accept connections by any MPD client. Check out our non-exhaustive
:doc:`/clients/mpd` list to find recommended clients.
To stop Mopidy, press ``CTRL+C``.

55
docs/settings.rst Normal file
View File

@ -0,0 +1,55 @@
********
Settings
********
Mopidy reads settings from the file ``~/.mopidy/settings.py``, where ``~``
means your *home directory*. If your username is ``alice`` and you are running
Linux, the settings file should probably be at
``/home/alice/.mopidy/settings.py``.
You can either create this file yourself, or run the ``mopidy`` command, and it
will create an empty settings file for you.
Music from Spotify
==================
If you are using the Spotify backend, which is the default, enter your Spotify
Premium account's username and password into the file, like this::
SPOTIFY_USERNAME = u'myusername'
SPOTIFY_PASSWORD = u'mysecret'
Music from local storage
========================
If you want use Mopidy to play music you have locally at your machine instead
of using Spotify, you need to change the backend from the default to
:mod:`mopidy.backends.local` by adding the following line to your settings
file::
BACKENDS = (u'mopidy.backends.local.LocalBackend',)
You may also want to change some of the ``LOCAL_*`` settings. See
:mod:`mopidy.settings`, for a full list of available settings.
Connecting from other machines on the network
=============================================
As a secure default, Mopidy only accepts connections from ``localhost``. If you
want to open it for connections from other machines on your network, see
the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`.
Scrobbling tracks to Last.fm
============================
If you want to submit the tracks you are playing to your `Last.fm
<http://www.last.fm/>`_ profile, make sure you've installed the dependencies
found at :mod:`mopidy.frontends.lastfm` and add the following to your settings
file::
LASTFM_USERNAME = u'myusername'
LASTFM_PASSWORD = u'mysecret'

View File

@ -3,7 +3,7 @@ if not (2, 6) <= sys.version_info < (3,):
sys.exit(u'Mopidy requires Python >= 2.6, < 3')
def get_version():
return u'0.1.0'
return u'0.2.0'
class MopidyException(Exception):
def __init__(self, message, *args, **kwargs):
@ -22,6 +22,9 @@ class MopidyException(Exception):
class SettingsError(MopidyException):
pass
class OptionalDependencyError(MopidyException):
pass
from mopidy import settings as default_settings_module
from mopidy.utils.settings import SettingsProxy
settings = SettingsProxy(default_settings_module)

View File

@ -23,17 +23,17 @@ class BaseBackend(object):
:param core_queue: a queue for sending messages to
:class:`mopidy.process.CoreProcess`
:type core_queue: :class:`multiprocessing.Queue`
:param output_queue: a queue for sending messages to the output process
:type output_queue: :class:`multiprocessing.Queue`
:param output: the audio output
:type output: :class:`mopidy.outputs.gstreamer.GStreamerOutput` or similar
:param mixer_class: either a mixer class, or :class:`None` to use the mixer
defined in settings
:type mixer_class: a subclass of :class:`mopidy.mixers.BaseMixer` or
:class:`None`
"""
def __init__(self, core_queue=None, output_queue=None, mixer_class=None):
def __init__(self, core_queue=None, output=None, mixer_class=None):
self.core_queue = core_queue
self.output_queue = output_queue
self.output = output
if mixer_class is None:
mixer_class = get_class(settings.MIXER)
self.mixer = mixer_class(self)

View File

@ -12,13 +12,10 @@ class BaseCurrentPlaylistController(object):
:type backend: :class:`BaseBackend`
"""
#: The current playlist version. Integer which is increased every time the
#: current playlist is changed. Is not reset before Mopidy is restarted.
version = 0
def __init__(self, backend):
self.backend = backend
self._cp_tracks = []
self._version = 0
def destroy(self):
"""Cleanup after component."""
@ -42,6 +39,19 @@ class BaseCurrentPlaylistController(object):
"""
return [ct[1] for ct in self._cp_tracks]
@property
def version(self):
"""
The current playlist version. Integer which is increased every time the
current playlist is changed. Is not reset before Mopidy is restarted.
"""
return self._version
@version.setter
def version(self, version):
self._version = version
self.backend.playback.on_current_playlist_change()
def add(self, track, at_position=None):
"""
Add the track to the end of, or at the given position in the current
@ -71,16 +81,13 @@ class BaseCurrentPlaylistController(object):
:param tracks: tracks to append
:type tracks: list of :class:`mopidy.models.Track`
"""
self.version += 1
for track in tracks:
self.add(track)
self.backend.playback.on_current_playlist_change()
def clear(self):
"""Clear the current playlist."""
self._cp_tracks = []
self.version += 1
self.backend.playback.on_current_playlist_change()
def get(self, **criteria):
"""
@ -146,7 +153,6 @@ class BaseCurrentPlaylistController(object):
to_position += 1
self._cp_tracks = new_cp_tracks
self.version += 1
self.backend.playback.on_current_playlist_change()
def remove(self, **criteria):
"""
@ -191,7 +197,6 @@ class BaseCurrentPlaylistController(object):
random.shuffle(shuffled)
self._cp_tracks = before + shuffled + after
self.version += 1
self.backend.playback.on_current_playlist_change()
def mpd_format(self, *args, **kwargs):
"""Not a part of the generic backend API."""

View File

@ -142,7 +142,7 @@ class BasePlaybackController(object):
random.shuffle(self._shuffled)
self._first_shuffle = False
if self._shuffled:
if self.random and self._shuffled:
return self._shuffled[0]
if self.current_cp_track is None:
@ -195,7 +195,7 @@ class BasePlaybackController(object):
random.shuffle(self._shuffled)
self._first_shuffle = False
if self._shuffled:
if self.random and self._shuffled:
return self._shuffled[0]
if self.current_cp_track is None:
@ -311,14 +311,12 @@ class BasePlaybackController(object):
return
original_cp_track = self.current_cp_track
if self.cp_track_at_eot:
self.play(self.cp_track_at_eot)
if self.random and self.current_cp_track in self._shuffled:
self._shuffled.remove(self.current_cp_track)
if self.cp_track_at_eot:
self._trigger_stopped_playing_event()
self.play(self.cp_track_at_eot)
else:
self.stop()
self.current_cp_track = None
self.stop(clear_current_track=True)
if self.consume:
self.backend.current_playlist.remove(cpid=original_cp_track[0])
@ -332,13 +330,10 @@ class BasePlaybackController(object):
self._first_shuffle = True
self._shuffled = []
if not self.backend.current_playlist.cp_tracks:
self.stop()
self.current_cp_track = None
elif (self.current_cp_track not in
if (not self.backend.current_playlist.cp_tracks or
self.current_cp_track not in
self.backend.current_playlist.cp_tracks):
self.current_cp_track = None
self.stop()
self.stop(clear_current_track=True)
def next(self):
"""Play the next track."""
@ -346,13 +341,10 @@ class BasePlaybackController(object):
return
if self.cp_track_at_next:
self._trigger_stopped_playing_event()
self.play(self.cp_track_at_next)
else:
self.stop()
self.current_cp_track = None
if self.random and self.current_cp_track in self._shuffled:
self._shuffled.remove(self.current_cp_track)
self.stop(clear_current_track=True)
def pause(self):
"""Pause playback."""
@ -383,15 +375,21 @@ class BasePlaybackController(object):
if cp_track is not None:
assert cp_track in self.backend.current_playlist.cp_tracks
elif not self.current_cp_track:
if cp_track is None and self.current_cp_track is None:
cp_track = self.cp_track_at_next
if self.state == self.PAUSED and cp_track is None:
if cp_track is None and self.state == self.PAUSED:
self.resume()
elif cp_track is not None:
if cp_track is not None:
self.state = self.STOPPED
self.current_cp_track = cp_track
self.state = self.PLAYING
if not self._play(cp_track[1]):
# Track is not playable
if self.random and self._shuffled:
self._shuffled.remove(cp_track)
if on_error_step == 1:
self.next()
elif on_error_step == -1:
@ -400,6 +398,8 @@ class BasePlaybackController(object):
if self.random and self.current_cp_track in self._shuffled:
self._shuffled.remove(self.current_cp_track)
self._trigger_started_playing_event()
def _play(self, track):
"""
To be overridden by subclass. Implement your backend's play
@ -418,6 +418,7 @@ class BasePlaybackController(object):
return
if self.state == self.STOPPED:
return
self._trigger_stopped_playing_event()
self.play(self.cp_track_at_previous, on_error_step=-1)
def resume(self):
@ -442,8 +443,9 @@ class BasePlaybackController(object):
:type time_position: int
:rtype: :class:`True` if successful, else :class:`False`
"""
# FIXME I think return value is only really useful for internal
# testing, as such it should probably not be exposed in API.
if not self.backend.current_playlist.tracks:
return False
if self.state == self.STOPPED:
self.play()
elif self.state == self.PAUSED:
@ -451,9 +453,9 @@ class BasePlaybackController(object):
if time_position < 0:
time_position = 0
elif self.current_track and time_position > self.current_track.length:
elif time_position > self.current_track.length:
self.next()
return
return True
self._play_time_started = self._current_wall_time
self._play_time_accumulated = time_position
@ -471,10 +473,21 @@ class BasePlaybackController(object):
"""
raise NotImplementedError
def stop(self):
"""Stop playing."""
if self.state != self.STOPPED and self._stop():
def stop(self, clear_current_track=False):
"""
Stop playing.
:param clear_current_track: whether to clear the current track _after_
stopping
:type clear_current_track: boolean
"""
if self.state == self.STOPPED:
return
self._trigger_stopped_playing_event()
if self._stop():
self.state = self.STOPPED
if clear_current_track:
self.current_cp_track = None
def _stop(self):
"""
@ -484,3 +497,33 @@ class BasePlaybackController(object):
:rtype: :class:`True` if successful, else :class:`False`
"""
raise NotImplementedError
def _trigger_started_playing_event(self):
"""
Notifies frontends that a track has started playing.
For internal use only. Should be called by the backend directly after a
track has started playing.
"""
if self.current_track is not None:
self.backend.core_queue.put({
'to': 'frontend',
'command': 'started_playing',
'track': self.current_track,
})
def _trigger_stopped_playing_event(self):
"""
Notifies frontends that a track has stopped playing.
For internal use only. Should be called by the backend before a track
is stopped playing, e.g. at the next, previous, and stop actions and at
end-of-track.
"""
if self.current_track is not None:
self.backend.core_queue.put({
'to': 'frontend',
'command': 'stopped_playing',
'track': self.current_track,
'stop_position': self.time_position,
})

View File

@ -44,26 +44,35 @@ class DummyLibraryController(BaseLibraryController):
class DummyPlaybackController(BasePlaybackController):
def _next(self, track):
return True
"""Pass None as track to force failure"""
return track is not None
def _pause(self):
return True
def _play(self, track):
return True
"""Pass None as track to force failure"""
return track is not None
def _previous(self, track):
return True
"""Pass None as track to force failure"""
return track is not None
def _resume(self):
return True
def _seek(self, time_position):
pass
return True
def _stop(self):
return True
def _trigger_started_playing_event(self):
pass # noop
def _trigger_stopped_playing_event(self):
pass # noop
class DummyStoredPlaylistsController(BaseStoredPlaylistsController):
_playlists = []

View File

@ -51,10 +51,10 @@ class LibspotifyBackend(BaseBackend):
from .session_manager import LibspotifySessionManager
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
logger.info(u'Connecting to Spotify')
logger.debug(u'Connecting to Spotify')
spotify = LibspotifySessionManager(
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD,
core_queue=self.core_queue,
output_queue=self.output_queue)
output=self.output)
spotify.start()
return spotify

View File

@ -1,11 +1,12 @@
import logging
import multiprocessing
from spotify import Link
from spotify import Link, SpotifyError
from mopidy.backends.base import BaseLibraryController
from mopidy.backends.libspotify import ENCODING
from mopidy.backends.libspotify.translator import LibspotifyTranslator
from mopidy.models import Playlist
logger = logging.getLogger('mopidy.backends.libspotify.library')
@ -14,24 +15,41 @@ class LibspotifyLibraryController(BaseLibraryController):
return self.search(**query)
def lookup(self, uri):
spotify_track = Link.from_string(uri).as_track()
# TODO Block until metadata_updated callback is called. Before that the
# track will be unloaded, unless it's already in the stored playlists.
return LibspotifyTranslator.to_mopidy_track(spotify_track)
try:
spotify_track = Link.from_string(uri).as_track()
# TODO Block until metadata_updated callback is called. Before that
# the track will be unloaded, unless it's already in the stored
# playlists.
return LibspotifyTranslator.to_mopidy_track(spotify_track)
except SpotifyError as e:
logger.warning(u'Failed to lookup: %s', uri, e)
return None
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 stored playlists when the query is empty.
tracks = []
for playlist in self.backend.stored_playlists.playlists:
tracks += playlist.tracks
return Playlist(tracks=tracks)
spotify_query = []
for (field, values) in query.iteritems():
if field == u'track':
field = u'title'
if field == u'date':
field = u'year'
if not hasattr(values, '__iter__'):
values = [values]
for value in values:
if field == u'track':
field = u'title'
if field == u'any':
spotify_query.append(value)
elif field == u'year':
value = int(value.split('-')[0]) # Extract year
spotify_query.append(u'%s:%d' % (field, value))
else:
spotify_query.append(u'%s:"%s"' % (field, value))
spotify_query = u' '.join(spotify_query)

View File

@ -1,30 +1,17 @@
import logging
import multiprocessing
from spotify import Link, SpotifyError
from mopidy.backends.base import BasePlaybackController
from mopidy.utils.process import pickle_connection
logger = logging.getLogger('mopidy.backends.libspotify.playback')
class LibspotifyPlaybackController(BasePlaybackController):
def _set_output_state(self, state_name):
logger.debug(u'Setting output state to %s ...', state_name)
(my_end, other_end) = multiprocessing.Pipe()
self.backend.output_queue.put({
'command': 'set_state',
'state': state_name,
'reply_to': pickle_connection(other_end),
})
my_end.poll(None)
return my_end.recv()
def _pause(self):
return self._set_output_state('PAUSED')
return self.backend.output.set_state('PAUSED')
def _play(self, track):
self._set_output_state('READY')
self.backend.output.set_state('READY')
if self.state == self.PLAYING:
self.backend.spotify.session.play(0)
if track.uri is None:
@ -33,7 +20,7 @@ class LibspotifyPlaybackController(BasePlaybackController):
self.backend.spotify.session.load(
Link.from_string(track.uri).as_track())
self.backend.spotify.session.play(1)
self._set_output_state('PLAYING')
self.backend.output.set_state('PLAYING')
return True
except SpotifyError as e:
logger.warning('Play %s failed: %s', track.uri, e)
@ -43,12 +30,12 @@ class LibspotifyPlaybackController(BasePlaybackController):
return self._seek(self.time_position)
def _seek(self, time_position):
self._set_output_state('READY')
self.backend.output.set_state('READY')
self.backend.spotify.session.seek(time_position)
self._set_output_state('PLAYING')
self.backend.output.set_state('PLAYING')
return True
def _stop(self):
result = self._set_output_state('READY')
result = self.backend.output.set_state('READY')
self.backend.spotify.session.play(0)
return result

View File

@ -5,44 +5,42 @@ import threading
from spotify.manager import SpotifySessionManager
from mopidy import get_version, settings
from mopidy.models import Playlist
from mopidy.backends.libspotify.translator import LibspotifyTranslator
from mopidy.models import Playlist
from mopidy.utils.process import BaseThread
logger = logging.getLogger('mopidy.backends.libspotify.session_manager')
class LibspotifySessionManager(SpotifySessionManager, threading.Thread):
class LibspotifySessionManager(SpotifySessionManager, BaseThread):
cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE)
settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE)
appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key')
user_agent = 'Mopidy %s' % get_version()
def __init__(self, username, password, core_queue, output_queue):
def __init__(self, username, password, core_queue, output):
SpotifySessionManager.__init__(self, username, password)
threading.Thread.__init__(self, name='LibspotifySessionManagerThread')
# Run as a daemon thread, so Mopidy won't wait for this thread to exit
# before Mopidy exits.
self.daemon = True
self.core_queue = core_queue
self.output_queue = output_queue
BaseThread.__init__(self, core_queue)
self.name = 'LibspotifySMThread'
self.output = output
self.connected = threading.Event()
self.session = None
def run(self):
def run_inside_try(self):
self.connect()
def logged_in(self, session, error):
"""Callback used by pyspotify"""
logger.info('Logged in')
logger.info(u'Connected to Spotify')
self.session = session
self.connected.set()
def logged_out(self, session):
"""Callback used by pyspotify"""
logger.info('Logged out')
logger.info(u'Disconnected from Spotify')
def metadata_updated(self, session):
"""Callback used by pyspotify"""
logger.debug('Metadata updated, refreshing stored playlists')
logger.debug(u'Metadata updated, refreshing stored playlists')
playlists = []
for spotify_playlist in session.playlist_container():
playlists.append(
@ -54,52 +52,51 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread):
def connection_error(self, session, error):
"""Callback used by pyspotify"""
logger.error('Connection error: %s', error)
logger.error(u'Connection error: %s', error)
def message_to_user(self, session, message):
"""Callback used by pyspotify"""
logger.info(message.strip())
logger.debug(u'User message: %s', message.strip())
def notify_main_thread(self, session):
"""Callback used by pyspotify"""
logger.debug('Notify main thread')
logger.debug(u'notify_main_thread() called')
def music_delivery(self, session, frames, frame_size, num_frames,
sample_type, sample_rate, channels):
"""Callback used by pyspotify"""
# TODO Base caps_string on arguments
caps_string = """
assert sample_type == 0, u'Expects 16-bit signed integer samples'
capabilites = """
audio/x-raw-int,
endianness=(int)1234,
channels=(int)2,
channels=(int)%(channels)d,
width=(int)16,
depth=(int)16,
signed=True,
rate=(int)44100
"""
self.output_queue.put({
'command': 'deliver_data',
'caps': caps_string,
'data': bytes(frames),
})
signed=(boolean)true,
rate=(int)%(sample_rate)d
""" % {
'sample_rate': sample_rate,
'channels': channels,
}
self.output.deliver_data(capabilites, bytes(frames))
def play_token_lost(self, session):
"""Callback used by pyspotify"""
logger.debug('Play token lost')
logger.debug(u'Play token lost')
self.core_queue.put({'command': 'stop_playback'})
def log_message(self, session, data):
"""Callback used by pyspotify"""
logger.debug(data.strip())
logger.debug(u'System message: %s' % data.strip())
def end_of_track(self, session):
"""Callback used by pyspotify"""
logger.debug('End of data stream.')
self.output_queue.put({'command': 'end_of_data_stream'})
logger.debug(u'End of data stream reached')
self.output.end_of_data_stream()
def search(self, query, connection):
"""Search method used by Mopidy backend"""
def callback(results, userdata):
def callback(results, userdata=None):
# TODO Include results from results.albums(), etc. too
playlist = Playlist(tracks=[
LibspotifyTranslator.to_mopidy_track(t)

View File

@ -41,38 +41,24 @@ class LocalPlaybackController(BasePlaybackController):
super(LocalPlaybackController, self).__init__(backend)
self.stop()
def _send_recv(self, message):
(my_end, other_end) = multiprocessing.Pipe()
message.update({'reply_to': pickle_connection(other_end)})
self.backend.output_queue.put(message)
my_end.poll(None)
return my_end.recv()
def _send(self, message):
self.backend.output_queue.put(message)
def _set_state(self, state):
return self._send_recv({'command': 'set_state', 'state': state})
def _play(self, track):
return self._send_recv({'command': 'play_uri', 'uri': track.uri})
return self.backend.output.play_uri(track.uri)
def _stop(self):
return self._set_state('READY')
return self.backend.output.set_state('READY')
def _pause(self):
return self._set_state('PAUSED')
return self.backend.output.set_state('PAUSED')
def _resume(self):
return self._set_state('PLAYING')
return self.backend.output.set_state('PLAYING')
def _seek(self, time_position):
return self._send_recv({'command': 'set_position',
'position': time_position})
return self.backend.output.set_position(time_position)
@property
def time_position(self):
return self._send_recv({'command': 'get_position'})
return self.backend.output.get_position()
class LocalStoredPlaylistsController(BaseStoredPlaylistsController):

View File

@ -1,24 +1,26 @@
import logging
import multiprocessing
import optparse
import sys
from mopidy import get_version, settings
from mopidy import get_version, settings, OptionalDependencyError
from mopidy.utils import get_class
from mopidy.utils.log import setup_logging
from mopidy.utils.path import get_or_create_folder, get_or_create_file
from mopidy.utils.process import BaseProcess, unpickle_connection
from mopidy.utils.process import BaseThread
from mopidy.utils.settings import list_settings_optparse_callback
logger = logging.getLogger('mopidy.core')
class CoreProcess(BaseProcess):
class CoreProcess(BaseThread):
def __init__(self):
super(CoreProcess, self).__init__(name='CoreProcess')
self.core_queue = multiprocessing.Queue()
super(CoreProcess, self).__init__(self.core_queue)
self.name = 'CoreProcess'
self.options = self.parse_options()
self.output_queue = None
self.output = None
self.backend = None
self.frontend = None
self.frontends = []
def parse_options(self):
parser = optparse.OptionParser(version='Mopidy %s' % get_version())
@ -28,16 +30,15 @@ class CoreProcess(BaseProcess):
parser.add_option('-v', '--verbose',
action='store_const', const=2, dest='verbosity_level',
help='more output (debug level)')
parser.add_option('--dump',
action='store_true', dest='dump',
help='dump debug log to file')
parser.add_option('--save-debug-log',
action='store_true', dest='save_debug_log',
help='save debug log to "./mopidy.log"')
parser.add_option('--list-settings',
action='callback', callback=list_settings_optparse_callback,
help='list current settings')
return parser.parse_args()[0]
def run_inside_try(self):
logger.info(u'-- Starting Mopidy --')
self.setup()
while True:
message = self.core_queue.get()
@ -46,12 +47,14 @@ class CoreProcess(BaseProcess):
def setup(self):
self.setup_logging()
self.setup_settings()
self.output_queue = self.setup_output(self.core_queue)
self.backend = self.setup_backend(self.core_queue, self.output_queue)
self.frontend = self.setup_frontend(self.core_queue, self.backend)
self.output = self.setup_output(self.core_queue)
self.backend = self.setup_backend(self.core_queue, self.output)
self.frontends = self.setup_frontends(self.core_queue, self.backend)
def setup_logging(self):
setup_logging(self.options.verbosity_level, self.options.dump)
setup_logging(self.options.verbosity_level,
self.options.save_debug_log)
logger.info(u'-- Starting Mopidy --')
def setup_settings(self):
get_or_create_folder('~/.mopidy/')
@ -59,27 +62,32 @@ class CoreProcess(BaseProcess):
settings.validate()
def setup_output(self, core_queue):
output_queue = multiprocessing.Queue()
get_class(settings.OUTPUT)(core_queue, output_queue)
return output_queue
output = get_class(settings.OUTPUT)(core_queue)
output.start()
return output
def setup_backend(self, core_queue, output_queue):
return get_class(settings.BACKENDS[0])(core_queue, output_queue)
def setup_backend(self, core_queue, output):
return get_class(settings.BACKENDS[0])(core_queue, output)
def setup_frontend(self, core_queue, backend):
frontend = get_class(settings.FRONTENDS[0])()
frontend.start_server(core_queue)
frontend.create_dispatcher(backend)
return frontend
def setup_frontends(self, core_queue, backend):
frontends = []
for frontend_class_name in settings.FRONTENDS:
try:
frontend = get_class(frontend_class_name)(core_queue, backend)
frontend.start()
frontends.append(frontend)
except OptionalDependencyError as e:
logger.info(u'Disabled: %s (%s)', frontend_class_name, e)
return frontends
def process_message(self, message):
if message.get('to') == 'output':
self.output_queue.put(message)
elif message['command'] == 'mpd_request':
response = self.frontend.dispatcher.handle_request(
message['request'])
connection = unpickle_connection(message['reply_to'])
connection.send(response)
if message.get('to') == 'core':
self.process_message_to_core(message)
elif message.get('to') == 'output':
self.output.process_message(message)
elif message.get('to') == 'frontend':
for frontend in self.frontends:
frontend.process_message(message)
elif message['command'] == 'end_of_track':
self.backend.playback.on_end_of_track()
elif message['command'] == 'stop_playback':
@ -88,3 +96,12 @@ class CoreProcess(BaseProcess):
self.backend.stored_playlists.playlists = message['playlists']
else:
logger.warning(u'Cannot handle message: %s', message)
def process_message_to_core(self, message):
assert message['to'] == 'core', u'Message recipient must be "core".'
if message['command'] == 'exit':
if message['reason'] is not None:
logger.info(u'Exiting (%s)', message['reason'])
sys.exit(message['status'])
else:
logger.warning(u'Cannot handle message: %s', message)

30
mopidy/frontends/base.py Normal file
View File

@ -0,0 +1,30 @@
class BaseFrontend(object):
"""
Base class for frontends.
:param core_queue: queue for messaging the core
:type core_queue: :class:`multiprocessing.Queue`
:param backend: the backend
:type backend: :class:`mopidy.backends.base.BaseBackend`
"""
def __init__(self, core_queue, backend):
self.core_queue = core_queue
self.backend = backend
def start(self):
"""Start the frontend."""
pass
def destroy(self):
"""Destroy the frontend."""
pass
def process_message(self, message):
"""
Process messages for the frontend.
:param message: the message
:type message: dict
"""
raise NotImplementedError

139
mopidy/frontends/lastfm.py Normal file
View File

@ -0,0 +1,139 @@
import logging
import multiprocessing
import socket
import time
try:
import pylast
except ImportError as e:
from mopidy import OptionalDependencyError
raise OptionalDependencyError(e)
from mopidy import get_version, settings, SettingsError
from mopidy.frontends.base import BaseFrontend
from mopidy.utils.process import BaseThread
logger = logging.getLogger('mopidy.frontends.lastfm')
CLIENT_ID = u'mop'
CLIENT_VERSION = get_version()
# pylast raises UnicodeEncodeError on conversion from unicode objects to
# ascii-encoded bytestrings, so we explicitly encode as utf-8 before passing
# strings to pylast.
ENCODING = u'utf-8'
class LastfmFrontend(BaseFrontend):
"""
Frontend which scrobbles the music you play to your `Last.fm
<http://www.last.fm>`_ profile.
.. note::
This frontend requires a free user account at Last.fm.
**Dependencies:**
- `pylast <http://code.google.com/p/pylast/>`_ >= 0.4.30
**Settings:**
- :attr:`mopidy.settings.LASTFM_USERNAME`
- :attr:`mopidy.settings.LASTFM_PASSWORD`
"""
def __init__(self, *args, **kwargs):
super(LastfmFrontend, self).__init__(*args, **kwargs)
(self.connection, other_end) = multiprocessing.Pipe()
self.thread = LastfmFrontendThread(self.core_queue, other_end)
def start(self):
self.thread.start()
def destroy(self):
self.thread.destroy()
def process_message(self, message):
self.connection.send(message)
class LastfmFrontendThread(BaseThread):
def __init__(self, core_queue, connection):
super(LastfmFrontendThread, self).__init__(core_queue)
self.name = u'LastfmFrontendThread'
self.connection = connection
self.lastfm = None
self.scrobbler = None
self.last_start_time = None
def run_inside_try(self):
self.setup()
while True:
self.connection.poll(None)
message = self.connection.recv()
self.process_message(message)
def setup(self):
try:
username = settings.LASTFM_USERNAME
password_hash = pylast.md5(settings.LASTFM_PASSWORD)
self.lastfm = pylast.get_lastfm_network(
username=username, password_hash=password_hash)
self.scrobbler = self.lastfm.get_scrobbler(
CLIENT_ID, CLIENT_VERSION)
logger.info(u'Connected to Last.fm')
except SettingsError as e:
logger.info(u'Last.fm scrobbler not started')
logger.debug(u'Last.fm settings error: %s', e)
except (pylast.WSError, socket.error) as e:
logger.error(u'Last.fm connection error: %s', e)
def process_message(self, message):
if message['command'] == 'started_playing':
self.started_playing(message['track'])
elif message['command'] == 'stopped_playing':
self.stopped_playing(message['track'], message['stop_position'])
else:
pass # Ignore commands for other frontends
def started_playing(self, track):
artists = ', '.join([a.name for a in track.artists])
duration = track.length // 1000
self.last_start_time = int(time.time())
logger.debug(u'Now playing track: %s - %s', artists, track.name)
try:
self.scrobbler.report_now_playing(
artists.encode(ENCODING),
track.name.encode(ENCODING),
album=track.album.name.encode(ENCODING),
duration=duration,
track_number=track.track_no)
except (pylast.ScrobblingError, socket.error) as e:
logger.warning(u'Last.fm now playing error: %s', e)
def stopped_playing(self, track, stop_position):
artists = ', '.join([a.name for a in track.artists])
duration = track.length // 1000
stop_position = stop_position // 1000
if duration < 30:
logger.debug(u'Track too short to scrobble. (30s)')
return
if stop_position < duration // 2 and stop_position < 240:
logger.debug(
u'Track not played long enough to scrobble. (50% or 240s)')
return
if self.last_start_time is None:
self.last_start_time = int(time.time()) - duration
logger.debug(u'Scrobbling track: %s - %s', artists, track.name)
try:
self.scrobbler.scrobble(
artists.encode(ENCODING),
track.name.encode(ENCODING),
time_started=self.last_start_time,
source=pylast.SCROBBLE_SOURCE_USER,
mode=pylast.SCROBBLE_MODE_PLAYED,
duration=duration,
album=track.album.name.encode(ENCODING),
track_number=track.track_no)
except (pylast.ScrobblingError, socket.error) as e:
logger.warning(u'Last.fm scrobbling error: %s', e)

View File

@ -1,7 +1,13 @@
from mopidy.frontends.mpd.dispatcher import MpdDispatcher
from mopidy.frontends.mpd.process import MpdProcess
import logging
class MpdFrontend(object):
from mopidy.frontends.base import BaseFrontend
from mopidy.frontends.mpd.dispatcher import MpdDispatcher
from mopidy.frontends.mpd.thread import MpdThread
from mopidy.utils.process import unpickle_connection
logger = logging.getLogger('mopidy.frontends.mpd')
class MpdFrontend(BaseFrontend):
"""
The MPD frontend.
@ -11,27 +17,32 @@ class MpdFrontend(object):
- :attr:`mopidy.settings.MPD_SERVER_PORT`
"""
def __init__(self):
self.process = None
self.dispatcher = None
def __init__(self, *args, **kwargs):
super(MpdFrontend, self).__init__(*args, **kwargs)
self.thread = None
self.dispatcher = MpdDispatcher(self.backend)
def start_server(self, core_queue):
"""
Starts the MPD server.
def start(self):
"""Starts the MPD server."""
self.thread = MpdThread(self.core_queue)
self.thread.start()
:param core_queue: the core queue
:type core_queue: :class:`multiprocessing.Queue`
"""
self.process = MpdProcess(core_queue)
self.process.start()
def destroy(self):
"""Destroys the MPD server."""
self.thread.destroy()
def create_dispatcher(self, backend):
def process_message(self, message):
"""
Creates a dispatcher for MPD requests.
Processes messages with the MPD frontend as destination.
:param backend: the backend
:type backend: :class:`mopidy.backends.base.BaseBackend`
:rtype: :class:`mopidy.frontends.mpd.dispatcher.MpdDispatcher`
:param message: the message
:type message: dict
"""
self.dispatcher = MpdDispatcher(backend)
return self.dispatcher
assert message['to'] == 'frontend', \
u'Message recipient must be "frontend".'
if message['command'] == 'mpd_request':
response = self.dispatcher.handle_request(message['request'])
connection = unpickle_connection(message['reply_to'])
connection.send(response)
else:
pass # Ignore messages for other frontends

View File

@ -1,18 +0,0 @@
import asyncore
import logging
from mopidy.frontends.mpd.server import MpdServer
from mopidy.utils.process import BaseProcess
logger = logging.getLogger('mopidy.frontends.mpd.process')
class MpdProcess(BaseProcess):
def __init__(self, core_queue):
super(MpdProcess, self).__init__(name='MpdProcess')
self.core_queue = core_queue
def run_inside_try(self):
logger.debug(u'Starting MPD server process')
server = MpdServer(self.core_queue)
server.start()
asyncore.loop()

View File

@ -11,14 +11,19 @@ def add(frontend, uri):
Adds the file ``URI`` to the playlist (directories add recursively).
``URI`` can also be a single file.
*Clarifications:*
- ``add ""`` should add all tracks in the library to the current playlist.
"""
if not uri:
return
for handler_prefix in frontend.backend.uri_handlers:
if uri.startswith(handler_prefix):
track = frontend.backend.library.lookup(uri)
if track is not None:
frontend.backend.current_playlist.add(track)
return
raise MpdNoExistError(
u'directory or file not found', command=u'add')
@ -36,7 +41,13 @@ def addid(frontend, uri, songpos=None):
addid "foo.mp3"
Id: 999
OK
*Clarifications:*
- ``addid ""`` should return an error.
"""
if not uri:
raise MpdNoExistError(u'No such song', command=u'addid')
if songpos is not None:
songpos = int(songpos)
track = frontend.backend.library.lookup(uri)
@ -44,7 +55,8 @@ def addid(frontend, uri, songpos=None):
raise MpdNoExistError(u'No such song', command=u'addid')
if songpos and songpos > len(frontend.backend.current_playlist.tracks):
raise MpdArgError(u'Bad song index', command=u'addid')
cp_track = frontend.backend.current_playlist.add(track, at_position=songpos)
cp_track = frontend.backend.current_playlist.add(track,
at_position=songpos)
return ('Id', cp_track[0])
@handle_pattern(r'^delete "(?P<start>\d+):(?P<end>\d+)*"$')

View File

@ -1,7 +1,8 @@
import re
import shlex
from mopidy.frontends.mpd.protocol import handle_pattern, stored_playlists
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented
def _build_query(mpd_query):
"""
@ -81,13 +82,9 @@ def findadd(frontend, query):
# TODO Add result to current playlist
#result = frontend.find(query)
@handle_pattern(r'^list (?P<field>[Aa]rtist)$')
@handle_pattern(r'^list "(?P<field>[Aa]rtist)"$')
@handle_pattern(r'^list (?P<field>album( artist)?)'
'( "(?P<artist>[^"]+)")*$')
@handle_pattern(r'^list "(?P<field>album(" "artist)?)"'
'( "(?P<artist>[^"]+)")*$')
def list_(frontend, field, artist=None):
@handle_pattern(r'^list "?(?P<field>([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?'
'( (?P<mpd_query>.*))?$')
def list_(frontend, field, mpd_query=None):
"""
*musicpd.org, music database section:*
@ -101,22 +98,70 @@ def list_(frontend, field, artist=None):
This filters the result list by an artist.
*Clarifications:*
The musicpd.org documentation for ``list`` is far from complete. The
command also supports the following variant:
``list {TYPE} {QUERY}``
Where ``QUERY`` applies to all ``TYPE``. ``QUERY`` is one or more pairs
of a field name and a value. If the ``QUERY`` consists of more than one
pair, the pairs are AND-ed together to find the result. Examples of
valid queries and what they should return:
``list "artist" "artist" "ABBA"``
List artists where the artist name is "ABBA". Response::
Artist: ABBA
OK
``list "album" "artist" "ABBA"``
Lists albums where the artist name is "ABBA". Response::
Album: More ABBA Gold: More ABBA Hits
Album: Absolute More Christmas
Album: Gold: Greatest Hits
OK
``list "artist" "album" "Gold: Greatest Hits"``
Lists artists where the album name is "Gold: Greatest Hits".
Response::
Artist: ABBA
OK
``list "artist" "artist" "ABBA" "artist" "TLC"``
Lists artists where the artist name is "ABBA" *and* "TLC". Should
never match anything. Response::
OK
``list "date" "artist" "ABBA"``
Lists dates where artist name is "ABBA". Response::
Date:
Date: 1992
Date: 1993
OK
``list "date" "artist" "ABBA" "album" "Gold: Greatest Hits"``
Lists dates where artist name is "ABBA" and album name is "Gold:
Greatest Hits". Response::
Date: 1992
OK
``list "genre" "artist" "The Rolling Stones"``
Lists genres where artist name is "The Rolling Stones". Response::
Genre:
Genre: Rock
OK
*GMPC:*
- does not add quotes around the field argument.
- asks for "list artist" to get available artists and will not query
for artist/album information if this is not retrived
- asks for multiple fields, i.e.::
list album artist "an artist name"
returns the albums available for the asked artist::
list album artist "Tiesto"
Album: Radio Trance Vol 4-Promo-CD
Album: Ur A Tear in the Open CDR
Album: Simple Trance 2004 Step One
Album: In Concert 05-10-2003
*ncmpc:*
@ -124,31 +169,70 @@ def list_(frontend, field, artist=None):
- capitalizes the field argument.
"""
field = field.lower()
query = _list_build_query(field, mpd_query)
if field == u'artist':
return _list_artist(frontend)
elif field == u'album artist':
return _list_album_artist(frontend, artist)
# TODO More to implement
return _list_artist(frontend, query)
elif field == u'album':
return _list_album(frontend, query)
elif field == u'date':
return _list_date(frontend, query)
elif field == u'genre':
pass # TODO We don't have genre in our internal data structures yet
def _list_artist(frontend):
"""
Since we don't know exactly all available artists, we respond with
the artists we know for sure, which is all artists in our stored playlists.
"""
def _list_build_query(field, mpd_query):
"""Converts a ``list`` query to a Mopidy query."""
if mpd_query is None:
return {}
# shlex does not seem to be friends with unicode objects
tokens = shlex.split(mpd_query.encode('utf-8'))
tokens = [t.decode('utf-8') for t in tokens]
if len(tokens) == 1:
if field == u'album':
return {'artist': [tokens[0]]}
else:
raise MpdArgError(
u'should be "Album" for 3 arguments', command=u'list')
elif len(tokens) % 2 == 0:
query = {}
while tokens:
key = tokens[0].lower()
key = str(key) # Needed for kwargs keys on OS X and Windows
value = tokens[1]
tokens = tokens[2:]
if key not in (u'artist', u'album', u'date', u'genre'):
raise MpdArgError(u'not able to parse args', command=u'list')
if key in query:
query[key].append(value)
else:
query[key] = [value]
return query
else:
raise MpdArgError(u'not able to parse args', command=u'list')
def _list_artist(frontend, query):
artists = set()
for playlist in frontend.backend.stored_playlists.playlists:
for track in playlist.tracks:
for artist in track.artists:
artists.add((u'Artist', artist.name))
playlist = frontend.backend.library.find_exact(**query)
for track in playlist.tracks:
for artist in track.artists:
artists.add((u'Artist', artist.name))
return artists
def _list_album_artist(frontend, artist):
playlist = frontend.backend.library.find_exact(artist=[artist])
def _list_album(frontend, query):
albums = set()
playlist = frontend.backend.library.find_exact(**query)
for track in playlist.tracks:
albums.add((u'Album', track.album.name))
if track.album is not None:
albums.add((u'Album', track.album.name))
return albums
def _list_date(frontend, query):
dates = set()
playlist = frontend.backend.library.find_exact(**query)
for track in playlist.tracks:
if track.date is not None:
dates.add((u'Date', track.date.strftime('%Y-%m-%d')))
return dates
@handle_pattern(r'^listall "(?P<uri>[^"]+)"')
def listall(frontend, uri):
"""

View File

@ -138,6 +138,10 @@ def playid(frontend, cpid):
at the first track.
"""
cpid = int(cpid)
paused = (frontend.backend.playback.state ==
frontend.backend.playback.PAUSED)
if cpid == -1 and paused:
return frontend.backend.playback.resume()
try:
if cpid == -1:
cp_track = _get_cp_track_for_play_minus_one(frontend)

View File

@ -24,19 +24,23 @@ class MpdServer(asyncore.dispatcher):
try:
if socket.has_ipv6:
self.create_socket(socket.AF_INET6, socket.SOCK_STREAM)
# Explicitly configure socket to work for both IPv4 and IPv6
self.socket.setsockopt(
socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
else:
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
self.set_reuse_addr()
hostname = self._format_hostname(settings.MPD_SERVER_HOSTNAME)
port = settings.MPD_SERVER_PORT
logger.debug(u'Binding to [%s]:%s', hostname, port)
logger.debug(u'MPD server is binding to [%s]:%s', hostname, port)
self.bind((hostname, port))
self.listen(1)
logger.info(u'MPD server running at [%s]:%s',
self._format_hostname(settings.MPD_SERVER_HOSTNAME),
settings.MPD_SERVER_PORT)
except IOError, e:
sys.exit('MPD server startup failed: %s' % e)
logger.error('MPD server startup failed: %s' % e)
sys.exit(1)
def handle_accept(self):
"""Handle new client connection."""

View File

@ -48,6 +48,7 @@ class MpdSession(asynchat.async_chat):
"""Handle request by sending it to the MPD frontend."""
my_end, other_end = multiprocessing.Pipe()
self.core_queue.put({
'to': 'frontend',
'command': 'mpd_request',
'request': request,
'reply_to': pickle_connection(other_end),

View File

@ -0,0 +1,18 @@
import asyncore
import logging
from mopidy.frontends.mpd.server import MpdServer
from mopidy.utils.process import BaseThread
logger = logging.getLogger('mopidy.frontends.mpd.thread')
class MpdThread(BaseThread):
def __init__(self, core_queue):
super(MpdThread, self).__init__(core_queue)
self.name = u'MpdThread'
def run_inside_try(self):
logger.debug(u'Starting MPD server thread')
server = MpdServer(self.core_queue)
server.start()
asyncore.loop()

View File

@ -1,7 +1,4 @@
import multiprocessing
from mopidy.mixers import BaseMixer
from mopidy.utils.process import pickle_connection
class GStreamerSoftwareMixer(BaseMixer):
"""Mixer which uses GStreamer to control volume in software."""
@ -10,16 +7,7 @@ class GStreamerSoftwareMixer(BaseMixer):
super(GStreamerSoftwareMixer, self).__init__(*args, **kwargs)
def _get_volume(self):
my_end, other_end = multiprocessing.Pipe()
self.backend.output_queue.put({
'command': 'get_volume',
'reply_to': pickle_connection(other_end),
})
my_end.poll(None)
return my_end.recv()
return self.backend.output.get_volume()
def _set_volume(self, volume):
self.backend.output_queue.put({
'command': 'set_volume',
'volume': volume,
})
self.backend.output.set_volume(volume)

View File

@ -4,7 +4,7 @@ from multiprocessing import Pipe
from mopidy import settings
from mopidy.mixers import BaseMixer
from mopidy.utils.process import BaseProcess
from mopidy.utils.process import BaseThread
logger = logging.getLogger('mopidy.mixers.nad')
@ -50,7 +50,7 @@ class NadMixer(BaseMixer):
self._pipe.send({'command': 'set_volume', 'volume': volume})
class NadTalker(BaseProcess):
class NadTalker(BaseThread):
"""
Independent process which does the communication with the NAD device.

88
mopidy/outputs/base.py Normal file
View File

@ -0,0 +1,88 @@
class BaseOutput(object):
"""
Base class for audio outputs.
"""
def __init__(self, core_queue):
self.core_queue = core_queue
def start(self):
"""Start the output."""
pass
def destroy(self):
"""Destroy the output."""
pass
def process_message(self, message):
"""Process messages with the output as destination."""
raise NotImplementedError
def play_uri(self, uri):
"""
Play URI.
:param uri: the URI to play
:type uri: string
:rtype: :class:`True` if successful, else :class:`False`
"""
raise NotImplementedError
def deliver_data(self, capabilities, data):
"""
Deliver audio data to be played.
:param capabilities: a GStreamer capabilities string
:type capabilities: string
"""
raise NotImplementedError
def end_of_data_stream(self):
"""Signal that the last audio data has been delivered."""
raise NotImplementedError
def get_position(self):
"""
Get position in milliseconds.
:rtype: int
"""
raise NotImplementedError
def set_position(self, position):
"""
Set position in milliseconds.
:param position: the position in milliseconds
:type volume: int
:rtype: :class:`True` if successful, else :class:`False`
"""
raise NotImplementedError
def set_state(self, state):
"""
Set playback state.
:param state: the state
:type state: string
:rtype: :class:`True` if successful, else :class:`False`
"""
raise NotImplementedError
def get_volume(self):
"""
Get volume level for software mixer.
:rtype: int in range [0..100]
"""
raise NotImplementedError
def set_volume(self, volume):
"""
Set volume level for software mixer.
:param volume: the volume in the range [0..100]
:type volume: int
:rtype: :class:`True` if successful, else :class:`False`
"""
raise NotImplementedError

76
mopidy/outputs/dummy.py Normal file
View File

@ -0,0 +1,76 @@
from mopidy.outputs.base import BaseOutput
class DummyOutput(BaseOutput):
"""
Audio output used for testing.
"""
#: For testing. :class:`True` if :meth:`start` has been called.
start_called = False
#: For testing. :class:`True` if :meth:`destroy` has been called.
destroy_called = False
#: For testing. Contains all messages :meth:`process_message` has received.
messages = []
#: For testing. Contains the last URI passed to :meth:`play_uri`.
uri = None
#: For testing. Contains the last capabilities passed to
#: :meth:`deliver_data`.
capabilities = None
#: For testing. Contains the last data passed to :meth:`deliver_data`.
data = None
#: For testing. :class:`True` if :meth:`end_of_data_stream` has been
#: called.
end_of_data_stream_called = False
#: For testing. Contains the current position.
position = 0
#: For testing. Contains the current state.
state = 'NULL'
#: For testing. Contains the current volume.
volume = 100
def start(self):
self.start_called = True
def destroy(self):
self.destroy_called = True
def process_message(self, message):
self.messages.append(message)
def play_uri(self, uri):
self.uri = uri
return True
def deliver_data(self, capabilities, data):
self.capabilities = capabilities
self.data = data
def end_of_data_stream(self):
self.end_of_data_stream_called = True
def get_position(self):
return self.position
def set_position(self, position):
self.position = position
return True
def set_state(self, state):
self.state = state
return True
def get_volume(self):
return self.volume
def set_volume(self, volume):
self.volume = volume
return True

View File

@ -6,36 +6,100 @@ pygst.require('0.10')
import gst
import logging
import threading
import multiprocessing
from mopidy import settings
from mopidy.utils.process import BaseProcess, unpickle_connection
from mopidy.outputs.base import BaseOutput
from mopidy.utils.process import (BaseThread, pickle_connection,
unpickle_connection)
logger = logging.getLogger('mopidy.outputs.gstreamer')
class GStreamerOutput(object):
class GStreamerOutput(BaseOutput):
"""
Audio output through GStreamer.
Starts the :class:`GStreamerProcess`.
Starts :class:`GStreamerMessagesThread` and :class:`GStreamerPlayerThread`.
**Settings:**
- :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK`
"""
def __init__(self, core_queue, output_queue):
self.process = GStreamerProcess(core_queue, output_queue)
self.process.start()
def __init__(self, *args, **kwargs):
super(GStreamerOutput, self).__init__(*args, **kwargs)
# Start a helper thread that can run the gobject.MainLoop
self.messages_thread = GStreamerMessagesThread(self.core_queue)
# Start a helper thread that can process the output_queue
self.output_queue = multiprocessing.Queue()
self.player_thread = GStreamerPlayerThread(self.core_queue,
self.output_queue)
def start(self):
self.messages_thread.start()
self.player_thread.start()
def destroy(self):
self.process.terminate()
self.messages_thread.destroy()
self.player_thread.destroy()
class GStreamerMessagesThread(threading.Thread):
def run(self):
def process_message(self, message):
assert message['to'] == 'output', \
u'Message recipient must be "output".'
self.output_queue.put(message)
def _send_recv(self, message):
(my_end, other_end) = multiprocessing.Pipe()
message['to'] = 'output'
message['reply_to'] = pickle_connection(other_end)
self.process_message(message)
my_end.poll(None)
return my_end.recv()
def _send(self, message):
message['to'] = 'output'
self.process_message(message)
def play_uri(self, uri):
return self._send_recv({'command': 'play_uri', 'uri': uri})
def deliver_data(self, capabilities, data):
return self._send({
'command': 'deliver_data',
'caps': capabilities,
'data': data,
})
def end_of_data_stream(self):
return self._send({'command': 'end_of_data_stream'})
def get_position(self):
return self._send_recv({'command': 'get_position'})
def set_position(self, position):
return self._send_recv({'command': 'set_position', 'position': position})
def set_state(self, state):
return self._send_recv({'command': 'set_state', 'state': state})
def get_volume(self):
return self._send_recv({'command': 'get_volume'})
def set_volume(self, volume):
return self._send_recv({'command': 'set_volume', 'volume': volume})
class GStreamerMessagesThread(BaseThread):
def __init__(self, core_queue):
super(GStreamerMessagesThread, self).__init__(core_queue)
self.name = u'GStreamerMessagesThread'
def run_inside_try(self):
gobject.MainLoop().run()
class GStreamerProcess(BaseProcess):
class GStreamerPlayerThread(BaseThread):
"""
A process for all work related to GStreamer.
@ -48,8 +112,8 @@ class GStreamerProcess(BaseProcess):
"""
def __init__(self, core_queue, output_queue):
super(GStreamerProcess, self).__init__(name='GStreamerProcess')
self.core_queue = core_queue
super(GStreamerPlayerThread, self).__init__(core_queue)
self.name = u'GStreamerPlayerThread'
self.output_queue = output_queue
self.gst_pipeline = None
@ -62,11 +126,6 @@ class GStreamerProcess(BaseProcess):
def setup(self):
logger.debug(u'Setting up GStreamer pipeline')
# Start a helper thread that can run the gobject.MainLoop
messages_thread = GStreamerMessagesThread()
messages_thread.daemon = True
messages_thread.start()
self.gst_pipeline = gst.parse_launch(' ! '.join([
'audioconvert name=convert',
'volume name=volume',
@ -80,7 +139,16 @@ class GStreamerProcess(BaseProcess):
uri_bin.connect('pad-added', self.process_new_pad, pad)
self.gst_pipeline.add(uri_bin)
else:
app_src = gst.element_factory_make('appsrc', 'src')
app_src = gst.element_factory_make('appsrc', 'appsrc')
app_src_caps = gst.Caps("""
audio/x-raw-int,
endianness=(int)1234,
channels=(int)2,
width=(int)16,
depth=(int)16,
signed=(boolean)true,
rate=(int)44100""")
app_src.set_property('caps', app_src_caps)
self.gst_pipeline.add(app_src)
app_src.get_pad('src').link(pad)
@ -111,7 +179,9 @@ class GStreamerProcess(BaseProcess):
connection = unpickle_connection(message['reply_to'])
connection.send(volume)
elif message['command'] == 'set_volume':
self.set_volume(message['volume'])
response = self.set_volume(message['volume'])
connection = unpickle_connection(message['reply_to'])
connection.send(response)
elif message['command'] == 'set_position':
response = self.set_position(message['position'])
connection = unpickle_connection(message['reply_to'])
@ -144,12 +214,12 @@ class GStreamerProcess(BaseProcess):
def deliver_data(self, caps_string, data):
"""Deliver audio data to be played"""
data_src = self.gst_pipeline.get_by_name('src')
app_src = self.gst_pipeline.get_by_name('appsrc')
caps = gst.caps_from_string(caps_string)
buffer_ = gst.Buffer(buffer(data))
buffer_.set_caps(caps)
data_src.set_property('caps', caps)
data_src.emit('push-buffer', buffer_)
app_src.set_property('caps', caps)
app_src.emit('push-buffer', buffer_)
def end_of_data_stream(self):
"""
@ -158,7 +228,7 @@ class GStreamerProcess(BaseProcess):
We will get a GStreamer message when the stream playback reaches the
token, and can then do any end-of-stream related tasks.
"""
self.gst_pipeline.get_by_name('src').emit('end-of-stream')
self.gst_pipeline.get_by_name('appsrc').emit('end-of-stream')
def set_state(self, state_name):
"""
@ -195,6 +265,7 @@ class GStreamerProcess(BaseProcess):
"""Set volume in range [0..100]"""
gst_volume = self.gst_pipeline.get_by_name('volume')
gst_volume.set_property('volume', volume / 100.0)
return True
def set_position(self, position):
self.gst_pipeline.get_state() # block until state changes are done

View File

@ -20,36 +20,39 @@ BACKENDS = (
u'mopidy.backends.libspotify.LibspotifyBackend',
)
#: The log format used on the console. See
#: http://docs.python.org/library/logging.html#formatter-objects for details on
#: the format.
CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \
#: The log format used for informational logging.
#:
#: See http://docs.python.org/library/logging.html#formatter-objects for
#: details on the format.
CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s'
#: The log format used for debug logging.
#:
#: See http://docs.python.org/library/logging.html#formatter-objects for
#: details on the format.
DEBUG_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \
' [%(process)d:%(threadName)s] %(name)s\n %(message)s'
#: The log format used for dump logs.
#:
#: Default::
#:
#: DUMP_LOG_FILENAME = CONSOLE_LOG_FORMAT
DUMP_LOG_FORMAT = CONSOLE_LOG_FORMAT
#: The file to dump debug log data to when Mopidy is run with the
#: :option:`--dump` option.
#: :option:`--save-debug-log` option.
#:
#: Default::
#:
#: DUMP_LOG_FILENAME = u'dump.log'
DUMP_LOG_FILENAME = u'dump.log'
#: DEBUG_LOG_FILENAME = u'mopidy.log'
DEBUG_LOG_FILENAME = u'mopidy.log'
#: List of server frontends to use.
#:
#: Default::
#:
#: FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',)
#:
#: .. note::
#: Currently only the first frontend in the list is used.
FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',)
#: FRONTENDS = (
#: u'mopidy.frontends.mpd.MpdFrontend',
#: u'mopidy.frontends.lastfm.LastfmFrontend',
#: )
FRONTENDS = (
u'mopidy.frontends.mpd.MpdFrontend',
u'mopidy.frontends.lastfm.LastfmFrontend',
)
#: Which GStreamer audio sink to use in :mod:`mopidy.outputs.gstreamer`.
#:
@ -58,6 +61,16 @@ FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',)
#: GSTREAMER_AUDIO_SINK = u'autoaudiosink'
GSTREAMER_AUDIO_SINK = u'autoaudiosink'
#: Your `Last.fm <http://www.last.fm/>`_ username.
#:
#: Used by :mod:`mopidy.frontends.lastfm`.
LASTFM_USERNAME = u''
#: Your `Last.fm <http://www.last.fm/>`_ password.
#:
#: Used by :mod:`mopidy.frontends.lastfm`.
LASTFM_PASSWORD = u''
#: Path to folder with local music.
#:
#: Used by :mod:`mopidy.backends.local`.

View File

@ -3,27 +3,40 @@ import logging.handlers
from mopidy import settings
def setup_logging(verbosity_level, dump):
def setup_logging(verbosity_level, save_debug_log):
setup_root_logger()
setup_console_logging(verbosity_level)
if dump:
setup_dump_logging()
if save_debug_log:
setup_debug_logging_to_file()
def setup_root_logger():
root = logging.getLogger('')
root.setLevel(logging.DEBUG)
def setup_console_logging(verbosity_level):
if verbosity_level == 0:
level = logging.WARNING
log_level = logging.WARNING
log_format = settings.CONSOLE_LOG_FORMAT
elif verbosity_level == 2:
level = logging.DEBUG
log_level = logging.DEBUG
log_format = settings.DEBUG_LOG_FORMAT
else:
level = logging.INFO
logging.basicConfig(format=settings.CONSOLE_LOG_FORMAT, level=level)
def setup_dump_logging():
root = logging.getLogger('')
root.setLevel(logging.DEBUG)
formatter = logging.Formatter(settings.DUMP_LOG_FORMAT)
handler = logging.handlers.RotatingFileHandler(
settings.DUMP_LOG_FILENAME, maxBytes=102400, backupCount=3)
log_level = logging.INFO
log_format = settings.CONSOLE_LOG_FORMAT
formatter = logging.Formatter(log_format)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
handler.setLevel(log_level)
root = logging.getLogger('')
root.addHandler(handler)
def setup_debug_logging_to_file():
formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT)
handler = logging.handlers.RotatingFileHandler(
settings.DEBUG_LOG_FILENAME, maxBytes=10485760, backupCount=3)
handler.setFormatter(formatter)
handler.setLevel(logging.DEBUG)
root = logging.getLogger('')
root.addHandler(handler)
def indent(string, places=4, linebreak='\n'):

View File

@ -1,5 +1,6 @@
import logging
import multiprocessing
import multiprocessing.dummy
from multiprocessing.reduction import reduce_connection
import pickle
import sys
@ -18,22 +19,70 @@ def unpickle_connection(pickled_connection):
class BaseProcess(multiprocessing.Process):
def __init__(self, core_queue):
super(BaseProcess, self).__init__()
self.core_queue = core_queue
def run(self):
logger.debug(u'%s: Starting process', self.name)
try:
self.run_inside_try()
except KeyboardInterrupt:
logger.info(u'%s: Interrupted by user', self.name)
sys.exit(0)
logger.info(u'Interrupted by user')
self.exit(0, u'Interrupted by user')
except SettingsError as e:
logger.error(e.message)
sys.exit(1)
self.exit(1, u'Settings error')
except ImportError as e:
logger.error(e)
sys.exit(1)
self.exit(2, u'Import error')
except Exception as e:
logger.exception(e)
raise e
self.exit(3, u'Unknown error')
def run_inside_try(self):
raise NotImplementedError
def destroy(self):
self.terminate()
def exit(self, status=0, reason=None):
self.core_queue.put({'to': 'core', 'command': 'exit',
'status': status, 'reason': reason})
self.destroy()
class BaseThread(multiprocessing.dummy.Process):
def __init__(self, core_queue):
super(BaseThread, self).__init__()
self.core_queue = core_queue
# No thread should block process from exiting
self.daemon = True
def run(self):
logger.debug(u'%s: Starting thread', self.name)
try:
self.run_inside_try()
except KeyboardInterrupt:
logger.info(u'Interrupted by user')
self.exit(0, u'Interrupted by user')
except SettingsError as e:
logger.error(e.message)
self.exit(1, u'Settings error')
except ImportError as e:
logger.error(e)
self.exit(2, u'Import error')
except Exception as e:
logger.exception(e)
self.exit(3, u'Unknown error')
def run_inside_try(self):
raise NotImplementedError
def destroy(self):
pass
def exit(self, status=0, reason=None):
self.core_queue.put({'to': 'core', 'command': 'exit',
'status': status, 'reason': reason})
self.destroy()

View File

@ -15,6 +15,7 @@ class SettingsProxy(object):
self.default = self._get_settings_dict_from_module(
default_settings_module)
self.local = self._get_local_settings()
self.runtime = {}
def _get_local_settings(self):
dotdir = os.path.expanduser(u'~/.mopidy/')
@ -37,6 +38,7 @@ class SettingsProxy(object):
def current(self):
current = copy(self.default)
current.update(self.local)
current.update(self.runtime)
return current
def __getattr__(self, attr):
@ -49,6 +51,12 @@ class SettingsProxy(object):
raise SettingsError(u'Setting "%s" is empty.' % attr)
return value
def __setattr__(self, attr, value):
if self._is_setting(attr):
self.runtime[attr] = value
else:
super(SettingsProxy, self).__setattr__(attr, value)
def validate(self):
if self.get_errors():
logger.error(u'Settings validation errors: %s',
@ -81,6 +89,8 @@ def validate_settings(defaults, settings):
errors = {}
changed = {
'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME',
'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT',
'FRONTEND': 'FRONTENDS',
'SERVER': None,
'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME',
@ -122,7 +132,7 @@ def list_settings_optparse_callback(*args):
lines = []
for (key, value) in sorted(settings.current.iteritems()):
default_value = settings.default.get(key)
if key.endswith('PASSWORD'):
if key.endswith('PASSWORD') and len(value):
value = u'********'
lines.append(u'%s:' % key)
lines.append(u' Value: %s' % repr(value))

1
requirements-lastfm.txt Normal file
View File

@ -0,0 +1 @@
pylast >= 0.4.30

View File

@ -4,6 +4,7 @@ import random
from mopidy import settings
from mopidy.mixers.dummy import DummyMixer
from mopidy.models import Playlist, Track
from mopidy.outputs.dummy import DummyOutput
from mopidy.utils import get_class
from tests.backends.base import populate_playlist
@ -12,12 +13,10 @@ class BaseCurrentPlaylistControllerTest(object):
tracks = []
def setUp(self):
self.output_queue = multiprocessing.Queue()
self.core_queue = multiprocessing.Queue()
self.output = get_class(settings.OUTPUT)(
self.core_queue, self.output_queue)
self.output = DummyOutput(self.core_queue)
self.backend = self.backend_class(
self.core_queue, self.output_queue, DummyMixer)
self.core_queue, self.output, DummyMixer)
self.controller = self.backend.current_playlist
self.playback = self.backend.playback
@ -129,7 +128,7 @@ class BaseCurrentPlaylistControllerTest(object):
def test_append_does_not_reset_version(self):
version = self.controller.version
self.controller.append([])
self.assertEqual(self.controller.version, version + 1)
self.assertEqual(self.controller.version, version)
@populate_playlist
def test_append_preserves_playing_state(self):
@ -250,7 +249,12 @@ class BaseCurrentPlaylistControllerTest(object):
self.assertEqual(self.tracks[0], shuffled_tracks[0])
self.assertEqual(set(self.tracks), set(shuffled_tracks))
def test_version(self):
def test_version_does_not_change_when_appending_nothing(self):
version = self.controller.version
self.controller.append([])
self.assertEquals(version, self.controller.version)
def test_version_increases_when_appending_something(self):
version = self.controller.version
self.controller.append([Track()])
self.assert_(version < self.controller.version)

View File

@ -5,21 +5,22 @@ import time
from mopidy import settings
from mopidy.mixers.dummy import DummyMixer
from mopidy.models import Track
from mopidy.outputs.dummy import DummyOutput
from mopidy.utils import get_class
from tests import SkipTest
from tests.backends.base import populate_playlist
# TODO Test 'playlist repeat', e.g. repeat=1,single=0
class BasePlaybackControllerTest(object):
tracks = []
def setUp(self):
self.output_queue = multiprocessing.Queue()
self.core_queue = multiprocessing.Queue()
self.output = get_class(settings.OUTPUT)(
self.core_queue, self.output_queue)
self.output = DummyOutput(self.core_queue)
self.backend = self.backend_class(
self.core_queue, self.output_queue, DummyMixer)
self.core_queue, self.output, DummyMixer)
self.playback = self.backend.playback
self.current_playlist = self.backend.current_playlist
@ -523,16 +524,17 @@ class BasePlaybackControllerTest(object):
wrapper.called = False
self.playback.on_current_playlist_change = wrapper
self.backend.current_playlist.append([])
self.backend.current_playlist.append([Track()])
self.assert_(wrapper.called)
@SkipTest # Blocks for 10ms and does not work with DummyOutput
@populate_playlist
def test_end_of_track_callback_gets_called(self):
self.playback.play()
result = self.playback.seek(self.tracks[0].length - 10)
self.assert_(result, 'Seek failed')
message = self.core_queue.get()
self.assertTrue(result, 'Seek failed')
message = self.core_queue.get(True, 1)
self.assertEqual('end_of_track', message['command'])
@populate_playlist
@ -606,6 +608,7 @@ class BasePlaybackControllerTest(object):
self.playback.pause()
self.assertEqual(self.playback.resume(), None)
@SkipTest # Uses sleep and does not work with DummyOutput+LocalBackend
@populate_playlist
def test_resume_continues_from_right_position(self):
self.playback.play()
@ -626,8 +629,7 @@ class BasePlaybackControllerTest(object):
self.assert_(position >= 990, position)
def test_seek_on_empty_playlist(self):
result = self.playback.seek(0)
self.assert_(not result, 'Seek return value was %s' % result)
self.assertFalse(self.playback.seek(0))
def test_seek_on_empty_playlist_updates_position(self):
self.playback.seek(0)
@ -738,15 +740,16 @@ class BasePlaybackControllerTest(object):
def test_time_position_when_stopped_with_playlist(self):
self.assertEqual(self.playback.time_position, 0)
@SkipTest # Uses sleep and does not work with LocalBackend+DummyOutput
@populate_playlist
def test_time_position_when_playing(self):
self.playback.play()
first = self.playback.time_position
time.sleep(1)
second = self.playback.time_position
self.assert_(second > first, '%s - %s' % (first, second))
@SkipTest # Uses sleep
@populate_playlist
def test_time_position_when_paused(self):
self.playback.play()
@ -755,7 +758,6 @@ class BasePlaybackControllerTest(object):
time.sleep(0.2)
first = self.playback.time_position
second = self.playback.time_position
self.assertEqual(first, second)
@populate_playlist

View File

@ -10,10 +10,6 @@ from tests import SkipTest, data_folder
class BaseStoredPlaylistsControllerTest(object):
def setUp(self):
self.original_playlist_folder = settings.LOCAL_PLAYLIST_FOLDER
self.original_tag_cache = settings.LOCAL_TAG_CACHE
self.original_music_folder = settings.LOCAL_MUSIC_FOLDER
settings.LOCAL_PLAYLIST_FOLDER = tempfile.mkdtemp()
settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache')
settings.LOCAL_MUSIC_FOLDER = data_folder('')
@ -27,9 +23,7 @@ class BaseStoredPlaylistsControllerTest(object):
if os.path.exists(settings.LOCAL_PLAYLIST_FOLDER):
shutil.rmtree(settings.LOCAL_PLAYLIST_FOLDER)
settings.LOCAL_PLAYLIST_FOLDER = self.original_playlist_folder
settings.LOCAL_TAG_CACHE = self.original_tag_cache
settings.LOCAL_MUSIC_FOLDER = self.original_music_folder
settings.runtime.clear()
def test_create(self):
playlist = self.stored.create('test')

View File

@ -22,10 +22,9 @@ class LocalCurrentPlaylistControllerTest(BaseCurrentPlaylistControllerTest,
for i in range(1, 4)]
def setUp(self):
self.original_backends = settings.BACKENDS
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
super(LocalCurrentPlaylistControllerTest, self).setUp()
def tearDown(self):
super(LocalCurrentPlaylistControllerTest, self).tearDown()
settings.BACKENDS = settings.original_backends
settings.runtime.clear()

View File

@ -17,16 +17,12 @@ class LocalLibraryControllerTest(BaseLibraryControllerTest, unittest.TestCase):
backend_class = LocalBackend
def setUp(self):
self.original_tag_cache = settings.LOCAL_TAG_CACHE
self.original_music_folder = settings.LOCAL_MUSIC_FOLDER
settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache')
settings.LOCAL_MUSIC_FOLDER = data_folder('')
super(LocalLibraryControllerTest, self).setUp()
def tearDown(self):
settings.LOCAL_TAG_CACHE = self.original_tag_cache
settings.LOCAL_MUSIC_FOLDER = self.original_music_folder
settings.runtime.clear()
super(LocalLibraryControllerTest, self).tearDown()

View File

@ -23,7 +23,6 @@ class LocalPlaybackControllerTest(BasePlaybackControllerTest,
for i in range(1, 4)]
def setUp(self):
self.original_backends = settings.BACKENDS
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
super(LocalPlaybackControllerTest, self).setUp()
@ -32,7 +31,7 @@ class LocalPlaybackControllerTest(BasePlaybackControllerTest,
def tearDown(self):
super(LocalPlaybackControllerTest, self).tearDown()
settings.BACKENDS = settings.original_backends
settings.runtime.clear()
def add_track(self, path):
uri = path_to_uri(data_folder(path))

View File

@ -33,6 +33,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
self.assertEqual(result[0],
u'ACK [50@0] {add} directory or file not found')
def test_add_with_empty_uri_should_add_all_known_tracks_and_ok(self):
result = self.h.handle_request(u'add ""')
# TODO check that we add all tracks (we currently don't)
self.assert_(u'OK' in result)
def test_addid_without_songpos(self):
needle = Track(uri='dummy://foo')
self.b.library._library = [Track(), Track(), needle, Track()]
@ -46,6 +51,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
in result)
self.assert_(u'OK' in result)
def test_addid_with_empty_uri_does_not_lookup_and_acks(self):
self.b.library.lookup = lambda uri: self.fail("Shouldn't run")
result = self.h.handle_request(u'addid ""')
self.assertEqual(result[0], u'ACK [50@0] {addid} No such song')
def test_addid_with_songpos(self):
needle = Track(uri='dummy://foo')
self.b.library._library = [Track(), Track(), needle, Track()]
@ -125,7 +135,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
def test_deleteid(self):
self.b.current_playlist.append([Track(), Track()])
self.assertEqual(len(self.b.current_playlist.tracks), 2)
result = self.h.handle_request(u'deleteid "2"')
result = self.h.handle_request(u'deleteid "1"')
self.assertEqual(len(self.b.current_playlist.tracks), 1)
self.assert_(u'OK' in result)
@ -183,7 +193,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'),
])
result = self.h.handle_request(u'moveid "5" "2"')
result = self.h.handle_request(u'moveid "4" "2"')
self.assertEqual(self.b.current_playlist.tracks[0].name, 'a')
self.assertEqual(self.b.current_playlist.tracks[1].name, 'b')
self.assertEqual(self.b.current_playlist.tracks[2].name, 'e')
@ -219,7 +229,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
result = self.h.handle_request(
u'playlistfind filename "file:///exists"')
self.assert_(u'file: file:///exists' in result)
self.assert_(u'Id: 1' in result)
self.assert_(u'Id: 0' in result)
self.assert_(u'Pos: 0' in result)
self.assert_(u'OK' in result)
@ -232,11 +242,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
def test_playlistid_with_songid(self):
self.b.current_playlist.append([Track(name='a'), Track(name='b')])
result = self.h.handle_request(u'playlistid "2"')
result = self.h.handle_request(u'playlistid "1"')
self.assert_(u'Title: a' not in result)
self.assert_(u'Id: 1' not in result)
self.assert_(u'Id: 0' not in result)
self.assert_(u'Title: b' in result)
self.assert_(u'Id: 2' in result)
self.assert_(u'Id: 1' in result)
self.assert_(u'OK' in result)
def test_playlistid_with_not_existing_songid_fails(self):
@ -419,7 +429,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'),
])
result = self.h.handle_request(u'swapid "2" "5"')
result = self.h.handle_request(u'swapid "1" "4"')
self.assertEqual(self.b.current_playlist.tracks[0].name, 'a')
self.assertEqual(self.b.current_playlist.tracks[1].name, 'e')
self.assertEqual(self.b.current_playlist.tracks[2].name, 'c')

View File

@ -15,6 +15,59 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
self.assert_(u'playtime: 0' in result)
self.assert_(u'OK' in result)
def test_findadd(self):
result = self.h.handle_request(u'findadd "album" "what"')
self.assert_(u'OK' in result)
def test_listall(self):
result = self.h.handle_request(u'listall "file:///dev/urandom"')
self.assert_(u'ACK [0@0] {} Not implemented' in result)
def test_listallinfo(self):
result = self.h.handle_request(u'listallinfo "file:///dev/urandom"')
self.assert_(u'ACK [0@0] {} Not implemented' in result)
def test_lsinfo_without_path_returns_same_as_listplaylists(self):
lsinfo_result = self.h.handle_request(u'lsinfo')
listplaylists_result = self.h.handle_request(u'listplaylists')
self.assertEqual(lsinfo_result, listplaylists_result)
def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self):
lsinfo_result = self.h.handle_request(u'lsinfo ""')
listplaylists_result = self.h.handle_request(u'listplaylists')
self.assertEqual(lsinfo_result, listplaylists_result)
def test_lsinfo_for_root_returns_same_as_listplaylists(self):
lsinfo_result = self.h.handle_request(u'lsinfo "/"')
listplaylists_result = self.h.handle_request(u'listplaylists')
self.assertEqual(lsinfo_result, listplaylists_result)
def test_update_without_uri(self):
result = self.h.handle_request(u'update')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_update_with_uri(self):
result = self.h.handle_request(u'update "file:///dev/urandom"')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_rescan_without_uri(self):
result = self.h.handle_request(u'rescan')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_rescan_with_uri(self):
result = self.h.handle_request(u'rescan "file:///dev/urandom"')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
class MusicDatabaseFindTest(unittest.TestCase):
def setUp(self):
self.b = DummyBackend(mixer_class=DummyMixer)
self.h = dispatcher.MpdDispatcher(backend=self.b)
def test_find_album(self):
result = self.h.handle_request(u'find "album" "what"')
self.assert_(u'OK' in result)
@ -48,11 +101,20 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
u'find album "album_what" artist "artist_what"')
self.assert_(u'OK' in result)
def test_findadd(self):
result = self.h.handle_request(u'findadd "album" "what"')
self.assert_(u'OK' in result)
def test_list_artist(self):
class MusicDatabaseListTest(unittest.TestCase):
def setUp(self):
self.b = DummyBackend(mixer_class=DummyMixer)
self.h = dispatcher.MpdDispatcher(backend=self.b)
def test_list_foo_returns_ack(self):
result = self.h.handle_request(u'list "foo"')
self.assertEqual(result[0],
u'ACK [2@0] {list} incorrect arguments')
### Artist
def test_list_artist_with_quotes(self):
result = self.h.handle_request(u'list "artist"')
self.assert_(u'OK' in result)
@ -64,44 +126,177 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
result = self.h.handle_request(u'list Artist')
self.assert_(u'OK' in result)
def test_list_artist_with_artist_should_fail(self):
def test_list_artist_with_query_of_one_token(self):
result = self.h.handle_request(u'list "artist" "anartist"')
self.assertEqual(result[0], u'ACK [2@0] {list} incorrect arguments')
self.assertEqual(result[0],
u'ACK [2@0] {list} should be "Album" for 3 arguments')
def test_list_album_without_artist(self):
def test_list_artist_with_unknown_field_in_query_returns_ack(self):
result = self.h.handle_request(u'list "artist" "foo" "bar"')
self.assertEqual(result[0],
u'ACK [2@0] {list} not able to parse args')
def test_list_artist_by_artist(self):
result = self.h.handle_request(u'list "artist" "artist" "anartist"')
self.assert_(u'OK' in result)
def test_list_artist_by_album(self):
result = self.h.handle_request(u'list "artist" "album" "analbum"')
self.assert_(u'OK' in result)
def test_list_artist_by_full_date(self):
result = self.h.handle_request(u'list "artist" "date" "2001-01-01"')
self.assert_(u'OK' in result)
def test_list_artist_by_year(self):
result = self.h.handle_request(u'list "artist" "date" "2001"')
self.assert_(u'OK' in result)
def test_list_artist_by_genre(self):
result = self.h.handle_request(u'list "artist" "genre" "agenre"')
self.assert_(u'OK' in result)
def test_list_artist_by_artist_and_album(self):
result = self.h.handle_request(
u'list "artist" "artist" "anartist" "album" "analbum"')
self.assert_(u'OK' in result)
### Album
def test_list_album_with_quotes(self):
result = self.h.handle_request(u'list "album"')
self.assert_(u'OK' in result)
def test_list_album_with_artist(self):
def test_list_album_without_quotes(self):
result = self.h.handle_request(u'list album')
self.assert_(u'OK' in result)
def test_list_album_without_quotes_and_capitalized(self):
result = self.h.handle_request(u'list Album')
self.assert_(u'OK' in result)
def test_list_album_with_artist_name(self):
result = self.h.handle_request(u'list "album" "anartist"')
self.assert_(u'OK' in result)
def test_list_album_artist_with_artist_without_quotes(self):
result = self.h.handle_request(u'list album artist "anartist"')
def test_list_album_by_artist(self):
result = self.h.handle_request(u'list "album" "artist" "anartist"')
self.assert_(u'OK' in result)
def test_listall(self):
result = self.h.handle_request(u'listall "file:///dev/urandom"')
self.assert_(u'ACK [0@0] {} Not implemented' in result)
def test_list_album_by_album(self):
result = self.h.handle_request(u'list "album" "album" "analbum"')
self.assert_(u'OK' in result)
def test_listallinfo(self):
result = self.h.handle_request(u'listallinfo "file:///dev/urandom"')
self.assert_(u'ACK [0@0] {} Not implemented' in result)
def test_list_album_by_full_date(self):
result = self.h.handle_request(u'list "album" "date" "2001-01-01"')
self.assert_(u'OK' in result)
def test_lsinfo_without_path_returns_same_as_listplaylists(self):
lsinfo_result = self.h.handle_request(u'lsinfo')
listplaylists_result = self.h.handle_request(u'listplaylists')
self.assertEqual(lsinfo_result, listplaylists_result)
def test_list_album_by_year(self):
result = self.h.handle_request(u'list "album" "date" "2001"')
self.assert_(u'OK' in result)
def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self):
lsinfo_result = self.h.handle_request(u'lsinfo ""')
listplaylists_result = self.h.handle_request(u'listplaylists')
self.assertEqual(lsinfo_result, listplaylists_result)
def test_list_album_by_genre(self):
result = self.h.handle_request(u'list "album" "genre" "agenre"')
self.assert_(u'OK' in result)
def test_lsinfo_for_root_returns_same_as_listplaylists(self):
lsinfo_result = self.h.handle_request(u'lsinfo "/"')
listplaylists_result = self.h.handle_request(u'listplaylists')
self.assertEqual(lsinfo_result, listplaylists_result)
def test_list_album_by_artist_and_album(self):
result = self.h.handle_request(
u'list "album" "artist" "anartist" "album" "analbum"')
self.assert_(u'OK' in result)
### Date
def test_list_date_with_quotes(self):
result = self.h.handle_request(u'list "date"')
self.assert_(u'OK' in result)
def test_list_date_without_quotes(self):
result = self.h.handle_request(u'list date')
self.assert_(u'OK' in result)
def test_list_date_without_quotes_and_capitalized(self):
result = self.h.handle_request(u'list Date')
self.assert_(u'OK' in result)
def test_list_date_with_query_of_one_token(self):
result = self.h.handle_request(u'list "date" "anartist"')
self.assertEqual(result[0],
u'ACK [2@0] {list} should be "Album" for 3 arguments')
def test_list_date_by_artist(self):
result = self.h.handle_request(u'list "date" "artist" "anartist"')
self.assert_(u'OK' in result)
def test_list_date_by_album(self):
result = self.h.handle_request(u'list "date" "album" "analbum"')
self.assert_(u'OK' in result)
def test_list_date_by_full_date(self):
result = self.h.handle_request(u'list "date" "date" "2001-01-01"')
self.assert_(u'OK' in result)
def test_list_date_by_year(self):
result = self.h.handle_request(u'list "date" "date" "2001"')
self.assert_(u'OK' in result)
def test_list_date_by_genre(self):
result = self.h.handle_request(u'list "date" "genre" "agenre"')
self.assert_(u'OK' in result)
def test_list_date_by_artist_and_album(self):
result = self.h.handle_request(
u'list "date" "artist" "anartist" "album" "analbum"')
self.assert_(u'OK' in result)
### Genre
def test_list_genre_with_quotes(self):
result = self.h.handle_request(u'list "genre"')
self.assert_(u'OK' in result)
def test_list_genre_without_quotes(self):
result = self.h.handle_request(u'list genre')
self.assert_(u'OK' in result)
def test_list_genre_without_quotes_and_capitalized(self):
result = self.h.handle_request(u'list Genre')
self.assert_(u'OK' in result)
def test_list_genre_with_query_of_one_token(self):
result = self.h.handle_request(u'list "genre" "anartist"')
self.assertEqual(result[0],
u'ACK [2@0] {list} should be "Album" for 3 arguments')
def test_list_genre_by_artist(self):
result = self.h.handle_request(u'list "genre" "artist" "anartist"')
self.assert_(u'OK' in result)
def test_list_genre_by_album(self):
result = self.h.handle_request(u'list "genre" "album" "analbum"')
self.assert_(u'OK' in result)
def test_list_genre_by_full_date(self):
result = self.h.handle_request(u'list "genre" "date" "2001-01-01"')
self.assert_(u'OK' in result)
def test_list_genre_by_year(self):
result = self.h.handle_request(u'list "genre" "date" "2001"')
self.assert_(u'OK' in result)
def test_list_genre_by_genre(self):
result = self.h.handle_request(u'list "genre" "genre" "agenre"')
self.assert_(u'OK' in result)
def test_list_genre_by_artist_and_album(self):
result = self.h.handle_request(
u'list "genre" "artist" "anartist" "album" "analbum"')
self.assert_(u'OK' in result)
class MusicDatabaseSearchTest(unittest.TestCase):
def setUp(self):
self.b = DummyBackend(mixer_class=DummyMixer)
self.h = dispatcher.MpdDispatcher(backend=self.b)
def test_search_album(self):
result = self.h.handle_request(u'search "album" "analbum"')
@ -147,22 +342,4 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
result = self.h.handle_request(u'search "sometype" "something"')
self.assertEqual(result[0], u'ACK [2@0] {search} incorrect arguments')
def test_update_without_uri(self):
result = self.h.handle_request(u'update')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_update_with_uri(self):
result = self.h.handle_request(u'update "file:///dev/urandom"')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_rescan_without_uri(self):
result = self.h.handle_request(u'rescan')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_rescan_with_uri(self):
result = self.h.handle_request(u'rescan "file:///dev/urandom"')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)

View File

@ -254,7 +254,7 @@ class PlaybackControlHandlerTest(unittest.TestCase):
def test_playid(self):
self.b.current_playlist.append([Track()])
result = self.h.handle_request(u'playid "1"')
result = self.h.handle_request(u'playid "0"')
self.assert_(u'OK' in result)
self.assertEqual(self.b.playback.PLAYING, self.b.playback.state)
@ -285,6 +285,18 @@ class PlaybackControlHandlerTest(unittest.TestCase):
self.assertEqual(self.b.playback.STOPPED, self.b.playback.state)
self.assertEqual(self.b.playback.current_track, None)
def test_playid_minus_one_resumes_if_paused(self):
self.b.current_playlist.append([Track(length=40000)])
self.b.playback.seek(30000)
self.assert_(self.b.playback.time_position >= 30000)
self.assertEquals(self.b.playback.PLAYING, self.b.playback.state)
self.b.playback.pause()
self.assertEquals(self.b.playback.PAUSED, self.b.playback.state)
result = self.h.handle_request(u'playid "-1"')
self.assert_(u'OK' in result)
self.assertEqual(self.b.playback.PLAYING, self.b.playback.state)
self.assert_(self.b.playback.time_position >= 30000)
def test_playid_which_does_not_exist(self):
self.b.current_playlist.append([Track()])
result = self.h.handle_request(u'playid "12345"')
@ -310,7 +322,7 @@ class PlaybackControlHandlerTest(unittest.TestCase):
def test_seekid(self):
self.b.current_playlist.append([Track(length=40000)])
result = self.h.handle_request(u'seekid "1" "30"')
result = self.h.handle_request(u'seekid "0" "30"')
self.assert_(u'OK' in result)
self.assert_(self.b.playback.time_position >= 30000)
@ -318,8 +330,8 @@ class PlaybackControlHandlerTest(unittest.TestCase):
seek_track = Track(uri='2', length=40000)
self.b.current_playlist.append(
[Track(length=40000), seek_track])
result = self.h.handle_request(u'seekid "2" "30"')
self.assertEqual(self.b.playback.current_cpid, 2)
result = self.h.handle_request(u'seekid "1" "30"')
self.assertEqual(self.b.playback.current_cpid, 1)
self.assertEqual(self.b.playback.current_track, seek_track)
def test_stop(self):

View File

@ -0,0 +1,110 @@
import random
import unittest
from mopidy.backends.dummy import DummyBackend
from mopidy.frontends.mpd import dispatcher
from mopidy.mixers.dummy import DummyMixer
from mopidy.models import Track
class IssueGH17RegressionTest(unittest.TestCase):
"""
The issue: http://github.com/jodal/mopidy/issues#issue/17
How to reproduce:
- Play a playlist where one track cannot be played
- Turn on random mode
- Press next until you get to the unplayable track
"""
def setUp(self):
self.backend = DummyBackend(mixer_class=DummyMixer)
self.backend.current_playlist.append([
Track(uri='a'), Track(uri='b'), None,
Track(uri='d'), Track(uri='e'), Track(uri='f')])
self.mpd = dispatcher.MpdDispatcher(backend=self.backend)
def test(self):
random.seed(1) # Playlist order: abcfde
self.mpd.handle_request(u'play')
self.assertEquals('a', self.backend.playback.current_track.uri)
self.mpd.handle_request(u'random "1"')
self.mpd.handle_request(u'next')
self.assertEquals('b', self.backend.playback.current_track.uri)
self.mpd.handle_request(u'next')
# Should now be at track 'c', but playback fails and it skips ahead
self.assertEquals('f', self.backend.playback.current_track.uri)
self.mpd.handle_request(u'next')
self.assertEquals('d', self.backend.playback.current_track.uri)
self.mpd.handle_request(u'next')
self.assertEquals('e', self.backend.playback.current_track.uri)
class IssueGH18RegressionTest(unittest.TestCase):
"""
The issue: http://github.com/jodal/mopidy/issues#issue/18
How to reproduce:
Play, random on, next, random off, next, next.
At this point it gives the same song over and over.
"""
def setUp(self):
self.backend = DummyBackend(mixer_class=DummyMixer)
self.backend.current_playlist.append([
Track(uri='a'), Track(uri='b'), Track(uri='c'),
Track(uri='d'), Track(uri='e'), Track(uri='f')])
self.mpd = dispatcher.MpdDispatcher(backend=self.backend)
def test(self):
random.seed(1)
self.mpd.handle_request(u'play')
self.mpd.handle_request(u'random "1"')
self.mpd.handle_request(u'next')
self.mpd.handle_request(u'random "0"')
self.mpd.handle_request(u'next')
self.mpd.handle_request(u'next')
cp_track_1 = self.backend.playback.current_cp_track
self.mpd.handle_request(u'next')
cp_track_2 = self.backend.playback.current_cp_track
self.mpd.handle_request(u'next')
cp_track_3 = self.backend.playback.current_cp_track
self.assertNotEqual(cp_track_1, cp_track_2)
self.assertNotEqual(cp_track_2, cp_track_3)
class IssueGH22RegressionTest(unittest.TestCase):
"""
The issue: http://github.com/jodal/mopidy/issues/#issue/22
How to reproduce:
Play, random on, remove all tracks from the current playlist (as in
"delete" each one, not "clear").
Alternatively: Play, random on, remove a random track from the current
playlist, press next until it crashes.
"""
def setUp(self):
self.backend = DummyBackend(mixer_class=DummyMixer)
self.backend.current_playlist.append([
Track(uri='a'), Track(uri='b'), Track(uri='c'),
Track(uri='d'), Track(uri='e'), Track(uri='f')])
self.mpd = dispatcher.MpdDispatcher(backend=self.backend)
def test(self):
random.seed(1)
self.mpd.handle_request(u'play')
self.mpd.handle_request(u'random "1"')
self.mpd.handle_request(u'deleteid "1"')
self.mpd.handle_request(u'deleteid "2"')
self.mpd.handle_request(u'deleteid "3"')
self.mpd.handle_request(u'deleteid "4"')
self.mpd.handle_request(u'deleteid "5"')
self.mpd.handle_request(u'deleteid "6"')
self.mpd.handle_request(u'status')

View File

@ -27,7 +27,7 @@ class StatusHandlerTest(unittest.TestCase):
self.assert_(u'Track: 0' in result)
self.assert_(u'Date: ' in result)
self.assert_(u'Pos: 0' in result)
self.assert_(u'Id: 1' in result)
self.assert_(u'Id: 0' in result)
self.assert_(u'OK' in result)
def test_currentsong_without_song(self):
@ -166,7 +166,7 @@ class StatusHandlerTest(unittest.TestCase):
self.b.playback.play()
result = dict(dispatcher.status.status(self.h))
self.assert_('songid' in result)
self.assertEqual(int(result['songid']), 1)
self.assertEqual(int(result['songid']), 0)
def test_status_method_when_playing_contains_time_with_no_length(self):
self.b.current_playlist.append([Track(length=None)])

View File

@ -1,61 +1,65 @@
import multiprocessing
import unittest
from tests import SkipTest
# FIXME Our Windows build server does not support GStreamer yet
import sys
if sys.platform == 'win32':
raise SkipTest
from mopidy import settings
from mopidy.outputs.gstreamer import GStreamerOutput
from mopidy.utils.path import path_to_uri
from mopidy.utils.process import pickle_connection
from tests import data_folder, SkipTest
from tests import data_folder
class GStreamerOutputTest(unittest.TestCase):
def setUp(self):
self.original_backends = settings.BACKENDS
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
self.song_uri = path_to_uri(data_folder('song1.wav'))
self.output_queue = multiprocessing.Queue()
self.core_queue = multiprocessing.Queue()
self.output = GStreamerOutput(self.core_queue, self.output_queue)
self.output = GStreamerOutput(self.core_queue)
self.output.start()
def tearDown(self):
self.output.destroy()
settings.BACKENDS = settings.original_backends
def send_recv(self, message):
(my_end, other_end) = multiprocessing.Pipe()
message.update({'reply_to': pickle_connection(other_end)})
self.output_queue.put(message)
my_end.poll(None)
return my_end.recv()
def send(self, message):
self.output_queue.put(message)
settings.runtime.clear()
def test_play_uri_existing_file(self):
message = {'command': 'play_uri', 'uri': self.song_uri}
self.assertEqual(True, self.send_recv(message))
self.assertTrue(self.output.play_uri(self.song_uri))
def test_play_uri_non_existing_file(self):
message = {'command': 'play_uri', 'uri': self.song_uri + 'bogus'}
self.assertEqual(False, self.send_recv(message))
self.assertFalse(self.output.play_uri(self.song_uri + 'bogus'))
@SkipTest
def test_deliver_data(self):
pass # TODO
@SkipTest
def test_end_of_data_stream(self):
pass # TODO
def test_default_get_volume_result(self):
message = {'command': 'get_volume'}
self.assertEqual(100, self.send_recv(message))
self.assertEqual(100, self.output.get_volume())
def test_set_volume(self):
self.send({'command': 'set_volume', 'volume': 50})
self.assertEqual(50, self.send_recv({'command': 'get_volume'}))
self.assertTrue(self.output.set_volume(50))
self.assertEqual(50, self.output.get_volume())
def test_set_volume_to_zero(self):
self.send({'command': 'set_volume', 'volume': 0})
self.assertEqual(0, self.send_recv({'command': 'get_volume'}))
self.assertTrue(self.output.set_volume(0))
self.assertEqual(0, self.output.get_volume())
def test_set_volume_to_one_hundred(self):
self.send({'command': 'set_volume', 'volume': 100})
self.assertEqual(100, self.send_recv({'command': 'get_volume'}))
self.assertTrue(self.output.set_volume(100))
self.assertEqual(100, self.output.get_volume())
@SkipTest
def test_set_state(self):
raise NotImplementedError
pass # TODO
@SkipTest
def test_set_position(self):
pass # TODO

View File

@ -1,6 +1,7 @@
import unittest
from mopidy.utils.settings import validate_settings
from mopidy import settings as default_settings_module
from mopidy.utils.settings import validate_settings, SettingsProxy
class ValidateSettingsTest(unittest.TestCase):
def setUp(self):
@ -43,3 +44,24 @@ class ValidateSettingsTest(unittest.TestCase):
result = validate_settings(self.defaults,
{'FOO': '', 'BAR': ''})
self.assertEquals(len(result), 2)
class SettingsProxyTest(unittest.TestCase):
def setUp(self):
self.settings = SettingsProxy(default_settings_module)
def test_set_and_get_attr(self):
self.settings.TEST = 'test'
self.assertEqual(self.settings.TEST, 'test')
def test_setattr_updates_runtime_settings(self):
self.settings.TEST = 'test'
self.assert_('TEST' in self.settings.runtime)
def test_setattr_updates_runtime_with_value(self):
self.settings.TEST = 'test'
self.assertEqual(self.settings.runtime['TEST'], 'test')
def test_runtime_value_included_in_current(self):
self.settings.TEST = 'test'
self.assertEqual(self.settings.current['TEST'], 'test')

View File

@ -11,7 +11,7 @@ class VersionTest(unittest.TestCase):
self.assert_(SV('0.1.0a0') < SV('0.1.0a1'))
self.assert_(SV('0.1.0a1') < SV('0.1.0a2'))
self.assert_(SV('0.1.0a2') < SV('0.1.0a3'))
self.assert_(SV('0.1.0a3') < SV(get_version()))
self.assert_(SV(get_version()) < SV('0.1.1'))
self.assert_(SV('0.1.1') < SV('0.2.0'))
self.assert_(SV('0.1.0a3') < SV('0.1.0'))
self.assert_(SV('0.1.0') < SV(get_version()))
self.assert_(SV(get_version()) < SV('0.2.1'))
self.assert_(SV('0.2.0') < SV('1.0.0'))