Merge branch 'master' into gstreamer

This commit is contained in:
Thomas Adamcik 2010-05-05 19:29:26 +02:00
commit 62dde4de74
13 changed files with 310 additions and 96 deletions

View File

@ -16,6 +16,9 @@ The backend and its controllers
Backend API
===========
.. automodule:: mopidy.backends
:synopsis: Backend interface.
.. note::
Currently this only documents the API that is available for use by
@ -26,8 +29,54 @@ Backend API
generally just implement or override a few of these methods yourself to
create a new backend with a complete feature set.
.. automodule:: mopidy.backends
:synopsis: Backend interface.
.. autoclass:: mopidy.backends.BaseBackend
:members:
:undoc-members:
Playback controller
-------------------
Manages playback, with actions like play, pause, stop, next, previous, and
seek.
.. autoclass:: mopidy.backends.BasePlaybackController
:members:
:undoc-members:
Mixer controller
----------------
Manages volume. See :class:`mopidy.mixers.BaseMixer`.
Current playlist controller
---------------------------
Manages everything related to the currently loaded playlist.
.. autoclass:: mopidy.backends.BaseCurrentPlaylistController
:members:
:undoc-members:
Stored playlists controller
---------------------------
Manages stored playlist.
.. autoclass:: mopidy.backends.BaseStoredPlaylistsController
:members:
:undoc-members:
Library controller
------------------
Manages the music library, e.g. searching for tracks to be added to a playlist.
.. autoclass:: mopidy.backends.BaseLibraryController
:members:
:undoc-members:

View File

@ -4,13 +4,51 @@ Changes
This change log is used to track all major changes to Mopidy.
0.1.0a1 (unreleased)
0.1.0a2 (unreleased)
====================
- Merged the ``gstreamer`` branch from Thomas Adamcik, bringing more than a
hundred new tests, several bugfixes and a new backend for playing music from
a local music archive.
- **[WIP]** Changed backend API for get/filter/find_exact/search.
- (nothing yet)
0.1.0a1 (2010-05-04)
====================
Since the previous release Mopidy has seen about 300 commits, more than 200 new
tests, a libspotify release, and major feature additions to Spotify. The new
releases from Spotify have lead to updates to our dependencies, and also to new
bugs in Mopidy. Thus, this is primarily a bugfix release, even though the not
yet finished work on a Gstreamer backend have been merged.
All users are recommended to upgrade to 0.1.0a1, and should at the same time
ensure that they have the latest versions of our dependencies: Despotify r508
if you are using DespotifyBackend, and pyspotify 1.1 with libspotify 0.0.4 if
you are using LibspotifyBackend.
As always, report problems at our IRC channel or our issue tracker. Thanks!
**Changes**
- Backend API changes:
- Removed ``backend.playback.volume`` wrapper. Use ``backend.mixer.volume``
directly.
- Renamed ``backend.playback.playlist_position`` to
``current_playlist_position`` to match naming of ``current_track``.
- Replaced ``get_by_id()`` with a more flexible ``get(**criteria)``.
- Merged the ``gstreamer`` branch from Thomas Adamcik:
- More than 200 new tests, and thus several bugfixes to existing code.
- Several new generic features, like shuffle, consume, and playlist repeat.
(Fixes: GH-3)
- **[Work in Progress]** A new backend for playing music from a local music
archive using the Gstreamer library.
- Made :class:`mopidy.mixers.alsa.AlsaMixer` work on machines without a mixer
named "Master".
- Make :class:`mopidy.backends.DespotifyBackend` ignore local files in
playlists (feature added in Spotify 0.4.3). Reported by Richard Haugen Olsen.
- And much more.
0.1.0a0 (2010-03-27)

View File

@ -1,7 +1,7 @@
from mopidy import settings as raw_settings
def get_version():
return u'0.1.0a1'
return u'0.1.0a2'
def get_mpd_protocol_version():
return u'0.16.0'

View File

@ -14,6 +14,15 @@ __all__ = ['BaseBackend', 'BasePlaybackController',
'BaseLibraryController']
class BaseBackend(object):
"""
:param core_queue: a queue for sending messages to
:class:`mopidy.process.CoreProcess`
:type core_queue: :class:`multiprocessing.Queue`
:param mixer: either a mixer instance, or :class:`None` to use the mixer
defined in settings
:type mixer: :class:`mopidy.mixers.BaseMixer` or :class:`None`
"""
def __init__(self, core_queue=None, mixer=None):
self.core_queue = core_queue
if mixer is not None:
@ -22,7 +31,8 @@ class BaseBackend(object):
self.mixer = get_class(settings.MIXER)()
#: A :class:`multiprocessing.Queue` which can be used by e.g. library
#: callbacks to send messages to the core.
#: callbacks executing in other threads to send messages to the core
#: thread, so that action may be taken in the correct thread.
core_queue = None
#: The current playlist controller. An instance of
@ -73,14 +83,17 @@ class BaseCurrentPlaylistController(object):
"""
#: The current playlist version. Integer which is increased every time the
#: current playlist is changed. Is not reset before the MPD server is
#: restarted.
#: current playlist is changed. Is not reset before Mopidy is restarted.
version = 0
def __init__(self, backend):
self.backend = backend
self._playlist = Playlist()
def destroy(self):
"""Cleanup after component."""
pass
@property
def playlist(self):
"""The currently loaded :class:`mopidy.models.Playlist`."""
@ -229,7 +242,7 @@ class BaseCurrentPlaylistController(object):
self.playlist = self.playlist.with_(tracks=before+shuffled+after)
def destroy(self):
"""Cleanup after component"""
"""Cleanup after component."""
pass
@ -242,6 +255,10 @@ class BaseLibraryController(object):
def __init__(self, backend):
self.backend = backend
def destroy(self):
"""Cleanup after component."""
pass
def find_exact(self, field, query):
"""
Find tracks in the library where ``field`` matches ``query`` exactly.
@ -256,11 +273,11 @@ class BaseLibraryController(object):
def lookup(self, uri):
"""
Lookup track with given URI.
Lookup track with given URI. Returns :class:`None` if not found.
:param uri: track URI
:type uri: string
:rtype: :class:`mopidy.models.Track`
:rtype: :class:`mopidy.models.Track` or :class:`None`
"""
raise NotImplementedError
@ -285,10 +302,6 @@ class BaseLibraryController(object):
"""
raise NotImplementedError
def destroy(self):
"""Cleanup after component"""
pass
class BasePlaybackController(object):
"""
@ -321,9 +334,10 @@ class BasePlaybackController(object):
random = False
#: :class:`True`
#: The current track is played repeatedly.
#: The current playlist is played repeatedly. To repeat a single track,
#: select both :attr:`repeat` and :attr:`single`.
#: :class:`False`
#: The current track is played once.
#: The current playlist is played once.
repeat = False
#: :class:`True`
@ -340,6 +354,21 @@ class BasePlaybackController(object):
self._play_time_accumulated = 0
self._play_time_started = None
def destroy(self):
"""Cleanup after component."""
pass
@property
def current_playlist_position(self):
"""The position of the current track in the current playlist."""
if self.current_track is None:
return None
try:
return self.backend.current_playlist.playlist.tracks.index(
self.current_track)
except ValueError:
return None
@property
def next_track(self):
"""
@ -369,40 +398,30 @@ class BasePlaybackController(object):
return tracks[0]
if self.repeat:
return tracks[(self.playlist_position + 1) % len(tracks)]
return tracks[(self.current_playlist_position + 1) % len(tracks)]
try:
return tracks[self.playlist_position + 1]
return tracks[self.current_playlist_position + 1]
except IndexError:
return None
@property
def playlist_position(self):
"""The position in the current playlist."""
if self.current_track is None:
return None
try:
return self.backend.current_playlist.playlist.tracks.index(
self.current_track)
except ValueError:
return None
@property
def previous_track(self):
"""
The previous :class:`mopidy.models.Track` in the playlist.
For normal playback this is the previous track in the playlist. If random
and/or consume is enabled it should return the current track instead.
For normal playback this is the previous track in the playlist. If
random and/or consume is enabled it should return the current track
instead.
"""
if self.repeat or self.consume or self.random:
return self.current_track
if self.current_track is None or self.playlist_position == 0:
if self.current_track is None or self.current_playlist_position == 0:
return None
return self.backend.current_playlist.playlist.tracks[
self.playlist_position - 1]
self.current_playlist_position - 1]
@property
def state(self):
@ -464,23 +483,13 @@ class BasePlaybackController(object):
def _current_wall_time(self):
return int(time.time() * 1000)
@property
def volume(self):
# FIXME Shouldn't we just be using the backend mixer directly? ie can we
# remove this?
"""
The audio volume as an int in the range [0, 100].
:class:`None` if unknown.
"""
return self.backend.mixer.volume
@volume.setter
def volume(self, volume):
self.backend.mixer.volume = volume
def end_of_track_callback(self):
"""Tell the playback controller that end of track is reached."""
"""
Tell the playback controller that end of track is reached.
Typically called by :class:`mopidy.process.CoreProcess` after a message
from a library thread is received.
"""
if self.next_track is not None:
self.next()
else:
@ -488,7 +497,12 @@ class BasePlaybackController(object):
self.current_track = None
def new_playlist_loaded_callback(self):
"""Tell the playback controller that a new playlist has been loaded."""
"""
Tell the playback controller that a new playlist has been loaded.
Typically called by :class:`mopidy.process.CoreProcess` after a message
from a library thread is received.
"""
self.current_track = None
self._first_shuffle = True
self._shuffled = []
@ -611,10 +625,6 @@ class BasePlaybackController(object):
def _stop(self):
raise NotImplementedError
def destroy(self):
"""Cleanup after component"""
pass
class BaseStoredPlaylistsController(object):
"""
@ -626,6 +636,10 @@ class BaseStoredPlaylistsController(object):
self.backend = backend
self._playlists = []
def destroy(self):
"""Cleanup after component."""
pass
@property
def playlists(self):
"""List of :class:`mopidy.models.Playlist`."""
@ -726,7 +740,3 @@ class BaseStoredPlaylistsController(object):
:rtype: list of :class:`mopidy.models.Playlist`
"""
return filter(lambda p: query in p.name, self._playlists)
def destroy(self):
"""Cleanup after component"""
pass

View File

@ -137,6 +137,8 @@ class DespotifyTranslator(object):
@classmethod
def to_mopidy_track(cls, spotify_track):
if not spotify_track.has_meta_data():
return None
if dt.MINYEAR <= int(spotify_track.year) <= dt.MAXYEAR:
date = dt.date(spotify_track.year, 1, 1)
else:
@ -158,7 +160,7 @@ class DespotifyTranslator(object):
return Playlist(
uri=spotify_playlist.get_uri(),
name=spotify_playlist.name.decode(ENCODING),
tracks=[cls.to_mopidy_track(t) for t in spotify_playlist.tracks],
tracks=filter(None, [cls.to_mopidy_track(t) for t in spotify_playlist.tracks]),
)

View File

@ -47,13 +47,16 @@ class LibspotifyBackend(BaseBackend):
self.stored_playlists = LibspotifyStoredPlaylistsController(
backend=self)
self.uri_handlers = [u'spotify:', u'http://open.spotify.com/']
self.audio_controller_class = kwargs.get(
'audio_controller_class', AlsaController)
self.spotify = self._connect()
def _connect(self):
logger.info(u'Connecting to Spotify')
spotify = LibspotifySessionManager(
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD,
core_queue=self.core_queue)
core_queue=self.core_queue,
audio_controller_class=self.audio_controller_class)
spotify.start()
return spotify
@ -175,12 +178,12 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread):
appkey_file = os.path.expanduser(settings.SPOTIFY_LIB_APPKEY)
user_agent = 'Mopidy %s' % get_version()
def __init__(self, username, password, core_queue):
def __init__(self, username, password, core_queue, audio_controller_class):
SpotifySessionManager.__init__(self, username, password)
threading.Thread.__init__(self)
self.core_queue = core_queue
self.connected = threading.Event()
self.audio = AlsaController()
self.audio = audio_controller_class()
self.session = None
def run(self):

View File

@ -1,7 +1,10 @@
import alsaaudio
import logging
from mopidy.mixers import BaseMixer
logger = logging.getLogger('mopidy.mixers.alsa')
class AlsaMixer(BaseMixer):
"""
Mixer which uses the Advanced Linux Sound Architecture (ALSA) to control
@ -10,7 +13,16 @@ class AlsaMixer(BaseMixer):
def __init__(self, *args, **kwargs):
super(AlsaMixer, self).__init__(*args, **kwargs)
self._mixer = alsaaudio.Mixer()
# A mixer named 'Master' does not always exist, so we fall back to
# using 'PCM'. If this turns out to be a bad solution, we should make
# it possible to override with a setting.
self._mixer = None
for mixer_name in (u'Master', u'PCM'):
if mixer_name in alsaaudio.mixers():
logger.info(u'Mixer in use: %s', mixer_name)
self._mixer = alsaaudio.Mixer(mixer_name)
break
assert self._mixer is not None
def _get_volume(self):
return self._mixer.getvolume()[0]

View File

@ -679,8 +679,12 @@ class MpdFrontend(object):
When listing the root directory, this currently returns the list of
stored playlists. This behavior is deprecated; use
``listplaylists`` instead.
MPD returns the same result, including both playlists and the files and
directories located at the root level, for both ``lsinfo``, ``lsinfo
""``, and ``lsinfo "/"``.
"""
if uri == u'/' or uri is None:
if uri is None or uri == u'/' or uri == u'':
return self._stored_playlists_listplaylists()
raise MpdNotImplemented # TODO
@ -835,6 +839,7 @@ class MpdFrontend(object):
raise MpdAckError(e[0])
@handle_pattern(r'^play "(?P<songpos>\d+)"$')
@handle_pattern(r'^play "(?P<songpos>-1)"$')
def _playback_playpos(self, songpos):
"""
*musicpd.org, playback section:*
@ -842,10 +847,17 @@ class MpdFrontend(object):
``play [SONGPOS]``
Begins playing the playlist at song number ``SONGPOS``.
*MPoD:*
- issues ``play "-1"`` after playlist replacement.
"""
songpos = int(songpos)
try:
track = self.backend.current_playlist.playlist.tracks[songpos]
if songpos == -1:
track = self.backend.current_playlist.playlist.tracks[0]
else:
track = self.backend.current_playlist.playlist.tracks[songpos]
return self.backend.playback.play(track)
except IndexError:
raise MpdAckError(u'Position out of bounds')
@ -954,7 +966,7 @@ class MpdFrontend(object):
volume = 0
if volume > 100:
volume = 100
self.backend.playback.volume = volume
self.backend.mixer.volume = volume
@handle_pattern(r'^single "(?P<state>[01])"$')
def _playback_single(self, state):
@ -1070,7 +1082,7 @@ class MpdFrontend(object):
"""
if self.backend.playback.current_track is not None:
return self.backend.playback.current_track.mpd_format(
position=self.backend.playback.playlist_position)
position=self.backend.playback.current_playlist_position)
@handle_pattern(r'^idle$')
@handle_pattern(r'^idle (?P<subsystems>.+)$')
@ -1226,7 +1238,7 @@ class MpdFrontend(object):
return self.__status_status_songpos()
def __status_status_songpos(self):
return self.backend.playback.playlist_position
return self.backend.playback.current_playlist_position
def __status_status_state(self):
if self.backend.playback.state == self.backend.playback.PLAYING:
@ -1252,8 +1264,8 @@ class MpdFrontend(object):
return self.backend.playback.current_track.length
def __status_status_volume(self):
if self.backend.playback.volume is not None:
return self.backend.playback.volume
if self.backend.mixer.volume is not None:
return self.backend.mixer.volume
else:
return 0

View File

@ -397,12 +397,13 @@ class BasePlaybackControllerTest(object):
def test_next(self):
self.playback.play()
old_position = self.playback.playlist_position
old_position = self.playback.current_playlist_position
old_uri = self.playback.current_track.uri
self.playback.next()
self.assertEqual(self.playback.playlist_position, old_position+1)
self.assertEqual(self.playback.current_playlist_position,
old_position+1)
self.assertNotEqual(self.playback.current_track.uri, old_uri)
@populate_playlist
@ -422,7 +423,7 @@ class BasePlaybackControllerTest(object):
for i, track in enumerate(self.tracks):
self.assertEqual(self.playback.state, self.playback.PLAYING)
self.assertEqual(self.playback.current_track, track)
self.assertEqual(self.playback.playlist_position, i)
self.assertEqual(self.playback.current_playlist_position, i)
self.playback.next()
@ -584,25 +585,25 @@ class BasePlaybackControllerTest(object):
self.assertEqual(self.playback.current_track, self.tracks[1])
@populate_playlist
def test_initial_playlist_position(self):
self.assertEqual(self.playback.playlist_position, None)
def test_initial_current_playlist_position(self):
self.assertEqual(self.playback.current_playlist_position, None)
@populate_playlist
def test_playlist_position_during_play(self):
def test_current_playlist_position_during_play(self):
self.playback.play()
self.assertEqual(self.playback.playlist_position, 0)
self.assertEqual(self.playback.current_playlist_position, 0)
@populate_playlist
def test_playlist_position_after_next(self):
def test_current_playlist_position_after_next(self):
self.playback.play()
self.playback.next()
self.assertEqual(self.playback.playlist_position, 1)
self.assertEqual(self.playback.current_playlist_position, 1)
@populate_playlist
def test_playlist_position_at_end_of_playlist(self):
def test_current_playlist_position_at_end_of_playlist(self):
self.playback.play(self.tracks[-1])
self.playback.end_of_track_callback()
self.assertEqual(self.playback.playlist_position, None)
self.assertEqual(self.playback.current_playlist_position, None)
def test_new_playlist_loaded_callback_gets_called(self):
callback = self.playback.new_playlist_loaded_callback

View File

@ -0,0 +1,35 @@
# TODO This integration test is work in progress.
import unittest
from mopidy.backends.despotify import DespotifyBackend
from mopidy.models import Track
from tests.backends.base import *
uris = [
'spotify:track:6vqcpVcbI3Zu6sH3ieLDNt',
'spotify:track:111sulhaZqgsnypz3MkiaW',
'spotify:track:7t8oznvbeiAPMDRuK0R5ZT',
]
class DespotifyCurrentPlaylistControllerTest(
BaseCurrentPlaylistControllerTest, unittest.TestCase):
tracks = [Track(uri=uri, id=i, length=4464) for i, uri in enumerate(uris)]
backend_class = DespotifyBackend
class DespotifyPlaybackControllerTest(
BasePlaybackControllerTest, unittest.TestCase):
tracks = [Track(uri=uri, id=i, length=4464) for i, uri in enumerate(uris)]
backend_class = DespotifyBackend
class DespotifyStoredPlaylistsControllerTest(
BaseStoredPlaylistsControllerTest, unittest.TestCase):
backend_class = DespotifyBackend
class DespotifyLibraryControllerTest(
BaseLibraryControllerTest, unittest.TestCase):
backend_class = DespotifyBackend

View File

@ -0,0 +1,42 @@
# TODO This integration test is work in progress.
import unittest
from mopidy.backends.libspotify import LibspotifyBackend
from mopidy.models import Track
from tests.backends.base import *
uris = [
'spotify:track:6vqcpVcbI3Zu6sH3ieLDNt',
'spotify:track:111sulhaZqgsnypz3MkiaW',
'spotify:track:7t8oznvbeiAPMDRuK0R5ZT',
]
class LibspotifyCurrentPlaylistControllerTest(
BaseCurrentPlaylistControllerTest, unittest.TestCase):
tracks = [Track(uri=uri, id=i, length=4464) for i, uri in enumerate(uris)]
backend_class = LibspotifyBackend
class LibspotifyPlaybackControllerTest(
BasePlaybackControllerTest, unittest.TestCase):
tracks = [Track(uri=uri, id=i, length=4464) for i, uri in enumerate(uris)]
backend_class = LibspotifyBackend
class LibspotifyStoredPlaylistsControllerTest(
BaseStoredPlaylistsControllerTest, unittest.TestCase):
backend_class = LibspotifyBackend
class LibspotifyLibraryControllerTest(
BaseLibraryControllerTest, unittest.TestCase):
backend_class = LibspotifyBackend
# TODO Plug this into the backend under test to avoid music output during
# testing.
class DummyAudioController(object):
def music_delivery(self, *args, **kwargs):
pass

View File

@ -155,7 +155,7 @@ class StatusHandlerTest(unittest.TestCase):
self.assertEqual(int(result['volume']), 0)
def test_status_method_contains_volume(self):
self.b.playback.volume = 17
self.b.mixer.volume = 17
result = dict(self.h._status_status())
self.assert_('volume' in result)
self.assertEqual(int(result['volume']), 17)
@ -334,32 +334,32 @@ class PlaybackOptionsHandlerTest(unittest.TestCase):
def test_setvol_below_min(self):
result = self.h.handle_request(u'setvol "-10"')
self.assert_(u'OK' in result)
self.assertEqual(0, self.b.playback.volume)
self.assertEqual(0, self.b.mixer.volume)
def test_setvol_min(self):
result = self.h.handle_request(u'setvol "0"')
self.assert_(u'OK' in result)
self.assertEqual(0, self.b.playback.volume)
self.assertEqual(0, self.b.mixer.volume)
def test_setvol_middle(self):
result = self.h.handle_request(u'setvol "50"')
self.assert_(u'OK' in result)
self.assertEqual(50, self.b.playback.volume)
self.assertEqual(50, self.b.mixer.volume)
def test_setvol_max(self):
result = self.h.handle_request(u'setvol "100"')
self.assert_(u'OK' in result)
self.assertEqual(100, self.b.playback.volume)
self.assertEqual(100, self.b.mixer.volume)
def test_setvol_above_max(self):
result = self.h.handle_request(u'setvol "110"')
self.assert_(u'OK' in result)
self.assertEqual(100, self.b.playback.volume)
self.assertEqual(100, self.b.mixer.volume)
def test_setvol_plus_is_ignored(self):
result = self.h.handle_request(u'setvol "+10"')
self.assert_(u'OK' in result)
self.assertEqual(10, self.b.playback.volume)
self.assertEqual(10, self.b.mixer.volume)
def test_single_off(self):
result = self.h.handle_request(u'single "0"')
@ -461,6 +461,14 @@ class PlaybackControlHandlerTest(unittest.TestCase):
self.assert_(u'ACK Position out of bounds' in result)
self.assertEqual(self.b.playback.STOPPED, self.b.playback.state)
def test_play_minus_one_plays_first_in_playlist(self):
track = Track(id=0)
self.b.current_playlist.load(Playlist(tracks=[track]))
result = self.h.handle_request(u'play "-1"')
self.assert_(u'OK' in result)
self.assertEqual(self.b.playback.PLAYING, self.b.playback.state)
self.assertEqual(self.b.playback.current_track, track)
def test_playid(self):
self.b.current_playlist.load(Playlist(tracks=[Track(id=0)]))
result = self.h.handle_request(u'playid "0"')
@ -964,9 +972,10 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
listplaylists_result = self.h.handle_request(u'listplaylists')
self.assertEqual(lsinfo_result, listplaylists_result)
def test_lsinfo_with_path(self):
result = self.h.handle_request(u'lsinfo ""')
self.assert_(u'ACK Not implemented' 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_lsinfo_for_root_returns_same_as_listplaylists(self):
lsinfo_result = self.h.handle_request(u'lsinfo "/"')

View File

@ -9,7 +9,8 @@ class VersionTest(unittest.TestCase):
def test_versions_can_be_strictly_ordered(self):
self.assert_(SV(get_version()) > SV('0.1.0a0'))
self.assert_(SV(get_version()) < SV('0.1.0a2'))
self.assert_(SV(get_version()) > SV('0.1.0a1'))
self.assert_(SV(get_version()) < SV('0.1.0a3'))
self.assert_(SV('0.1.0a1') < SV('0.1.0'))
self.assert_(SV('0.1.0') < SV('0.1.1'))
self.assert_(SV('0.1.1') < SV('0.2.0'))