From b6bceacc0f42e32fc3dac4dbe7e17cadaab9542e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Feb 2010 21:23:32 +0100 Subject: [PATCH 001/341] Add Playlist.with() --- mopidy/models.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/mopidy/models.py b/mopidy/models.py index 39212c8e..c2e97218 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -217,3 +217,26 @@ class Playlist(object): for track, position in zip(self.tracks, range(start, end)): tracks.append(track.mpd_format(position)) return tracks + + def with(self, uri=None, name=None, tracks=None): + """ + Create a new playlist object with the given values. The values that are + not given are taken from the object the method is called on. + + Does not change the object on which it is called. + + :param uri: playlist URI + :type uri: string + :param name: playlist name + :type name: string + :param tracks: playlist's tracks + :type tracks: list of :class:`Track` elements + :rtype: :class:`Playlist` + """ + if uri is None: + uri = self.uri + if name is None: + name = self.name + if tracks is None: + tracks = self.tracks + return Playlist(uri=uri, name=name, tracks=tracks) From 682b14c13c1034c8b1c4f6a9b547e03e50ed0042 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Feb 2010 21:24:57 +0100 Subject: [PATCH 002/341] Rename to with_() since with is reserved in Python >=2.6 --- mopidy/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/models.py b/mopidy/models.py index c2e97218..37b0853a 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -218,7 +218,7 @@ class Playlist(object): tracks.append(track.mpd_format(position)) return tracks - def with(self, uri=None, name=None, tracks=None): + def with_(self, uri=None, name=None, tracks=None): """ Create a new playlist object with the given values. The values that are not given are taken from the object the method is called on. From 21019740b1892f0e926f861bd20744e9167f4b8c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Feb 2010 22:36:08 +0100 Subject: [PATCH 003/341] Add two new methods for the backend API --- docs/api/backends.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index e20578ea..afcded4d 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -113,6 +113,10 @@ The currently playing or selected :class:`mopidy.models.Track`. + .. method:: new_playlist_loaded_callback() + + Tell the playback controller that a new playlist has been loaded. + .. method:: next() Play the next track. @@ -285,6 +289,13 @@ :param new_name: the new name :type new_name: string + .. method:: save(playlist) + + Save the playlist. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + .. method:: search(query) Search for playlists whose name contains ``query``. From 969beea69b475e86fc5e0503a6394e51ecf9758e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Feb 2010 22:36:47 +0100 Subject: [PATCH 004/341] Document arguments to Playlist.mpd_format() --- mopidy/models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/models.py b/mopidy/models.py index 37b0853a..c1bf83a7 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -209,6 +209,12 @@ class Playlist(object): """ Format playlist for output to MPD client. + Optionally limit output to the slice ``[start:end]`` of the playlist. + + :param start: position of first track to include in output + :type start: int + :param end: position after last track to include in output + :type end: int or :class:`None` for end of list :rtype: list of lists of two-tuples """ if end is None: From 035e43b4f5430e6cf8201ae038f46fe07361350e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Feb 2010 22:56:22 +0100 Subject: [PATCH 005/341] Replace BaseBackend with new API, keeping the existing logic. MPD formatting moved to MpdHandler. --- mopidy/backends/__init__.py | 314 ++++++++++++++++++------------------ mopidy/mpd/handler.py | 39 ++++- 2 files changed, 191 insertions(+), 162 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index af3e1b23..2cb4c692 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -1,23 +1,96 @@ +from copy import copy import logging +import random import time -from mopidy.exceptions import MpdNotImplemented from mopidy.models import Playlist logger = logging.getLogger('backends.base') class BaseBackend(object): - PLAY = u'play' - PAUSE = u'pause' - STOP = u'stop' + current_playlist = None + library = None + playback = None + stored_playlists = None + uri_handlers = [] - def __init__(self, *args, **kwargs): - self._state = self.STOP - self._playlists = [] - self._x_current_playlist = Playlist() - self._current_playlist_version = 0 -# Backend state +class BaseCurrentPlaylistController(object): + def __init__(self, backend): + self.backend = backend + self.version = 0 + self.playlist = Playlist() + + @property + def playlist(self): + return copy(self._playlist) + + @playlist.setter + def playlist(self, new_playlist): + self._playlist = new_playlist + self.version += 1 + + def add(self, uri, at_position=None): + raise NotImplementedError + + def clear(self): + self.backend.playback.stop() + self.playlist = Playlist() + + def load(self, playlist): + self.playlist = playlist + self.version = 0 + + def move(self, start, end, to_position): + tracks = self.playlist.tracks + new_tracks = tracks[:start] + tracks[end:] + + for track in tracks[start:end]: + new_tracks.insert(to_position, track) + to_position += 1 + + self.playlist = self.playlist.with_(tracks=new_tracks) + + def remove(self, position): + tracks = self.playlist.tracks + del tracks[position] + self.playlist = self.playlist.with_(tracks=tracks) + + def shuffle(self, start=None, end=None): + tracks = self.playlist.tracks + + before = tracks[:start or 0] + shuffled = tracks[start:end] + after = tracks[end or len(tracks):] + + random.shuffle(shuffled) + + self.playlist = self.playlist.with_(tracks=before+shuffled+after) + + +class BasePlaybackController(object): + PAUSED = u'paused' + PLAYING = u'playing' + STOPPED = u'stopped' + + def __init__(self, backend): + self.backend = backend + self.consume = False + self.current_track = None + self.random = False + self.repeat = False + self.state = self.STOPPED + self.volume = None + + @property + def playlist_position(self): + if self.current_track is None: + return None + try: + return self.backend.current_playlist.playlist.index( + self.current_track) + except ValueError: + return None @property def state(self): @@ -35,7 +108,7 @@ class BaseBackend(object): self._play_time_resume() @property - def _play_time_elapsed(self): + def time_position(self): if self.state == self.PLAY: time_since_started = int(time.time()) - self._play_time_started return self._play_time_accumulated + time_since_started @@ -55,184 +128,113 @@ class BaseBackend(object): def _play_time_resume(self): self._play_time_started = int(time.time()) - @property - def _current_playlist(self): - return self._x_current_playlist - - @_current_playlist.setter - def _current_playlist(self, playlist): - self._x_current_playlist = playlist - self._current_playlist_version += 1 - - @property - def _current_track(self): - if self._current_song_pos is not None: - return self._current_playlist.tracks[self._current_song_pos] - - @property - def _current_song_pos(self): - if not hasattr(self, '_x_current_song_pos'): - self._x_current_song_pos = None - if (self._current_playlist is None - or self._current_playlist.length == 0): - self._x_current_song_pos = None - elif self._x_current_song_pos < 0: - self._x_current_song_pos = 0 - elif self._x_current_song_pos >= self._current_playlist.length: - self._x_current_song_pos = self._current_playlist.length - 1 - return self._x_current_song_pos - - @_current_song_pos.setter - def _current_song_pos(self, songid): - self._x_current_song_pos = songid - -# Status methods - - def current_song(self): - if self.state is not self.STOP and self._current_track is not None: - return self._current_track.mpd_format(self._current_song_pos) - - def status_bitrate(self): - return 0 - - def status_consume(self): - return 0 - - def status_volume(self): - return 0 - - def status_repeat(self): - return 0 - - def status_random(self): - return 0 - - def status_single(self): - return 0 - - def status_song_id(self): - return self._current_song_pos # Override if you got a better ID scheme - - def status_playlist(self): - return self._current_playlist_version - - def status_playlist_length(self): - return self._current_playlist.length - - def status_state(self): - return self.state - - def status_time(self): - return u'%s:%s' % (self._play_time_elapsed, self.status_time_total()) - - def status_time_total(self): - if self._current_track is not None: - return self._current_track.length // 1000 - else: - return 0 - - def status_xfade(self): - return 0 - - def url_handlers(self): - return [] - -# Control methods - - def end_of_track(self): - self.next() + def new_playlist_loaded_callback(self): + self.current_track = None + if self.state == self.PLAYING: + if self.backend.current_playlist.playlist.length > 0: + self.play(self.backend.current_playlist.playlist.tracks[0]) + else: + self.stop() def next(self): self.stop() if self._next(): - self.state = self.PLAY + self.state = self.PLAYING def _next(self): - raise MpdNotImplemented + raise NotImplementedError def pause(self): - if self.state == self.PLAY and self._pause(): - self.state = self.PAUSE + if self.state == self.PLAYING and self._pause(): + self.state = self.PAUSED def _pause(self): - raise MpdNotImplemented + raise NotImplementedError - def play(self, songpos=None, songid=None): - if self.state == self.PAUSE and songpos is None and songid is None: + def play(self, track=None): + if self.state == self.PAUSED and track is None: return self.resume() + if track is not None: + self.current_track = track self.stop() - if songpos is not None and self._play_pos(songpos): - self.state = self.PLAY - elif songid is not None and self._play_id(songid): - self.state = self.PLAY - elif self._play(): - self.state = self.PLAY + if self._play(track): + self.state = self.PLAYING - def _play(self): - raise MpdNotImplemented - - def _play_id(self, songid): - raise MpdNotImplemented - - def _play_pos(self, songpos): - raise MpdNotImplemented + def _play(self, track): + raise NotImplementedError def previous(self): self.stop() if self._previous(): - self.state = self.PLAY + self.state = self.PLAYING def _previous(self): - raise MpdNotImplemented + raise NotImplementedError def resume(self): - if self.state == self.PAUSE and self._resume(): - self.state = self.PLAY + if self.state == self.PAUSED and self._resume(): + self.state = self.PLAYING def _resume(self): - raise MpdNotImplemented + raise NotImplementedError + + def seek(self, time_position): + raise NotImplementedError def stop(self): - if self.state != self.STOP and self._stop(): - self.state = self.STOP + if self.state != self.STOPPED and self._stop(): + self.state = self.STOPPED def _stop(self): - raise MpdNotImplemented + raise NotImplementedError -# Current/single playlist methods - def playlist_load(self, name): - self._current_song_pos = None - matches = filter(lambda p: p.name == name, self._playlists) - if matches: - self._current_playlist = matches[0] - if self.state == self.PLAY: - self.play(songpos=0) - else: - self._current_playlist = None +class BaseLibraryController(object): + def __init__(self, backend): + self.backend = backend - def playlist_changes_since(self, version='0'): - if int(version) < self._current_playlist_version: - return self._current_playlist.mpd_format() + def find_exact(self, type, query): + raise NotImplementedError - def playlist_info(self, songpos=None, start=0, end=None): - if songpos is not None: - start = int(songpos) - end = start + 1 - else: - if start is None: - start = 0 - start = int(start) - if end is not None: - end = int(end) - return self._current_playlist.mpd_format(start, end) + def lookup(self, uri): + raise NotImplementedError -# Stored playlist methods + def refresh(self, uri=None): + raise NotImplementedError - def playlists_list(self): - return [u'playlist: %s' % p.name for p in self._playlists] + def search(self, type, query): + raise NotImplementedError -# Music database methods - def search(self, type, what): - return None +class BaseStoredPlaylistController(object): + def __init__(self, backend): + self.backend = backend + self._playlists = [] + + @property + def playlists(self): + return copy(self._playlists) + + def add(self, uri): + raise NotImplementedError + + def create(self, name): + raise NotImplementedError + + def delete(self, playlist): + raise NotImplementedError + + def lookup(self, uri): + raise NotImplementedError + + def refresh(self): + raise NotImplementedError + + def rename(self, playlist, new_name): + raise NotImplementedError + + def save(self, playlist): + raise NotImplementedError + + def search(self, query): + return filter(lambda p: query in p.name, self._playlists) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index a331d2bd..a41d0b15 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -189,11 +189,15 @@ class MpdHandler(object): @register(r'^listplaylists$') def _listplaylists(self): - return self.backend.playlists_list() + return [u'playlist: %s' % p.name + for p in self.backend.stored_playlists.playlists] @register(r'^load "(?P[^"]+)"$') def _load(self, name): - return self.backend.playlist_load(name) + matches = self.backend.stored_playlists.search(name) + if matches: + self.backend.current_playlist.load(matches[0]) + self.backend.playback.new_playlist_loaded_callback() @register(r'^lsinfo$') @register(r'^lsinfo "(?P[^"]*)"$') @@ -264,13 +268,22 @@ class MpdHandler(object): @register(r'^playlistid( "(?P\S+)")*$') def _playlistid(self, songid=None): - return self.backend.playlist_info(songid, None, None) + return self.backend.current_playlist.playlist.mpd_format() @register(r'^playlistinfo$') @register(r'^playlistinfo "(?P\d+)"$') @register(r'^playlistinfo "(?P\d+):(?P\d+)*"$') def _playlistinfo(self, songpos=None, start=None, end=None): - return self.backend.playlist_info(songpos, start, end) + if songpos is not None: + return self.backend.current_playlist.playlist.mpd_format( + songpos, songpos + 1) + else: + if start is None: + start = 0 + start = int(start) + if end is not None: + end = int(end) + return self.backend.current_playlist.playlist.mpd_format(start, end) @register(r'^playlistmove "(?P[^"]+)" "(?P\d+)" "(?P\d+)"$') def _playlistdelete(self, name, songid, songpos): @@ -282,7 +295,8 @@ class MpdHandler(object): @register(r'^plchanges "(?P\d+)"$') def _plchanges(self, version): - return self.backend.playlist_changes_since(version) + if int(version) < self.backend.current_playlist.version: + return self.backend.current_playlist.playlist.mpd_format() @register(r'^plchangesposid "(?P\d+)"$') def _plchangesposid(self, version): @@ -400,10 +414,23 @@ class MpdHandler(object): result.append(('song', self.backend.status_song_id())) result.append(('songid', self.backend.status_song_id())) if self.backend.state in (self.backend.PLAY, self.backend.PAUSE): - result.append(('time', self.backend.status_time())) + result.append(('time', self._status_time())) result.append(('bitrate', self.backend.status_bitrate())) return result + def _status_time(self): + return u'%s:%s' % ( + self._status_time_elapsed(), self._status_time_total()) + + def _status_time_elapsed(self): + return self.backend.playback.time_position + + def _status_time_total(self): + if self.backend.playback.current_track is not None: + return self.backend.playback.current_track.length // 1000 + else: + return 0 + @register(r'^swap "(?P\d+)" "(?P\d+)"$') def _swap(self, songpos1, songpos2): raise MpdNotImplemented # TODO From 0eaee6b70d9d038a5a900cc3462fa95b8667e4ae Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Feb 2010 23:10:25 +0100 Subject: [PATCH 006/341] Reorder classes --- mopidy/backends/__init__.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 2cb4c692..41cc4289 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -68,6 +68,23 @@ class BaseCurrentPlaylistController(object): self.playlist = self.playlist.with_(tracks=before+shuffled+after) +class BaseLibraryController(object): + def __init__(self, backend): + self.backend = backend + + def find_exact(self, type, query): + raise NotImplementedError + + def lookup(self, uri): + raise NotImplementedError + + def refresh(self, uri=None): + raise NotImplementedError + + def search(self, type, query): + raise NotImplementedError + + class BasePlaybackController(object): PAUSED = u'paused' PLAYING = u'playing' @@ -189,23 +206,6 @@ class BasePlaybackController(object): raise NotImplementedError -class BaseLibraryController(object): - def __init__(self, backend): - self.backend = backend - - def find_exact(self, type, query): - raise NotImplementedError - - def lookup(self, uri): - raise NotImplementedError - - def refresh(self, uri=None): - raise NotImplementedError - - def search(self, type, query): - raise NotImplementedError - - class BaseStoredPlaylistController(object): def __init__(self, backend): self.backend = backend From 3d7fc78010389f6b3a35a8b242f2c038dfd4e10b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Feb 2010 23:16:40 +0100 Subject: [PATCH 007/341] Fix old use of state constants --- mopidy/backends/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 41cc4289..f453a83b 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -92,6 +92,7 @@ class BasePlaybackController(object): def __init__(self, backend): self.backend = backend + self._state = self.STOPPED self.consume = False self.current_track = None self.random = False @@ -117,21 +118,22 @@ class BasePlaybackController(object): def state(self, new_state): (old_state, self._state) = (self.state, new_state) logger.debug(u'Changing state: %s -> %s', old_state, new_state) - if old_state in (self.PLAY, self.STOP) and new_state == self.PLAY: + if (old_state in (self.PLAYING, self.STOPPED) + and new_state == self.PLAYING): self._play_time_start() - elif old_state == self.PLAY and new_state == self.PAUSE: + elif old_state == self.PLAYING and new_state == self.PAUSED: self._play_time_pause() - elif old_state == self.PAUSE and new_state == self.PLAY: + elif old_state == self.PAUSED and new_state == self.PLAYING: self._play_time_resume() @property def time_position(self): - if self.state == self.PLAY: + if self.state == self.PLAYING: time_since_started = int(time.time()) - self._play_time_started return self._play_time_accumulated + time_since_started - elif self.state == self.PAUSE: + elif self.state == self.PAUSED: return self._play_time_accumulated - elif self.state == self.STOP: + elif self.state == self.STOPPED: return 0 def _play_time_start(self): From 2a30d40ed518e1bf54c58533a5fd25b1b9ac969f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Feb 2010 23:17:22 +0100 Subject: [PATCH 008/341] Fix typo in BaseStoredPlaylistsController class name --- mopidy/backends/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index f453a83b..99e8b2d6 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -208,7 +208,7 @@ class BasePlaybackController(object): raise NotImplementedError -class BaseStoredPlaylistController(object): +class BaseStoredPlaylistsController(object): def __init__(self, backend): self.backend = backend self._playlists = [] From acdfff5b61643ae62d5df2e141a7e6d6266bbd6f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Feb 2010 23:36:53 +0100 Subject: [PATCH 009/341] Add get_by_id() and get_by_uri() to BaseCurrentPlaylistController --- docs/api/backends.rst | 14 ++++++++++++++ mopidy/backends/__init__.py | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index afcded4d..fe5a96e7 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -52,6 +52,20 @@ Clear the current playlist. + .. method:: get_by_id(id) + + Get track by ID. Raises :class:`KeyError` if not found. + + :param id: track ID + :type id: int + + .. method:: get_by_uri(uri) + + Get track by URI. Raises :class:`KeyError` if not found. + + :param uri: track URI + :type uri: string + .. method:: load(playlist) Replace the current playlist with the given playlist. diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 99e8b2d6..416a64a0 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -37,6 +37,20 @@ class BaseCurrentPlaylistController(object): self.backend.playback.stop() self.playlist = Playlist() + def get_by_id(self, id): + matches = filter(lambda t: t.id == id, self._playlist.tracks) + if matches: + return matches[0] + else: + raise KeyError('Track with ID "%s" not found' % id) + + def get_by_uri(self, uri): + matches = filter(lambda t: t.uri == uri, self._playlist.tracks) + if matches: + return matches[0] + else: + raise KeyError('Track with URI "%s" not found' % uri) + def load(self, playlist): self.playlist = playlist self.version = 0 From e981edc2ccbf2731774137bfba0fb465f58c86a3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Feb 2010 00:10:50 +0100 Subject: [PATCH 010/341] Fix error in BasePlaybackController.playlist_position() --- mopidy/backends/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 416a64a0..6f1ddd69 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -119,7 +119,7 @@ class BasePlaybackController(object): if self.current_track is None: return None try: - return self.backend.current_playlist.playlist.index( + return self.backend.current_playlist.playlist.tracks.index( self.current_track) except ValueError: return None From 98e333bafafc0161a256b2df895d269825910aab Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Feb 2010 00:11:36 +0100 Subject: [PATCH 011/341] Update DummyBackend to adhere to new backend API --- mopidy/backends/dummy.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 8ebf3ac2..d88e3a0b 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -1,25 +1,30 @@ -from mopidy.backends import BaseBackend +from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, + BasePlaybackController, BaseLibraryController, + BaseStoredPlaylistsController) class DummyBackend(BaseBackend): - def __init__(self, *args, **kwargs): - super(DummyBackend, self).__init__(*args, **kwargs) + def __init__(self): + self.current_playlist = DummyCurrentPlaylistController(backend=self) + self.library = DummyLibraryController(backend=self) + self.playback = DummyPlaybackController(backend=self) + self.stored_playlists = DummyStoredPlaylistsController(backend=self) + self.uri_handlers = [u'dummy:'] - def url_handlers(self): - return [u'dummy:'] +class DummyCurrentPlaylistController(BaseCurrentPlaylistController): + pass +class DummyLibraryController(BaseLibraryController): + def search(self, type, query): + return [] + +class DummyPlaybackController(BasePlaybackController): def _next(self): return True def _pause(self): return True - def _play(self): - return True - - def _play_id(self, songid): - return True - - def _play_pos(self, songpos): + def _play(self, track): return True def _previous(self): @@ -27,3 +32,6 @@ class DummyBackend(BaseBackend): def _resume(self): return True + +class DummyStoredPlaylistsController(BaseStoredPlaylistsController): + pass From 46af63ab7ecb5788ec9aae7019ae74a6ac4b68f1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Feb 2010 00:12:05 +0100 Subject: [PATCH 012/341] Update MpdHandler to use new backend API --- mopidy/mpd/handler.py | 120 +++++++++++++++++++++++++++++++-------- tests/mpd/handlertest.py | 26 ++++++--- 2 files changed, 113 insertions(+), 33 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index a41d0b15..41ea21ce 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -132,7 +132,8 @@ class MpdHandler(object): @register(r'^currentsong$') def _currentsong(self): - return self.backend.current_song() + if self.backend.playback.current_track is not None: + return self.backend.playback.current_track.mpd_format() @register(r'^delete "(?P\d+)"$') @register(r'^delete "(?P\d+):(?P\d+)*"$') @@ -217,7 +218,7 @@ class MpdHandler(object): @register(r'^next$') def _next(self): - return self.backend.next() + return self.backend.playback.next() @register(r'^password "(?P[^"]+)"$') def _password(self, password): @@ -226,9 +227,9 @@ class MpdHandler(object): @register(r'^pause "(?P[01])"$') def _pause(self, state): if int(state): - self.backend.pause() + self.backend.playback.pause() else: - self.backend.resume() + self.backend.playback.resume() @register(r'^ping$') def _ping(self): @@ -236,15 +237,25 @@ class MpdHandler(object): @register(r'^play$') def _play(self): - return self.backend.play() + return self.backend.playback.play() @register(r'^play "(?P\d+)"$') def _playpos(self, songpos): - return self.backend.play(songpos=int(songpos)) + songpos = int(songpos) + try: + track = self.backend.current_playlist.playlist.tracks[songpos] + return self.backend.playback.play(track) + except IndexError: + raise MpdAckError(u'Position out of bounds') @register(r'^playid "(?P\d+)"$') def _playid(self, songid): - return self.backend.play(songid=int(songid)) + songid = int(songid) + try: + track = self.backend.current_playlist.get_by_id(songid) + return self.backend.playback.play(track) + except KeyError, e: + raise MpdAckError(unicode(e)) @register(r'^playlist$') def _playlist(self): @@ -275,6 +286,7 @@ class MpdHandler(object): @register(r'^playlistinfo "(?P\d+):(?P\d+)*"$') def _playlistinfo(self, songpos=None, start=None, end=None): if songpos is not None: + songpos = int(songpos) return self.backend.current_playlist.playlist.mpd_format( songpos, songpos + 1) else: @@ -304,7 +316,7 @@ class MpdHandler(object): @register(r'^previous$') def _previous(self): - return self.backend.previous() + return self.backend.playback.previous() @register(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') def _rename(self, old_name, new_name): @@ -348,7 +360,7 @@ class MpdHandler(object): @register(r'^search "(?P(album|artist|filename|title))" "(?P[^"]+)"$') def _search(self, type, what): - return self.backend.search(type, what) + return self.backend.library.search(type, what) @register(r'^seek "(?P\d+)" "(?P\d+)"$') def _seek(self, songpos, seconds): @@ -395,29 +407,78 @@ class MpdHandler(object): @register(r'^stop$') def _stop(self): - self.backend.stop() + self.backend.playback.stop() @register(r'^status$') def _status(self): result = [ - ('volume', self.backend.status_volume()), - ('repeat', self.backend.status_repeat()), - ('random', self.backend.status_random()), - ('single', self.backend.status_single()), - ('consume', self.backend.status_consume()), - ('playlist', self.backend.status_playlist()), - ('playlistlength', self.backend.status_playlist_length()), - ('xfade', self.backend.status_xfade()), - ('state', self.backend.status_state()), + ('volume', self._status_volume()), + ('repeat', self._status_repeat()), + ('random', self._status_random()), + ('single', self._status_single()), + ('consume', self._status_consume()), + ('playlist', self._status_playlist_version()), + ('playlistlength', self._status_playlist_length()), + ('xfade', self._status_xfade()), + ('state', self._status_state()), ] - if self.backend.status_playlist_length() > 0: - result.append(('song', self.backend.status_song_id())) - result.append(('songid', self.backend.status_song_id())) - if self.backend.state in (self.backend.PLAY, self.backend.PAUSE): + if self.backend.playback.current_track is not None: + result.append(('song', self._status_songpos())) + result.append(('songid', self._status_songid())) + if self.backend.playback.state in ( + self.backend.playback.PLAYING, self.backend.playback.PAUSED): result.append(('time', self._status_time())) - result.append(('bitrate', self.backend.status_bitrate())) + result.append(('bitrate', self._status_bitrate())) return result + def _status_bitrate(self): + if self.backend.playback.current_track is not None: + return self.backend.playback.current_track.bitrate + + def _status_consume(self): + if self.backend.playback.consume: + return 1 + else: + return 0 + + def _status_playlist_length(self): + return self.backend.current_playlist.playlist.length + + def _status_playlist_version(self): + return self.backend.current_playlist.version + + def _status_random(self): + if self.backend.playback.random: + return 1 + else: + return 0 + + def _status_repeat(self): + if self.backend.playback.repeat: + return 1 + else: + return 0 + + def _status_single(self): + return 0 # TODO + + def _status_songid(self): + if self.backend.playback.current_track.id is not None: + return self.backend.playback.current_track.id + else: + return self._status_songpos() + + def _status_songpos(self): + return self.backend.playback.playlist_position + + def _status_state(self): + if self.backend.playback.state == self.backend.playback.PLAYING: + return u'play' + elif self.backend.playback.state == self.backend.playback.STOPPED: + return u'stop' + elif self.backend.playback.state == self.backend.playback.PAUSED: + return u'pause' + def _status_time(self): return u'%s:%s' % ( self._status_time_elapsed(), self._status_time_total()) @@ -431,6 +492,15 @@ class MpdHandler(object): else: return 0 + def _status_volume(self): + if self.backend.playback.volume is not None: + return self.backend.playback.volume + else: + return 0 + + def _status_xfade(self): + return 0 # TODO + @register(r'^swap "(?P\d+)" "(?P\d+)"$') def _swap(self, songpos1, songpos2): raise MpdNotImplemented # TODO @@ -445,4 +515,4 @@ class MpdHandler(object): @register(r'^urlhandlers$') def _urlhandlers(self): - return self.backend.url_handlers() + return self.backend.uri_handlers diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 653044db..70304a36 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -152,7 +152,9 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(result['state'] in ('play', 'stop', 'pause')) def test_status_method_when_playlist_loaded(self): - self.b._current_playlist = Playlist(tracks=[Track()]) + track = Track() + self.b.current_playlist.load(Playlist(tracks=[track])) + self.b.playback.current_track = track result = dict(self.h._status()) self.assert_('song' in result) self.assert_(int(result['song']) >= 0) @@ -160,7 +162,7 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(int(result['songid']) >= 0) def test_status_method_when_playing(self): - self.b.state = self.b.PLAY + self.b.playback.state = self.b.playback.PLAYING result = dict(self.h._status()) self.assert_('time' in result) (position, total) = result['time'].split(':') @@ -283,28 +285,36 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.h.handle_request(u'pause "1"') result = self.h.handle_request(u'pause "0"') self.assert_(u'OK' in result) - self.assertEquals(self.b.PLAY, self.b.state) + self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) def test_pause_on(self): self.h.handle_request(u'play') result = self.h.handle_request(u'pause "1"') self.assert_(u'OK' in result) - self.assertEquals(self.b.PAUSE, self.b.state) + self.assertEquals(self.b.playback.PAUSED, self.b.playback.state) def test_play_without_pos(self): result = self.h.handle_request(u'play') self.assert_(u'OK' in result) - self.assertEquals(self.b.PLAY, self.b.state) + self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) def test_play_with_pos(self): + self.b.current_playlist.load(Playlist(tracks=[Track()])) result = self.h.handle_request(u'play "0"') self.assert_(u'OK' in result) - self.assertEquals(self.b.PLAY, self.b.state) + self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) + + def test_play_with_pos_out_of_bounds(self): + self.b.current_playlist.load(Playlist()) + result = self.h.handle_request(u'play "0"') + self.assert_(u'ACK Position out of bounds' in result) + self.assertEquals(self.b.playback.STOPPED, self.b.playback.state) def test_playid(self): + self.b.current_playlist.load(Playlist(tracks=[Track(id=0)])) result = self.h.handle_request(u'playid "0"') self.assert_(u'OK' in result) - self.assertEquals(self.b.PLAY, self.b.state) + self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) def test_previous(self): result = self.h.handle_request(u'previous') @@ -321,7 +331,7 @@ class PlaybackControlHandlerTest(unittest.TestCase): def test_stop(self): result = self.h.handle_request(u'stop') self.assert_(u'OK' in result) - self.assertEquals(self.b.STOP, self.b.state) + self.assertEquals(self.b.playback.STOPPED, self.b.playback.state) class CurrentPlaylistHandlerTest(unittest.TestCase): From 13819b62b3e72446977f54160c414eef55150ca7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Feb 2010 00:20:25 +0100 Subject: [PATCH 013/341] Test full currentsong output --- mopidy/models.py | 15 ++++++++------- tests/mpd/handlertest.py | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index c1bf83a7..8880651e 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -150,15 +150,16 @@ class Track(object): :rtype: list of two-tuples """ return [ - ('file', self.uri), - ('Time', self.length // 1000), + ('file', self.uri or ''), + ('Time', self.length and (self.length // 1000) or 0), ('Artist', self.mpd_format_artists()), - ('Title', self.title), - ('Album', self.album.name), - ('Track', '%d/%d' % (self.track_no, self.album.num_tracks)), - ('Date', self.date), + ('Title', self.title or ''), + ('Album', self.album and self.album.name or ''), + ('Track', '%d/%d' % (self.track_no, + self.album and self.album.num_tracks or 0)), + ('Date', self.date or ''), ('Pos', position), - ('Id', self.id), + ('Id', self.id or position), ] def mpd_format_artists(self): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 70304a36..c130725d 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -94,6 +94,20 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(u'ACK Not implemented' in result) def test_currentsong(self): + self.b.playback.current_track = Track() + result = self.h.handle_request(u'currentsong') + self.assert_(u'file: ' in result) + self.assert_(u'Time: 0' in result) + self.assert_(u'Artist: ' in result) + self.assert_(u'Title: ' in result) + self.assert_(u'Album: ' in result) + self.assert_(u'Track: 0/0' in result) + self.assert_(u'Date: ' in result) + self.assert_(u'Pos: 0' in result) + self.assert_(u'Id: 0' in result) + self.assert_(u'OK' in result) + + def test_currentsong_without_song(self): result = self.h.handle_request(u'currentsong') self.assert_(u'OK' in result) From 63eba83dd0cec8346dae9dca74c54cb297aef4ca Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Feb 2010 09:39:36 +0100 Subject: [PATCH 014/341] Add Thomas Adamcik to AUTHORS.rst --- AUTHORS.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index 8e08456e..65e7d950 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -5,3 +5,4 @@ Contributors to Mopidy in the order of appearance: * Stein Magnus Jodal * Johannes Knutsen +* Thomas Adamcik From c4cb47df6c390c85f9bf0c4129aa3a585a522a30 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Feb 2010 12:11:34 +0100 Subject: [PATCH 015/341] Move Backend API docs from docs/ to docstrings --- docs/api/backends.rst | 317 +----------------------------------- mopidy/backends/__init__.py | 237 +++++++++++++++++++++++++-- 2 files changed, 224 insertions(+), 330 deletions(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index fe5a96e7..56d6f059 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -2,318 +2,7 @@ :mod:`mopidy.backends` -- Backend API ************************************* -.. warning:: - This is our *planned* backend API, and not the current API. - -.. module:: mopidy.backends +.. automodule:: mopidy.backends :synopsis: Interface between Mopidy and its various backends. - -.. class:: BaseBackend() - - .. attribute:: current_playlist - - The current playlist controller. An instance of - :class:`BaseCurrentPlaylistController`. - - .. attribute:: library - - The library controller. An instance of :class:`BaseLibraryController`. - - .. attribute:: playback - - The playback controller. An instance of :class:`BasePlaybackController`. - - .. attribute:: stored_playlists - - The stored playlists controller. An instance of - :class:`BaseStoredPlaylistsController`. - - .. attribute:: uri_handlers - - List of URI prefixes this backend can handle. - - -.. class:: BaseCurrentPlaylistController(backend) - - :param backend: backend the controller is a part of - :type backend: :class:`BaseBackend` - - .. method:: add(track, at_position=None) - - Add the track to the end of, or at the given position in the current - playlist. - - :param track: track to add - :type track: :class:`mopidy.models.Track` - :param at_position: position in current playlist to add track - :type at_position: int or :class:`None` - - .. method:: clear() - - Clear the current playlist. - - .. method:: get_by_id(id) - - Get track by ID. Raises :class:`KeyError` if not found. - - :param id: track ID - :type id: int - - .. method:: get_by_uri(uri) - - Get track by URI. Raises :class:`KeyError` if not found. - - :param uri: track URI - :type uri: string - - .. method:: load(playlist) - - Replace the current playlist with the given playlist. - - :param playlist: playlist to load - :type playlist: :class:`mopidy.models.Playlist` - - .. method:: move(start, end, to_position) - - Move the tracks in the slice ``[start:end]`` to ``to_position``. - - :param start: position of first track to move - :type start: int - :param end: position after last track to move - :type end: int - :param to_position: new position for the tracks - :type to_position: int - - .. attribute:: playlist - - The currently loaded :class:`mopidy.models.Playlist`. - - .. method:: remove(track) - - Remove the track from the current playlist. - - :param track: track to remove - :type track: :class:`mopidy.models.Track` - - .. method:: shuffle(start=None, end=None) - - Shuffles the entire playlist. If ``start`` and ``end`` is given only - shuffles the slice ``[start:end]``. - - :param start: position of first track to shuffle - :type start: int or :class:`None` - :param end: position after last track to shuffle - :type end: int or :class:`None` - - .. attribute:: version - - The current playlist version. Integer which is increased every time the - current playlist is changed. - - -.. class:: BasePlaybackController(backend) - - :param backend: backend the controller is a part of - :type backend: :class:`BaseBackend` - - .. attribute:: consume - - :class:`True` - Tracks are removed from the playlist when they have been played. - :class:`False` - Tracks are not removed from the playlist. - - .. attribute:: current_track - - The currently playing or selected :class:`mopidy.models.Track`. - - .. method:: new_playlist_loaded_callback() - - Tell the playback controller that a new playlist has been loaded. - - .. method:: next() - - Play the next track. - - .. method:: pause() - - Pause playblack. - - .. attribute:: PAUSED - - Constant representing the paused state. - - .. method:: play(track=None) - - Play the given track or the currently active track. - - :param track: track to play - :type track: :class:`mopidy.models.Track` or :class:`None` - - .. attribute:: PLAYING - - Constant representing the playing state. - - .. attribute:: playlist_position - - The position in the current playlist. - - .. method:: previous() - - Play the previous track. - - .. attribute:: random - - :class:`True` - Tracks are selected at random from the playlist. - :class:`False` - Tracks are played in the order of the playlist. - - .. attribute:: repeat - - :class:`True` - The current track is played repeatedly. - :class:`False` - The current track is played once. - - .. method:: resume() - - If paused, resume playing the current track. - - .. method:: seek(time_position) - - Seeks to time position given in milliseconds. - - :param time_position: time position in milliseconds - :type time_position: int - - .. attribute:: state - - The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or - :attr:`STOPPED`. - - .. method:: stop() - - Stop playing. - - .. attribute:: STOPPED - - Constant representing the stopped state. - - .. attribute:: time_position - - Time position in milliseconds. - - .. attribute:: volume - - The audio volume as an int in the range [0, 100]. :class:`None` if - unknown. - - -.. class:: BaseLibraryController(backend) - - :param backend: backend the controller is a part of - :type backend: :class:`BaseBackend` - - .. method:: find_exact(type, query) - - Find tracks in the library where ``type`` matches ``query`` exactly. - - :param type: 'title', 'artist', or 'album' - :type type: string - :param query: the search query - :type query: string - :rtype: list of :class:`mopidy.models.Track` - - .. method:: lookup(uri) - - Lookup track with given URI. - - :param uri: track URI - :type uri: string - :rtype: :class:`mopidy.models.Track` - - .. method:: refresh(uri=None) - - Refresh library. Limit to URI and below if an URI is given. - - :param uri: directory or track URI - :type uri: string - - .. method:: search(type, query) - - Search the library for tracks where ``type`` contains ``query``. - - :param type: 'title', 'artist', 'album', or 'uri' - :type type: string - :param query: the search query - :type query: string - :rtype: list of :class:`mopidy.models.Track` - - -.. class:: BaseStoredPlaylistsController(backend) - - :param backend: backend the controller is a part of - :type backend: :class:`BaseBackend` - - .. method:: add(uri) - - Add existing playlist with the given URI. - - :param uri: URI of existing playlist - :type uri: string - - .. method:: create(name) - - Create a new playlist. - - :param name: name of the new playlist - :type name: string - :rtype: :class:`mopidy.models.Playlist` - - .. attribute:: playlists - - List of :class:`mopidy.models.Playlist`. - - .. method:: delete(playlist) - - Delete playlist. - - :param playlist: the playlist to delete - :type playlist: :class:`mopidy.models.Playlist` - - .. method:: lookup(uri) - - Lookup playlist with given URI. - - :param uri: playlist URI - :type uri: string - :rtype: :class:`mopidy.models.Playlist` - - .. method:: refresh() - - Refresh stored playlists. - - .. method:: rename(playlist, new_name) - - Rename playlist. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - :param new_name: the new name - :type new_name: string - - .. method:: save(playlist) - - Save the playlist. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - - .. method:: search(query) - - Search for playlists whose name contains ``query``. - - :param query: query to search for - :type query: string - :rtype: list of :class:`mopidy.models.Playlist` + :members: + :undoc-members: diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 6f1ddd69..92e79000 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -8,21 +8,41 @@ from mopidy.models import Playlist logger = logging.getLogger('backends.base') class BaseBackend(object): + #: The current playlist controller. An instance of + #: :class:`BaseCurrentPlaylistController`. current_playlist = None + + #: The library controller. An instance of :class:`BaseLibraryController`. library = None + + #: The playback controller. An instance of :class:`BasePlaybackController`. playback = None + + #: The stored playlists controller. An instance of + #: :class:`BaseStoredPlaylistsController`. stored_playlists = None + + #: List of URI prefixes this backend can handle. uri_handlers = [] class BaseCurrentPlaylistController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ + + #: The current playlist version. Integer which is increased every time the + #: current playlist is changed. + version = 0 + def __init__(self, backend): self.backend = backend - self.version = 0 self.playlist = Playlist() @property def playlist(self): + """The currently loaded :class:`mopidy.models.Playlist`.""" return copy(self._playlist) @playlist.setter @@ -30,14 +50,30 @@ class BaseCurrentPlaylistController(object): self._playlist = new_playlist self.version += 1 - def add(self, uri, at_position=None): + def add(self, track, at_position=None): + """ + Add the track to the end of, or at the given position in the current + playlist. + + :param track: track to add + :type track: :class:`mopidy.models.Track` + :param at_position: position in current playlist to add track + :type at_position: int or :class:`None` + """ raise NotImplementedError def clear(self): + """Clear the current playlist.""" self.backend.playback.stop() self.playlist = Playlist() def get_by_id(self, id): + """ + Get track by ID. Raises :class:`KeyError` if not found. + + :param id: track ID + :type id: int + """ matches = filter(lambda t: t.id == id, self._playlist.tracks) if matches: return matches[0] @@ -45,6 +81,12 @@ class BaseCurrentPlaylistController(object): raise KeyError('Track with ID "%s" not found' % id) def get_by_uri(self, uri): + """ + Get track by URI. Raises :class:`KeyError` if not found. + + :param uri: track URI + :type uri: string + """ matches = filter(lambda t: t.uri == uri, self._playlist.tracks) if matches: return matches[0] @@ -52,70 +94,164 @@ class BaseCurrentPlaylistController(object): raise KeyError('Track with URI "%s" not found' % uri) def load(self, playlist): + """ + Replace the current playlist with the given playlist. + + :param playlist: playlist to load + :type playlist: :class:`mopidy.models.Playlist` + """ self.playlist = playlist self.version = 0 def move(self, start, end, to_position): + """ + Move the tracks in the slice ``[start:end]`` to ``to_position``. + + :param start: position of first track to move + :type start: int + :param end: position after last track to move + :type end: int + :param to_position: new position for the tracks + :type to_position: int + """ tracks = self.playlist.tracks new_tracks = tracks[:start] + tracks[end:] - for track in tracks[start:end]: new_tracks.insert(to_position, track) to_position += 1 - self.playlist = self.playlist.with_(tracks=new_tracks) - def remove(self, position): + def remove(self, track): + """ + Remove the track from the current playlist. + + :param track: track to remove + :type track: :class:`mopidy.models.Track` + """ tracks = self.playlist.tracks + position = tracks.index(track) del tracks[position] self.playlist = self.playlist.with_(tracks=tracks) def shuffle(self, start=None, end=None): - tracks = self.playlist.tracks + """ + Shuffles the entire playlist. If ``start`` and ``end`` is given only + shuffles the slice ``[start:end]``. + :param start: position of first track to shuffle + :type start: int or :class:`None` + :param end: position after last track to shuffle + :type end: int or :class:`None` + """ + tracks = self.playlist.tracks before = tracks[:start or 0] shuffled = tracks[start:end] after = tracks[end or len(tracks):] - random.shuffle(shuffled) - self.playlist = self.playlist.with_(tracks=before+shuffled+after) class BaseLibraryController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ + def __init__(self, backend): self.backend = backend def find_exact(self, type, query): + """ + Find tracks in the library where ``type`` matches ``query`` exactly. + + :param type: 'title', 'artist', or 'album' + :type type: string + :param query: the search query + :type query: string + :rtype: list of :class:`mopidy.models.Track` + """ raise NotImplementedError def lookup(self, uri): + """ + Lookup track with given URI. + + :param uri: track URI + :type uri: string + :rtype: :class:`mopidy.models.Track` + """ raise NotImplementedError def refresh(self, uri=None): + """ + Refresh library. Limit to URI and below if an URI is given. + + :param uri: directory or track URI + :type uri: string + """ raise NotImplementedError def search(self, type, query): + """ + Search the library for tracks where ``type`` contains ``query``. + + :param type: 'title', 'artist', 'album', or 'uri' + :type type: string + :param query: the search query + :type query: string + :rtype: list of :class:`mopidy.models.Track` + """ raise NotImplementedError class BasePlaybackController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ + + #: Constant representing the paused state. PAUSED = u'paused' + + #: Constant representing the playing state. PLAYING = u'playing' + + #: Constant representing the stopped state. STOPPED = u'stopped' + #: :class:`True` + #: Tracks are removed from the playlist when they have been played. + #: :class:`False` + #: Tracks are not removed from the playlist. + consume = False + + #: The currently playing or selected :class:`mopidy.models.Track`. + current_track = None + + #: :class:`True` + #: Tracks are selected at random from the playlist. + #: :class:`False` + #: Tracks are played in the order of the playlist. + random = False + + #: :class:`True` + #: The current track is played repeatedly. + #: :class:`False` + #: The current track is played once. + repeat = False + + #: The audio volume as an int in the range [0, 100]. :class:`None` if + #: unknown. + volume = None + def __init__(self, backend): self.backend = backend self._state = self.STOPPED - self.consume = False - self.current_track = None - self.random = False - self.repeat = False self.state = self.STOPPED - self.volume = None @property def playlist_position(self): + """The position in the current playlist.""" if self.current_track is None: return None try: @@ -126,6 +262,10 @@ class BasePlaybackController(object): @property def state(self): + """ + The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or + :attr:`STOPPED`. + """ return self._state @state.setter @@ -142,6 +282,7 @@ class BasePlaybackController(object): @property def time_position(self): + """Time position in milliseconds.""" if self.state == self.PLAYING: time_since_started = int(time.time()) - self._play_time_started return self._play_time_accumulated + time_since_started @@ -162,6 +303,7 @@ class BasePlaybackController(object): self._play_time_started = int(time.time()) def new_playlist_loaded_callback(self): + """Tell the playback controller that a new playlist has been loaded.""" self.current_track = None if self.state == self.PLAYING: if self.backend.current_playlist.playlist.length > 0: @@ -170,6 +312,7 @@ class BasePlaybackController(object): self.stop() def next(self): + """Play the next track.""" self.stop() if self._next(): self.state = self.PLAYING @@ -178,6 +321,7 @@ class BasePlaybackController(object): raise NotImplementedError def pause(self): + """Pause playback.""" if self.state == self.PLAYING and self._pause(): self.state = self.PAUSED @@ -185,6 +329,12 @@ class BasePlaybackController(object): raise NotImplementedError def play(self, track=None): + """ + Play the given track or the currently active track. + + :param track: track to play + :type track: :class:`mopidy.models.Track` or :class:`None` + """ if self.state == self.PAUSED and track is None: return self.resume() if track is not None: @@ -197,6 +347,7 @@ class BasePlaybackController(object): raise NotImplementedError def previous(self): + """Play the previous track.""" self.stop() if self._previous(): self.state = self.PLAYING @@ -205,6 +356,7 @@ class BasePlaybackController(object): raise NotImplementedError def resume(self): + """If paused, resume playing the current track.""" if self.state == self.PAUSED and self._resume(): self.state = self.PLAYING @@ -212,9 +364,16 @@ class BasePlaybackController(object): raise NotImplementedError def seek(self, time_position): + """ + Seeks to time position given in milliseconds. + + :param time_position: time position in milliseconds + :type time_position: int + """ raise NotImplementedError def stop(self): + """Stop playing.""" if self.state != self.STOPPED and self._stop(): self.state = self.STOPPED @@ -223,34 +382,80 @@ class BasePlaybackController(object): class BaseStoredPlaylistsController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ + def __init__(self, backend): self.backend = backend self._playlists = [] @property def playlists(self): + """List of :class:`mopidy.models.Playlist`.""" return copy(self._playlists) - def add(self, uri): - raise NotImplementedError - def create(self, name): + """ + Create a new playlist. + + :param name: name of the new playlist + :type name: string + :rtype: :class:`mopidy.models.Playlist` + """ raise NotImplementedError def delete(self, playlist): + """ + Delete playlist. + + :param playlist: the playlist to delete + :type playlist: :class:`mopidy.models.Playlist` + """ raise NotImplementedError def lookup(self, uri): + """ + Lookup playlist with given URI in both the set of stored playlists and + in any other playlist sources. + + :param uri: playlist URI + :type uri: string + :rtype: :class:`mopidy.models.Playlist` + """ raise NotImplementedError def refresh(self): + """Refresh stored playlists.""" raise NotImplementedError def rename(self, playlist, new_name): + """ + Rename playlist. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + :param new_name: the new name + :type new_name: string + """ raise NotImplementedError def save(self, playlist): + """ + Save the playlist to the set of stored playlists. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + """ raise NotImplementedError def search(self, query): + """ + Search for playlists whose name contains ``query``. + + :param query: query to search for + :type query: string + :rtype: list of :class:`mopidy.models.Playlist` + """ return filter(lambda p: query in p.name, self._playlists) From 82e7d0b9ac1b50594e117a07e61d03d6db19be2f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Feb 2010 13:12:25 +0100 Subject: [PATCH 016/341] Do not reset playlist version when loading a new playlist --- mopidy/backends/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 92e79000..210e8c19 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -33,7 +33,8 @@ class BaseCurrentPlaylistController(object): """ #: The current playlist version. Integer which is increased every time the - #: current playlist is changed. + #: current playlist is changed. Is not reset before the MPD server is + #: restarted. version = 0 def __init__(self, backend): @@ -101,7 +102,6 @@ class BaseCurrentPlaylistController(object): :type playlist: :class:`mopidy.models.Playlist` """ self.playlist = playlist - self.version = 0 def move(self, start, end, to_position): """ From 52aface8867f0829c11a86f9b36f2693f2d97db0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 12:09:23 +0100 Subject: [PATCH 017/341] Update MpdHandler._search() to do MPD output formatting --- mopidy/mpd/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 41ea21ce..1df58186 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -360,7 +360,7 @@ class MpdHandler(object): @register(r'^search "(?P(album|artist|filename|title))" "(?P[^"]+)"$') def _search(self, type, what): - return self.backend.library.search(type, what) + return self.backend.library.search(type, what).mpd_format() @register(r'^seek "(?P\d+)" "(?P\d+)"$') def _seek(self, songpos, seconds): From 32afe4c1037bc73cf2a0f475fc4a13f27655bae8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 12:14:45 +0100 Subject: [PATCH 018/341] Add next_track and previous_track to BasePlaybackController API. Pass track object to internal _next() and _previous() methods. --- mopidy/backends/__init__.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 210e8c19..1706c0f6 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -249,6 +249,15 @@ class BasePlaybackController(object): self._state = self.STOPPED self.state = self.STOPPED + @property + def next_track(self): + """The next track in the playlist.""" + try: + return self.backend.current_playlist.playlist.tracks[ + self.playlist_position + 1] + except IndexError: + return None + @property def playlist_position(self): """The position in the current playlist.""" @@ -260,6 +269,15 @@ class BasePlaybackController(object): except ValueError: return None + @property + def previous_track(self): + """The previous track in the playlist.""" + try: + return self.backend.current_playlist.playlist.tracks[ + self.playlist_position - 1] + except IndexError: + return None + @property def state(self): """ @@ -314,10 +332,11 @@ class BasePlaybackController(object): def next(self): """Play the next track.""" self.stop() - if self._next(): + if self.next_track is not None and self._next(self.next_track): + self.current_track = self.next_track self.state = self.PLAYING - def _next(self): + def _next(self, track): raise NotImplementedError def pause(self): @@ -337,10 +356,9 @@ class BasePlaybackController(object): """ if self.state == self.PAUSED and track is None: return self.resume() - if track is not None: - self.current_track = track self.stop() - if self._play(track): + if track is not None and self._play(track): + self.current_track = track self.state = self.PLAYING def _play(self, track): @@ -349,10 +367,12 @@ class BasePlaybackController(object): def previous(self): """Play the previous track.""" self.stop() - if self._previous(): + if (self.previous_track is not None + and self._previous(self.previous_track)): + self.current_track = self.previous_track self.state = self.PLAYING - def _previous(self): + def _previous(self, track): raise NotImplementedError def resume(self): From a6cd24cc490b6986489ca6f770781e4ee73f1f1a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 12:18:29 +0100 Subject: [PATCH 019/341] BaseBackend's next(), play() and previous() should not call stop(). Wheter to stop before playing a new track is up to the backend to decide. --- mopidy/backends/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 1706c0f6..55f68794 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -331,7 +331,6 @@ class BasePlaybackController(object): def next(self): """Play the next track.""" - self.stop() if self.next_track is not None and self._next(self.next_track): self.current_track = self.next_track self.state = self.PLAYING @@ -356,7 +355,6 @@ class BasePlaybackController(object): """ if self.state == self.PAUSED and track is None: return self.resume() - self.stop() if track is not None and self._play(track): self.current_track = track self.state = self.PLAYING @@ -366,7 +364,6 @@ class BasePlaybackController(object): def previous(self): """Play the previous track.""" - self.stop() if (self.previous_track is not None and self._previous(self.previous_track)): self.current_track = self.previous_track From 9e59ef54e1708dbbfe62b2c749d6f63c2c059e5b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 12:20:44 +0100 Subject: [PATCH 020/341] Do not set initial state to STOPPED twice --- mopidy/backends/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 55f68794..c3dee726 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -247,7 +247,6 @@ class BasePlaybackController(object): def __init__(self, backend): self.backend = backend self._state = self.STOPPED - self.state = self.STOPPED @property def next_track(self): From f4c133b4d2e169f223ba23178c0a98adf72c9a5c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 12:23:44 +0100 Subject: [PATCH 021/341] Rewrite DespotifyBackend to use new backend API --- mopidy/backends/despotify.py | 157 ++++++++++++++++++----------------- 1 file changed, 79 insertions(+), 78 deletions(-) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index a60e2aac..229f74dc 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -5,7 +5,9 @@ import sys import spytify from mopidy import config -from mopidy.backends import BaseBackend +from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, + BaseLibraryController, BasePlaybackController, + BaseStoredPlaylistsController) from mopidy.models import Artist, Album, Track, Playlist logger = logging.getLogger(u'backends.despotify') @@ -14,36 +16,91 @@ ENCODING = 'utf-8' class DespotifyBackend(BaseBackend): def __init__(self, *args, **kwargs): - super(DespotifyBackend, self).__init__(*args, **kwargs) - logger.info(u'Connecting to Spotify') - self.spotify = spytify.Spytify( - config.SPOTIFY_USERNAME, config.SPOTIFY_PASSWORD) - self.cache_stored_playlists() + self.current_playlist = DespotifyCurrentPlaylistController(backend=self) + self.library = DespotifyLibraryController(backend=self) + self.playback = DespotifyPlaybackController(backend=self) + self.stored_playlists = DespotifyStoredPlaylistsController(backend=self) + self.uri_handlers = [u'spotify:', u'http://open.spotify.com/'] + self.translate = DespotifyTranslator() + self.spotify = self._connect() + self.stored_playlists.refresh() - def cache_stored_playlists(self): + def _connect(self): + logger.info(u'Connecting to Spotify') + return spytify.Spytify( + config.SPOTIFY_USERNAME, config.SPOTIFY_PASSWORD) + + +class DespotifyCurrentPlaylistController(BaseCurrentPlaylistController): + pass + + +class DespotifyLibraryController(BaseLibraryController): + def search(self, type, what): + query = u'%s:%s' % (type, what) + result = self.backend.spotify.search(query.encode(ENCODING)) + if result is not None: + return self.backend.translate.to_mopidy_playlist(result.playlist) + + +class DespotifyPlaybackController(BasePlaybackController): + def _next(self, track): + return self._play(track) + + def _pause(self): + self.backend.spotify.pause() + return True + + def _play(self, track): + self.backend.spotify.play(self.backend.spotify.lookup(track.uri)) + return True + + def _previous(self, track): + return self._play(track) + + def _resume(self): + self.backend.spotify.resume() + return True + + def _stop(self): + self.backend.spotify.stop() + return True + + +class DespotifyStoredPlaylistsController(BaseStoredPlaylistsController): + def refresh(self): logger.info(u'Caching stored playlists') playlists = [] - for spotify_playlist in self.spotify.stored_playlists: - playlists.append(self._to_mopidy_playlist(spotify_playlist)) + for spotify_playlist in self.backend.spotify.stored_playlists: + playlists.append( + self.backend.translate.to_mopidy_playlist(spotify_playlist)) self._playlists = playlists logger.debug(u'Available playlists: %s', - u', '.join([u'<%s>' % p.name for p in self._playlists])) + u', '.join([u'<%s>' % p.name + for p in self.backend.stored_playlists.playlists])) -# Model translation - def _to_mopidy_id(self, spotify_uri): - return 0 # TODO +class DespotifyTranslator(object): + uri_to_id_map = {} + next_id = 0 - def _to_mopidy_artist(self, spotify_artist): + def to_mopidy_id(self, spotify_uri): + if spotify_uri not in self.uri_to_id_map: + id = self.next_id + self.next_id += 1 + self.uri_to_id_map[spotify_uri] = id + return self.uri_to_id_map[spotify_uri] + + def to_mopidy_artist(self, spotify_artist): return Artist( uri=spotify_artist.get_uri(), name=spotify_artist.name.decode(ENCODING) ) - def _to_mopidy_album(self, spotify_album_name): + def to_mopidy_album(self, spotify_album_name): return Album(name=spotify_album_name.decode(ENCODING)) - def _to_mopidy_track(self, spotify_track): + def to_mopidy_track(self, spotify_track): if dt.MINYEAR <= int(spotify_track.year) <= dt.MAXYEAR: date = dt.date(spotify_track.year, 1, 1) else: @@ -51,74 +108,18 @@ class DespotifyBackend(BaseBackend): return Track( uri=spotify_track.get_uri(), title=spotify_track.title.decode(ENCODING), - artists=[self._to_mopidy_artist(a) for a in spotify_track.artists], - album=self._to_mopidy_album(spotify_track.album), + artists=[self.to_mopidy_artist(a) for a in spotify_track.artists], + album=self.to_mopidy_album(spotify_track.album), track_no=spotify_track.tracknumber, date=date, length=spotify_track.length, - id=self._to_mopidy_id(spotify_track.get_uri()), + bitrate=320, + id=self.to_mopidy_id(spotify_track.get_uri()), ) - def _to_mopidy_playlist(self, spotify_playlist): + def to_mopidy_playlist(self, spotify_playlist): return Playlist( uri=spotify_playlist.get_uri(), name=spotify_playlist.name.decode(ENCODING), - tracks=[self._to_mopidy_track(t) for t in spotify_playlist.tracks], + tracks=[self.to_mopidy_track(t) for t in spotify_playlist.tracks], ) - -# Play control - - def _next(self): - self._current_song_pos += 1 - self.spotify.play(self.spotify.lookup(self._current_track.uri)) - return True - - def _pause(self): - self.spotify.pause() - return True - - def _play(self): - if self._current_track is not None: - self.spotify.play(self.spotify.lookup(self._current_track.uri)) - return True - else: - return False - - def _play_id(self, songid): - self._current_song_pos = songid # XXX - self.spotify.play(self.spotify.lookup(self._current_track.uri)) - return True - - def _play_pos(self, songpos): - self._current_song_pos = songpos - self.spotify.play(self.spotify.lookup(self._current_track.uri)) - return True - - def _previous(self): - self._current_song_pos -= 1 - self.spotify.play(self.spotify.lookup(self._current_track.uri)) - return True - - def _resume(self): - self.spotify.resume() - return True - - def _stop(self): - self.spotify.stop() - return True - -# Status querying - - def status_bitrate(self): - return 320 - - def url_handlers(self): - return [u'spotify:', u'http://open.spotify.com/'] - -# Music database - - def search(self, type, what): - query = u'%s:%s' % (type, what) - result = self.spotify.search(query.encode(ENCODING)) - if result is not None: - return self._to_mopidy_playlist(result.playlist).mpd_format() From a56e6fcd6edb32cf0840577b9dfc4af6efacd191 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 12:45:06 +0100 Subject: [PATCH 022/341] Rename id variable to this_id to not shadow global id() function --- mopidy/backends/despotify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 229f74dc..d95f0a3f 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -86,9 +86,9 @@ class DespotifyTranslator(object): def to_mopidy_id(self, spotify_uri): if spotify_uri not in self.uri_to_id_map: - id = self.next_id + this_id = self.next_id self.next_id += 1 - self.uri_to_id_map[spotify_uri] = id + self.uri_to_id_map[spotify_uri] = this_id return self.uri_to_id_map[spotify_uri] def to_mopidy_artist(self, spotify_artist): From fe4956ee4827e3c75b24891498a45fc2e72919bb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 13:00:09 +0100 Subject: [PATCH 023/341] despotify: Remove unused arguments --- mopidy/backends/despotify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index d95f0a3f..cc826a63 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -15,7 +15,7 @@ logger = logging.getLogger(u'backends.despotify') ENCODING = 'utf-8' class DespotifyBackend(BaseBackend): - def __init__(self, *args, **kwargs): + def __init__(self): self.current_playlist = DespotifyCurrentPlaylistController(backend=self) self.library = DespotifyLibraryController(backend=self) self.playback = DespotifyPlaybackController(backend=self) From f8f46d4691be8d6306df6713c62d994300a57e0f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 13:00:22 +0100 Subject: [PATCH 024/341] despotify: Use shortest path to self.playlists --- mopidy/backends/despotify.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index cc826a63..86860586 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -76,8 +76,7 @@ class DespotifyStoredPlaylistsController(BaseStoredPlaylistsController): self.backend.translate.to_mopidy_playlist(spotify_playlist)) self._playlists = playlists logger.debug(u'Available playlists: %s', - u', '.join([u'<%s>' % p.name - for p in self.backend.stored_playlists.playlists])) + u', '.join([u'<%s>' % p.name for p in self.playlists])) class DespotifyTranslator(object): From a3ca1bf82fad6ccf371f157f09ad5dcbf060366b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 13:08:21 +0100 Subject: [PATCH 025/341] despotify: Remove unused import --- mopidy/backends/despotify.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 86860586..abdf4a1d 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -1,6 +1,5 @@ import datetime as dt import logging -import sys import spytify From 580fca2cb6d48faccedffdc1447726d532a71e47 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 13:09:31 +0100 Subject: [PATCH 026/341] Add a couple of spaces --- docs/installation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation.rst b/docs/installation.rst index f6eb8326..0d5f29be 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -126,7 +126,7 @@ libspotify backend, copy the Spotify application key to ``mopidy/spotify_appkey.key``, and add the following to ``mopidy/mopidy/local_settings.py``:: - BACKEND=u'mopidy.backends.libspotify.LibspotifyBackend' + BACKEND = u'mopidy.backends.libspotify.LibspotifyBackend' To start Mopidy, go to the root of the Mopidy project, then simply run:: From 10534793aeca2d9b5db6224dca8668aef6aa2ef1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 13:14:13 +0100 Subject: [PATCH 027/341] libspotify: Rewrite backend to use new API --- mopidy/backends/libspotify.py | 183 ++++++++++++++++------------------ 1 file changed, 88 insertions(+), 95 deletions(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index d33875c9..638e0a34 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -1,4 +1,3 @@ -from copy import deepcopy import datetime as dt import logging import threading @@ -8,7 +7,9 @@ from spotify.manager import SpotifySessionManager from spotify.alsahelper import AlsaController from mopidy import config -from mopidy.backends import BaseBackend +from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, + BaseLibraryController, BasePlaybackController, + BaseStoredPlaylistsController) from mopidy.models import Artist, Album, Track, Playlist logger = logging.getLogger(u'backends.libspotify') @@ -16,107 +17,50 @@ logger = logging.getLogger(u'backends.libspotify') ENCODING = 'utf-8' class LibspotifyBackend(BaseBackend): - def __init__(self, *args, **kwargs): - super(LibspotifyBackend, self).__init__(*args, **kwargs) - self._next_id = 0 - self._id_to_uri_map = {} - self._uri_to_id_map = {} + def __init__(self): + self.current_playlist = LibspotifyCurrentPlaylistController( + backend=self) + self.library = LibspotifyLibraryController(backend=self) + self.playback = LibspotifyPlaybackController(backend=self) + self.stored_playlists = LibspotifyStoredPlaylistsController( + backend=self) + self.uri_handlers = [u'spotify:', u'http://open.spotify.com/'] + self.translate = LibspotifyTranslator() + self.spotify = self._connect() + self.stored_playlists.refresh() + + def _connect(self): logger.info(u'Connecting to Spotify') - self.spotify = LibspotifySessionManager( + spotify = LibspotifySessionManager( config.SPOTIFY_USERNAME, config.SPOTIFY_PASSWORD, backend=self) - self.spotify.start() + spotify.start() + return spotify - def update_stored_playlists(self): - logger.info(u'Updating stored playlists') - playlists = [] - for spotify_playlist in self.spotify.playlists: - playlists.append(self._to_mopidy_playlist(spotify_playlist)) - self._playlists = playlists - logger.debug(u'Available playlists: %s', - u', '.join([u'<%s>' % p.name for p in self._playlists])) -# Model translation +class LibspotifyCurrentPlaylistController(BaseCurrentPlaylistController): + pass - def _to_mopidy_id(self, spotify_uri): - if spotify_uri in self._uri_to_id_map: - return self._uri_to_id_map[spotify_uri] - else: - id = self._next_id - self._next_id += 1 - self._id_to_uri_map[id] = spotify_uri - self._uri_to_id_map[spotify_uri] = id - return id - def _to_mopidy_artist(self, spotify_artist): - return Artist( - uri=str(Link.from_artist(spotify_artist)), - name=spotify_artist.name().decode(ENCODING), - ) +class LibspotifyLibraryController(BaseLibraryController): + pass - def _to_mopidy_album(self, spotify_album): - # TODO pyspotify got much more data on albums than this - return Album(name=spotify_album.name().decode(ENCODING)) - def _to_mopidy_track(self, spotify_track): - return Track( - uri=str(Link.from_track(spotify_track, 0)), - title=spotify_track.name().decode(ENCODING), - artists=[self._to_mopidy_artist(a) - for a in spotify_track.artists()], - album=self._to_mopidy_album(spotify_track.album()), - track_no=spotify_track.index(), - date=dt.date(spotify_track.album().year(), 1, 1), - length=spotify_track.duration(), - id=self._to_mopidy_id(str(Link.from_track(spotify_track, 0))), - ) - - def _to_mopidy_playlist(self, spotify_playlist): - return Playlist( - uri=str(Link.from_playlist(spotify_playlist)), - name=spotify_playlist.name().decode(ENCODING), - tracks=[self._to_mopidy_track(t) for t in spotify_playlist], - ) -# Playback control - - def _play_current_track(self): - self.spotify.session.load( - Link.from_string(self._current_track.uri).as_track()) - self.spotify.session.play(1) - - def _next(self): - self._current_song_pos += 1 - self._play_current_track() - return True +class LibspotifyPlaybackController(BasePlaybackController): + def _next(self, track): + return self._play(track) def _pause(self): # TODO return False - def _play(self): - if self._current_track is not None: - self._play_current_track() - return True - else: - return False - - def _play_id(self, songid): - matches = filter(lambda t: t.id == songid, self._current_playlist) - if matches: - self._current_song_pos = self._current_playlist.index(matches[0]) - self._play_current_track() - return True - else: - return False - - def _play_pos(self, songpos): - self._current_song_pos = songpos - self._play_current_track() + def _play(self, track): + self.backend.spotify.session.load( + Link.from_string(self._current_track.uri).as_track()) + self.backend.spotify.session.play(1) return True - def _previous(self): - self._current_song_pos -= 1 - self._play_current_track() - return True + def _previous(self, track): + return self._play(track) def _resume(self): # TODO @@ -126,13 +70,60 @@ class LibspotifyBackend(BaseBackend): self.spotify.session.play(0) return True -# Status querying - def status_bitrate(self): - return 320 +class LibspotifyStoredPlaylistsController(BaseStoredPlaylistsController): + def refresh(self): + logger.info(u'Refreshing stored playlists') + playlists = [] + for spotify_playlist in self.backend.spotify.playlists: + playlists.append( + self.backend.translate.to_mopidy_playlist(spotify_playlist)) + self._playlists = playlists + logger.debug(u'Available playlists: %s', + u', '.join([u'<%s>' % p.name for p in self.playlists])) - def url_handlers(self): - return [u'spotify:', u'http://open.spotify.com/'] + +class LibspotifyTranslator(object): + uri_to_id_map = {} + next_id = 0 + + def to_mopidy_id(self, spotify_uri): + if spotify_uri not in self.uri_to_id_map: + this_id = self.next_id + self.next_id += 1 + self.uri_to_id_map[spotify_uri] = this_id + return self._uri_to_id_map[spotify_uri] + + def to_mopidy_artist(self, spotify_artist): + return Artist( + uri=str(Link.from_artist(spotify_artist)), + name=spotify_artist.name().decode(ENCODING), + ) + + def to_mopidy_album(self, spotify_album): + # TODO pyspotify got much more data on albums than this + return Album(name=spotify_album.name().decode(ENCODING)) + + def to_mopidy_track(self, spotify_track): + uri = str(Link.from_track(spotify_track, 0)) + return Track( + uri=uri, + title=spotify_track.name().decode(ENCODING), + artists=[self.to_mopidy_artist(a) for a in spotify_track.artists()], + album=self.to_mopidy_album(spotify_track.album()), + track_no=spotify_track.index(), + date=dt.date(spotify_track.album().year(), 1, 1), + length=spotify_track.duration(), + bitrate=320, + id=self.to_mopidy_id(uri), + ) + + def to_mopidy_playlist(self, spotify_playlist): + return Playlist( + uri=str(Link.from_playlist(spotify_playlist)), + name=spotify_playlist.name().decode(ENCODING), + tracks=[self.to_mopidy_track(t) for t in spotify_playlist], + ) class LibspotifySessionManager(SpotifySessionManager, threading.Thread): @@ -141,6 +132,7 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): threading.Thread.__init__(self) self.backend = backend self.audio = AlsaController() + self.playlists = [] def run(self): self.connect() @@ -159,7 +151,9 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def metadata_updated(self, session): logger.debug('Metadata updated') - self.backend.update_stored_playlists() + # XXX This changes data "owned" by another thread, and leads to + # segmentation fault. We should use locking and messaging here. + self.backend.stored_playlists.refresh() def connection_error(self, session, error): logger.error('Connection error: %s', error) @@ -181,4 +175,3 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def end_of_track(self, session): logger.debug('End of track') - From 09b8319f0a155e73fc9a4cfea67441f843d13a08 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 13:39:54 +0100 Subject: [PATCH 028/341] Give BaseBackend's _next() and _previous() a default implementation --- mopidy/backends/__init__.py | 4 ++-- mopidy/backends/despotify.py | 6 ------ mopidy/backends/libspotify.py | 6 ------ 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index c3dee726..49e9790c 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -335,7 +335,7 @@ class BasePlaybackController(object): self.state = self.PLAYING def _next(self, track): - raise NotImplementedError + return self._play(track) def pause(self): """Pause playback.""" @@ -369,7 +369,7 @@ class BasePlaybackController(object): self.state = self.PLAYING def _previous(self, track): - raise NotImplementedError + return self._play(track) def resume(self): """If paused, resume playing the current track.""" diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index abdf4a1d..85f5abee 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -43,9 +43,6 @@ class DespotifyLibraryController(BaseLibraryController): class DespotifyPlaybackController(BasePlaybackController): - def _next(self, track): - return self._play(track) - def _pause(self): self.backend.spotify.pause() return True @@ -54,9 +51,6 @@ class DespotifyPlaybackController(BasePlaybackController): self.backend.spotify.play(self.backend.spotify.lookup(track.uri)) return True - def _previous(self, track): - return self._play(track) - def _resume(self): self.backend.spotify.resume() return True diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 638e0a34..588ad175 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -46,9 +46,6 @@ class LibspotifyLibraryController(BaseLibraryController): class LibspotifyPlaybackController(BasePlaybackController): - def _next(self, track): - return self._play(track) - def _pause(self): # TODO return False @@ -59,9 +56,6 @@ class LibspotifyPlaybackController(BasePlaybackController): self.backend.spotify.session.play(1) return True - def _previous(self, track): - return self._play(track) - def _resume(self): # TODO return False From 45e5b3fd3e0d6457a05b759505d7211b7c9def86 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 17:38:21 +0100 Subject: [PATCH 029/341] Refer to the Track class in next_track/previous_track docs --- mopidy/backends/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 49e9790c..70bfa0eb 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -250,7 +250,7 @@ class BasePlaybackController(object): @property def next_track(self): - """The next track in the playlist.""" + """The next :class:`mopidy.models.Track` in the playlist.""" try: return self.backend.current_playlist.playlist.tracks[ self.playlist_position + 1] @@ -270,7 +270,7 @@ class BasePlaybackController(object): @property def previous_track(self): - """The previous track in the playlist.""" + """The previous :class:`mopidy.models.Track` in the playlist.""" try: return self.backend.current_playlist.playlist.tracks[ self.playlist_position - 1] From 200d6a22ca0db7a5090d85b03afcbd1ff1c87338 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 17:40:53 +0100 Subject: [PATCH 030/341] DummyLibraryController.search() should return a Playlist --- mopidy/backends/dummy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index d88e3a0b..643d97c0 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -1,6 +1,7 @@ from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, BasePlaybackController, BaseLibraryController, BaseStoredPlaylistsController) +from mopidy.models import Playlist class DummyBackend(BaseBackend): def __init__(self): @@ -15,7 +16,7 @@ class DummyCurrentPlaylistController(BaseCurrentPlaylistController): class DummyLibraryController(BaseLibraryController): def search(self, type, query): - return [] + return Playlist() class DummyPlaybackController(BasePlaybackController): def _next(self): From ac13b20667f98980cfdbec5ae9eaee300b04c4f2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Feb 2010 17:43:35 +0100 Subject: [PATCH 031/341] next_track/previous_track should be None if current_track is None --- mopidy/backends/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 70bfa0eb..0ebc389c 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -251,6 +251,8 @@ class BasePlaybackController(object): @property def next_track(self): """The next :class:`mopidy.models.Track` in the playlist.""" + if self.current_track is None: + return None try: return self.backend.current_playlist.playlist.tracks[ self.playlist_position + 1] @@ -271,6 +273,8 @@ class BasePlaybackController(object): @property def previous_track(self): """The previous :class:`mopidy.models.Track` in the playlist.""" + if self.current_track is None: + return None try: return self.backend.current_playlist.playlist.tracks[ self.playlist_position - 1] From 379694ac4eaec0000fae7b6cff994f91d78620ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Feb 2010 22:47:23 +0100 Subject: [PATCH 032/341] despotify: search() should always return a Playlist --- mopidy/backends/despotify.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 85f5abee..dc786d64 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -38,8 +38,9 @@ class DespotifyLibraryController(BaseLibraryController): def search(self, type, what): query = u'%s:%s' % (type, what) result = self.backend.spotify.search(query.encode(ENCODING)) - if result is not None: - return self.backend.translate.to_mopidy_playlist(result.playlist) + if result is None: + return Playlist() + return self.backend.translate.to_mopidy_playlist(result.playlist) class DespotifyPlaybackController(BasePlaybackController): From c8fa0c4dcfe5de650098bbd64da8c330d8943ef8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Feb 2010 22:49:06 +0100 Subject: [PATCH 033/341] libspotify: Fix typo in field name --- mopidy/backends/libspotify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 588ad175..df299fb2 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -86,7 +86,7 @@ class LibspotifyTranslator(object): this_id = self.next_id self.next_id += 1 self.uri_to_id_map[spotify_uri] = this_id - return self._uri_to_id_map[spotify_uri] + return self.uri_to_id_map[spotify_uri] def to_mopidy_artist(self, spotify_artist): return Artist( From 80b1f377b604dac1799e04d019851340f758d1b9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Feb 2010 22:59:59 +0100 Subject: [PATCH 034/341] Fix broken MpdHandler tests --- tests/mpd/handlertest.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index c130725d..ef1e114e 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -295,19 +295,29 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_pause_off(self): - self.h.handle_request(u'play') + track = Track() + self.b.current_playlist.playlist = Playlist(tracks=[track]) + self.b.playback.current_track = track + self.h.handle_request(u'play "0"') self.h.handle_request(u'pause "1"') result = self.h.handle_request(u'pause "0"') self.assert_(u'OK' in result) self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) def test_pause_on(self): - self.h.handle_request(u'play') + track = Track() + self.b.current_playlist.playlist = Playlist(tracks=[track]) + self.b.playback.current_track = track + self.h.handle_request(u'play "0"') result = self.h.handle_request(u'pause "1"') self.assert_(u'OK' in result) self.assertEquals(self.b.playback.PAUSED, self.b.playback.state) def test_play_without_pos(self): + track = Track() + self.b.current_playlist.playlist = Playlist(tracks=[track]) + self.b.playback.current_track = track + self.b.playback.state = self.b.playback.PAUSED result = self.h.handle_request(u'play') self.assert_(u'OK' in result) self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) From e3e5f0c9bb852bae85f1f5e6afba11de63182a68 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Feb 2010 23:33:28 +0100 Subject: [PATCH 035/341] Add tests to get full test coverage of MpdHandler --- mopidy/backends/dummy.py | 3 +- mopidy/mpd/handler.py | 21 +++---- tests/mpd/handlertest.py | 118 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 122 insertions(+), 20 deletions(-) diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 643d97c0..93c8f218 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -35,4 +35,5 @@ class DummyPlaybackController(BasePlaybackController): return True class DummyStoredPlaylistsController(BaseStoredPlaylistsController): - pass + def search(self, query): + return [Playlist(name=query)] diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 1df58186..23803059 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -394,15 +394,14 @@ class MpdHandler(object): @register(r'^stats$') def _stats(self): - pass # TODO return { - 'artists': 0, - 'albums': 0, - 'songs': 0, + 'artists': 0, # TODO + 'albums': 0, # TODO + 'songs': 0, # TODO 'uptime': self.session.stats_uptime(), - 'db_playtime': 0, - 'db_update': 0, - 'playtime': 0, + 'db_playtime': 0, # TODO + 'db_update': 0, # TODO + 'playtime': 0, # TODO } @register(r'^stop$') @@ -487,10 +486,12 @@ class MpdHandler(object): return self.backend.playback.time_position def _status_time_total(self): - if self.backend.playback.current_track is not None: - return self.backend.playback.current_track.length // 1000 - else: + if self.backend.playback.current_track is None: return 0 + elif self.backend.playback.current_track.length is None: + return 0 + else: + return self.backend.playback.current_track.length // 1000 def _status_volume(self): if self.backend.playback.volume is not None: diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index ef1e114e..e9f3ec19 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -144,38 +144,116 @@ class StatusHandlerTest(unittest.TestCase): result = self.h.handle_request(u'status') self.assert_(u'OK' in result) - def test_status_method(self): + def test_status_method_contains_volume_which_defaults_to_0(self): + self.b.playback.volume = None result = dict(self.h._status()) self.assert_('volume' in result) - self.assert_(int(result['volume']) in xrange(0, 101)) + self.assertEquals(int(result['volume']), 0) + + def test_status_method_contains_volume(self): + self.b.playback.volume = 17 + result = dict(self.h._status()) + self.assert_('volume' in result) + self.assertEquals(int(result['volume']), 17) + + def test_status_method_contains_repeat_is_0(self): + result = dict(self.h._status()) self.assert_('repeat' in result) - self.assert_(int(result['repeat']) in (0, 1)) + self.assertEquals(int(result['repeat']), 0) + + def test_status_method_contains_repeat_is_1(self): + self.b.playback.repeat = 1 + result = dict(self.h._status()) + self.assert_('repeat' in result) + self.assertEquals(int(result['repeat']), 1) + + def test_status_method_contains_random_is_0(self): + result = dict(self.h._status()) self.assert_('random' in result) - self.assert_(int(result['random']) in (0, 1)) + self.assertEquals(int(result['random']), 0) + + def test_status_method_contains_random_is_1(self): + self.b.playback.random = 1 + result = dict(self.h._status()) + self.assert_('random' in result) + self.assertEquals(int(result['random']), 1) + + def test_status_method_contains_single(self): + result = dict(self.h._status()) self.assert_('single' in result) self.assert_(int(result['single']) in (0, 1)) + + def test_status_method_contains_consume_is_0(self): + result = dict(self.h._status()) self.assert_('consume' in result) - self.assert_(int(result['consume']) in (0, 1)) + self.assertEquals(int(result['consume']), 0) + + def test_status_method_contains_consume_is_1(self): + self.b.playback.consume = 1 + result = dict(self.h._status()) + self.assert_('consume' in result) + self.assertEquals(int(result['consume']), 1) + + def test_status_method_contains_playlist(self): + result = dict(self.h._status()) self.assert_('playlist' in result) self.assert_(int(result['playlist']) in xrange(0, 2**31)) + + def test_status_method_contains_playlistlength(self): + result = dict(self.h._status()) self.assert_('playlistlength' in result) self.assert_(int(result['playlistlength']) >= 0) + + def test_status_method_contains_xfade(self): + result = dict(self.h._status()) self.assert_('xfade' in result) self.assert_(int(result['xfade']) >= 0) - self.assert_('state' in result) - self.assert_(result['state'] in ('play', 'stop', 'pause')) - def test_status_method_when_playlist_loaded(self): + def test_status_method_contains_state_is_play(self): + self.b.playback.state = self.b.playback.PLAYING + result = dict(self.h._status()) + self.assert_('state' in result) + self.assertEquals(result['state'], 'play') + + def test_status_method_contains_state_is_stop(self): + self.b.playback.state = self.b.playback.STOPPED + result = dict(self.h._status()) + self.assert_('state' in result) + self.assertEquals(result['state'], 'stop') + + def test_status_method_contains_state_is_pause(self): + self.b.playback.state = self.b.playback.PLAYING + self.b.playback.state = self.b.playback.PAUSED + result = dict(self.h._status()) + self.assert_('state' in result) + self.assertEquals(result['state'], 'pause') + + def test_status_method_when_playlist_loaded_contains_song(self): track = Track() self.b.current_playlist.load(Playlist(tracks=[track])) self.b.playback.current_track = track result = dict(self.h._status()) self.assert_('song' in result) self.assert_(int(result['song']) >= 0) + + def test_status_method_when_playlist_loaded_contains_pos_as_songid(self): + track = Track() + self.b.current_playlist.load(Playlist(tracks=[track])) + self.b.playback.current_track = track + result = dict(self.h._status()) self.assert_('songid' in result) self.assert_(int(result['songid']) >= 0) - def test_status_method_when_playing(self): + def test_status_method_when_playlist_loaded_contains_id_as_songid(self): + track = Track(id=1) + self.b.current_playlist.load(Playlist(tracks=[track])) + self.b.playback.current_track = track + result = dict(self.h._status()) + self.assert_('songid' in result) + self.assertEquals(int(result['songid']), 1) + + def test_status_method_when_playing_contains_time_with_no_length(self): + self.b.playback.current_track = Track(length=None) self.b.playback.state = self.b.playback.PLAYING result = dict(self.h._status()) self.assert_('time' in result) @@ -184,6 +262,23 @@ class StatusHandlerTest(unittest.TestCase): total = int(total) self.assert_(position <= total) + def test_status_method_when_playing_contains_time_with_length(self): + self.b.playback.current_track = Track(length=10000) + self.b.playback.state = self.b.playback.PLAYING + result = dict(self.h._status()) + self.assert_('time' in result) + (position, total) = result['time'].split(':') + position = int(position) + total = int(total) + self.assert_(position <= total) + + def test_status_method_when_playing_contains_bitrate(self): + self.b.playback.state = self.b.playback.PLAYING + self.b.playback.current_track = Track(bitrate=320) + result = dict(self.h._status()) + self.assert_('bitrate' in result) + self.assertEquals(int(result['bitrate']), 320) + class PlaybackOptionsHandlerTest(unittest.TestCase): def setUp(self): @@ -340,6 +435,11 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) + def test_playid_which_does_not_exist(self): + self.b.current_playlist.load(Playlist(tracks=[Track(id=0)])) + result = self.h.handle_request(u'playid "1"') + self.assert_(u'ACK Track with ID "1" not found' in result) + def test_previous(self): result = self.h.handle_request(u'previous') self.assert_(u'OK' in result) From 3784bee5831d8ab7a775bc1145bd1d2b0ab212d9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 01:20:27 +0100 Subject: [PATCH 036/341] Library search should return Playlist, not list --- mopidy/backends/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 0ebc389c..8b6de67c 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -199,7 +199,7 @@ class BaseLibraryController(object): :type type: string :param query: the search query :type query: string - :rtype: list of :class:`mopidy.models.Track` + :rtype: :class:`mopidy.models.Playlist` """ raise NotImplementedError From 52c52da33ebf8dac8bb213f33e7ebd0d4364dc97 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 01:25:36 +0100 Subject: [PATCH 037/341] libspotify: Check is_loaded() on all Spotify objects before using them --- mopidy/backends/libspotify.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index df299fb2..ea491441 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -89,16 +89,22 @@ class LibspotifyTranslator(object): return self.uri_to_id_map[spotify_uri] def to_mopidy_artist(self, spotify_artist): + if not spotify_artist.is_loaded(): + return Artist(name=u'[loading...]') return Artist( uri=str(Link.from_artist(spotify_artist)), name=spotify_artist.name().decode(ENCODING), ) def to_mopidy_album(self, spotify_album): + if not spotify_album.is_loaded(): + return Album(name=u'[loading...]') # TODO pyspotify got much more data on albums than this return Album(name=spotify_album.name().decode(ENCODING)) def to_mopidy_track(self, spotify_track): + if not spotify_track.is_loaded(): + return Track(title=u'[loading...]') uri = str(Link.from_track(spotify_track, 0)) return Track( uri=uri, @@ -113,6 +119,8 @@ class LibspotifyTranslator(object): ) def to_mopidy_playlist(self, spotify_playlist): + if not spotify_playlist.is_loaded(): + return Playlist(name=u'[loading...]') return Playlist( uri=str(Link.from_playlist(spotify_playlist)), name=spotify_playlist.name().decode(ENCODING), From 051b79e7cbb35b71890b850cbc010e8cc5e21947 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 01:27:24 +0100 Subject: [PATCH 038/341] libspotify: Add dummy library search --- mopidy/backends/libspotify.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index ea491441..e6c74863 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -42,7 +42,8 @@ class LibspotifyCurrentPlaylistController(BaseCurrentPlaylistController): class LibspotifyLibraryController(BaseLibraryController): - pass + def search(self, type, query): + return Playlist() # TODO class LibspotifyPlaybackController(BasePlaybackController): From ba7a3a51e34dc710ed30153a881ba86348fd8af2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 01:28:39 +0100 Subject: [PATCH 039/341] libspotify: Improve playback controls by stopping before playing new track. Fix a couple of bugs. --- mopidy/backends/libspotify.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index e6c74863..14615ee6 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -47,22 +47,38 @@ class LibspotifyLibraryController(BaseLibraryController): class LibspotifyPlaybackController(BasePlaybackController): + def _next(self, track): + if self.state == self.PLAYING: + self._stop() + self._play(track) + return True + def _pause(self): # TODO return False def _play(self, track): + if self.state == self.PLAYING: + self._stop() + if track.uri is None: + return False self.backend.spotify.session.load( - Link.from_string(self._current_track.uri).as_track()) + Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) return True + def _previous(self, track): + if self.state == self.PLAYING: + self._stop() + self._play(track) + return True + def _resume(self): # TODO return False def _stop(self): - self.spotify.session.play(0) + self.backend.spotify.session.play(0) return True From 5386a8102ec7893ff04840a169d11d4015ebfd1b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 01:29:19 +0100 Subject: [PATCH 040/341] libspotify: Play next track on end of track. Stop playback when losing play token. --- mopidy/backends/libspotify.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 14615ee6..676e883d 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -188,9 +188,11 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def play_token_lost(self, session): logger.debug('Play token lost') + self.backend.playback.stop() def log_message(self, session, data): logger.debug(data) def end_of_track(self, session): logger.debug('End of track') + self.backend.playback.next() From 81c7f56b5b5922eec596336d4c4d93ef58193c9d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 14:10:30 +0100 Subject: [PATCH 041/341] Add tests for mopidy.models --- tests/__main__.py | 1 + tests/modelstest.py | 160 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 tests/modelstest.py diff --git a/tests/__main__.py b/tests/__main__.py index 86c814ac..e203582a 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -9,6 +9,7 @@ def main(): sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) r = CoverageTestRunner() + r.add_pair('mopidy/models.py', 'tests/modelstest.py') r.add_pair('mopidy/mpd/handler.py', 'tests/mpd/handlertest.py') r.run() diff --git a/tests/modelstest.py b/tests/modelstest.py new file mode 100644 index 00000000..df20f05e --- /dev/null +++ b/tests/modelstest.py @@ -0,0 +1,160 @@ +import datetime as dt +import unittest + +from mopidy.models import Artist, Album, Track, Playlist + +class ArtistTest(unittest.TestCase): + def test_uri(self): + uri = u'an_uri' + artist = Artist(uri=uri) + self.assertEqual(artist.uri, uri) + + def test_name(self): + name = u'a name' + artist = Artist(name=name) + self.assertEqual(artist.name, name) + + +class AlbumTest(unittest.TestCase): + def test_uri(self): + uri = u'an_uri' + album = Album(uri=uri) + self.assertEqual(album.uri, uri) + + def test_name(self): + name = u'a name' + album = Album(name=name) + self.assertEqual(album.name, name) + + def test_artists(self): + artists = [Artist()] + album = Album(artists=artists) + self.assertEqual(album.artists, artists) + + def test_num_tracks(self): + num_tracks = 11 + album = Album(num_tracks=11) + self.assertEqual(album.num_tracks, num_tracks) + + +class TrackTest(unittest.TestCase): + def test_uri(self): + uri = u'an_uri' + track = Track(uri=uri) + self.assertEqual(track.uri, uri) + + def test_title(self): + title = u'a title' + track = Track(title=title) + self.assertEqual(track.title, title) + + def test_artists(self): + artists = [Artist(), Artist()] + track = Track(artists=artists) + self.assertEqual(track.artists, artists) + + def test_album(self): + album = Album() + track = Track(album=album) + self.assertEqual(track.album, album) + + def test_track_no(self): + track_no = 7 + track = Track(track_no=track_no) + self.assertEqual(track.track_no, track_no) + + def test_date(self): + date = dt.date(1977, 1, 1) + track = Track(date=date) + self.assertEqual(track.date, date) + + def test_length(self): + length = 137000 + track = Track(length=length) + self.assertEqual(track.length, length) + + def test_bitrate(self): + bitrate = 160 + track = Track(bitrate=bitrate) + self.assertEqual(track.bitrate, bitrate) + + def test_id(self): + id = 17 + track = Track(id=id) + self.assertEqual(track.id, id) + + def test_mpd_format_for_empty_track(self): + track = Track() + result = track.mpd_format() + self.assert_(('file', '') in result) + self.assert_(('Time', 0) in result) + self.assert_(('Artist', '') in result) + self.assert_(('Title', '') in result) + self.assert_(('Album', '') in result) + self.assert_(('Track', '0/0') in result) + self.assert_(('Date', '') in result) + self.assert_(('Pos', 0) in result) + self.assert_(('Id', 0) in result) + + def test_mpd_format_artists(self): + track = Track(artists=[Artist(name=u'ABBA'), Artist(name=u'Beatles')]) + self.assertEqual(track.mpd_format_artists(), u'ABBA, Beatles') + +class PlaylistTest(unittest.TestCase): + def test_uri(self): + uri = u'an_uri' + playlist = Playlist(uri=uri) + self.assertEqual(playlist.uri, uri) + + def test_name(self): + name = u'a name' + playlist = Playlist(name=name) + self.assertEqual(playlist.name, name) + + def test_tracks(self): + tracks = [Track(), Track(), Track()] + playlist = Playlist(tracks=tracks) + self.assertEqual(playlist.tracks, tracks) + + def test_length(self): + tracks = [Track(), Track(), Track()] + playlist = Playlist(tracks=tracks) + self.assertEqual(playlist.length, 3) + + def test_mpd_format(self): + playlist = Playlist(tracks=[ + Track(track_no=1), Track(track_no=2), Track(track_no=3)]) + result = playlist.mpd_format() + self.assertEqual(len(result), 3) + + def test_mpd_format_with_range(self): + playlist = Playlist(tracks=[ + Track(track_no=1), Track(track_no=2), Track(track_no=3)]) + result = playlist.mpd_format(1, 2) + self.assertEqual(len(result), 1) + self.assertEqual(dict(result[0])['Track'], '2/0') + + def test_with_new_uri(self): + tracks = [Track()] + playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks) + new_playlist = playlist.with_(uri=u'another uri') + self.assertEqual(new_playlist.uri, u'another uri') + self.assertEqual(new_playlist.name, u'a name') + self.assertEqual(new_playlist.tracks, tracks) + + def test_with_new_name(self): + tracks = [Track()] + playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks) + new_playlist = playlist.with_(name=u'another name') + self.assertEqual(new_playlist.uri, u'an uri') + self.assertEqual(new_playlist.name, u'another name') + self.assertEqual(new_playlist.tracks, tracks) + + def test_with_new_tracks(self): + tracks = [Track()] + playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks) + new_tracks = [Track(), Track()] + new_playlist = playlist.with_(tracks=new_tracks) + self.assertEqual(new_playlist.uri, u'an uri') + self.assertEqual(new_playlist.name, u'a name') + self.assertEqual(new_playlist.tracks, new_tracks) From 902953d99259b621b0ca2f69d17bd5563b3defbc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 14:10:50 +0100 Subject: [PATCH 042/341] Fix bugs in mopidy.models revealed by new tests --- mopidy/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 8880651e..3c96aa74 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -9,7 +9,7 @@ class Artist(object): """ def __init__(self, uri=None, name=None): - self._uri = None + self._uri = uri self._name = name @property @@ -221,7 +221,7 @@ class Playlist(object): if end is None: end = self.length tracks = [] - for track, position in zip(self.tracks, range(start, end)): + for track, position in zip(self.tracks[start:end], range(start, end)): tracks.append(track.mpd_format(position)) return tracks From 837e7c361c95557b36cbdbb15f905ff95cf6b6d6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 14:43:28 +0100 Subject: [PATCH 043/341] Test that all model fields are immutable --- tests/modelstest.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/modelstest.py b/tests/modelstest.py index df20f05e..aaa2ee1e 100644 --- a/tests/modelstest.py +++ b/tests/modelstest.py @@ -8,11 +8,13 @@ class ArtistTest(unittest.TestCase): uri = u'an_uri' artist = Artist(uri=uri) self.assertEqual(artist.uri, uri) + self.assertRaises(AttributeError, setattr, artist, 'uri', None) def test_name(self): name = u'a name' artist = Artist(name=name) self.assertEqual(artist.name, name) + self.assertRaises(AttributeError, setattr, artist, 'name', None) class AlbumTest(unittest.TestCase): @@ -20,21 +22,25 @@ class AlbumTest(unittest.TestCase): uri = u'an_uri' album = Album(uri=uri) self.assertEqual(album.uri, uri) + self.assertRaises(AttributeError, setattr, album, 'uri', None) def test_name(self): name = u'a name' album = Album(name=name) self.assertEqual(album.name, name) + self.assertRaises(AttributeError, setattr, album, 'name', None) def test_artists(self): artists = [Artist()] album = Album(artists=artists) self.assertEqual(album.artists, artists) + self.assertRaises(AttributeError, setattr, album, 'artists', None) def test_num_tracks(self): num_tracks = 11 album = Album(num_tracks=11) self.assertEqual(album.num_tracks, num_tracks) + self.assertRaises(AttributeError, setattr, album, 'num_tracks', None) class TrackTest(unittest.TestCase): @@ -42,46 +48,55 @@ class TrackTest(unittest.TestCase): uri = u'an_uri' track = Track(uri=uri) self.assertEqual(track.uri, uri) + self.assertRaises(AttributeError, setattr, track, 'uri', None) def test_title(self): title = u'a title' track = Track(title=title) self.assertEqual(track.title, title) + self.assertRaises(AttributeError, setattr, track, 'title', None) def test_artists(self): artists = [Artist(), Artist()] track = Track(artists=artists) self.assertEqual(track.artists, artists) + self.assertRaises(AttributeError, setattr, track, 'artists', None) def test_album(self): album = Album() track = Track(album=album) self.assertEqual(track.album, album) + self.assertRaises(AttributeError, setattr, track, 'album', None) def test_track_no(self): track_no = 7 track = Track(track_no=track_no) self.assertEqual(track.track_no, track_no) + self.assertRaises(AttributeError, setattr, track, 'track_no', None) def test_date(self): date = dt.date(1977, 1, 1) track = Track(date=date) self.assertEqual(track.date, date) + self.assertRaises(AttributeError, setattr, track, 'date', None) def test_length(self): length = 137000 track = Track(length=length) self.assertEqual(track.length, length) + self.assertRaises(AttributeError, setattr, track, 'length', None) def test_bitrate(self): bitrate = 160 track = Track(bitrate=bitrate) self.assertEqual(track.bitrate, bitrate) + self.assertRaises(AttributeError, setattr, track, 'bitrate', None) def test_id(self): id = 17 track = Track(id=id) self.assertEqual(track.id, id) + self.assertRaises(AttributeError, setattr, track, 'id', None) def test_mpd_format_for_empty_track(self): track = Track() @@ -105,16 +120,19 @@ class PlaylistTest(unittest.TestCase): uri = u'an_uri' playlist = Playlist(uri=uri) self.assertEqual(playlist.uri, uri) + self.assertRaises(AttributeError, setattr, playlist, 'uri', None) def test_name(self): name = u'a name' playlist = Playlist(name=name) self.assertEqual(playlist.name, name) + self.assertRaises(AttributeError, setattr, playlist, 'name', None) def test_tracks(self): tracks = [Track(), Track(), Track()] playlist = Playlist(tracks=tracks) self.assertEqual(playlist.tracks, tracks) + self.assertRaises(AttributeError, setattr, playlist, 'tracks', None) def test_length(self): tracks = [Track(), Track(), Track()] From 94b100d7af26dcb242782bb109c53948dfc81d52 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 15:42:17 +0100 Subject: [PATCH 044/341] Simplify models by changing __setattr__ instead of having tons of property getters --- mopidy/models.py | 154 ++++++++++++++++++----------------------------- 1 file changed, 59 insertions(+), 95 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 3c96aa74..2fb0d909 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,6 +1,15 @@ from copy import copy -class Artist(object): +class ImmutableObject(object): + def __init__(self, *args, **kwargs): + self.__dict__.update(kwargs) + + def __setattr__(self, name, value): + if name.startswith('_'): + return super(ImmutableObject, self).__setattr__(name, value) + raise AttributeError('Object is immutable.') + +class Artist(ImmutableObject): """ :param uri: artist URI :type uri: string @@ -8,22 +17,14 @@ class Artist(object): :type name: string """ - def __init__(self, uri=None, name=None): - self._uri = uri - self._name = name + #: The artist URI. Read-only. + uri = None - @property - def uri(self): - """The artist URI. Read-only.""" - return self._uri - - @property - def name(self): - """The artist name. Read-only.""" - return self._name + #: The artist name. Read-only. + name = None -class Album(object): +class Album(ImmutableObject): """ :param uri: album URI :type uri: string @@ -35,34 +36,26 @@ class Album(object): :type num_tracks: integer """ - def __init__(self, uri=None, name=None, artists=None, num_tracks=0): - self._uri = uri - self._name = name - self._artists = artists or [] - self._num_tracks = num_tracks + #: The album URI. Read-only. + uri = None - @property - def uri(self): - """The album URI. Read-only.""" - return self._uri + #: The album name. Read-only.""" + name = None - @property - def name(self): - """The album name. Read-only.""" - return self._name + #: The number of tracks in the album. Read-only. + num_tracks = 0 + + def __init__(self, *args, **kwargs): + self._artists = kwargs.pop('artists', []) + super(Album, self).__init__(*args, **kwargs) @property def artists(self): """List of :class:`Artist` elements. Read-only.""" return copy(self._artists) - @property - def num_tracks(self): - """The number of tracks in the album. Read-only.""" - return self._num_tracks - -class Track(object): +class Track(ImmutableObject): """ :param uri: track URI :type uri: string @@ -84,63 +77,39 @@ class Track(object): :type id: integer """ - def __init__(self, uri=None, title=None, artists=None, album=None, - track_no=0, date=None, length=None, bitrate=None, id=None): - self._uri = uri - self._title = title - self._artists = artists or [] - self._album = album - self._track_no = track_no - self._date = date - self._length = length - self._bitrate = bitrate - self._id = id + #: The track URI. Read-only. + uri = None - @property - def uri(self): - """The track URI. Read-only.""" - return self._uri + #: The track title. Read-only. + title = None - @property - def title(self): - """The track title. Read-only.""" - return self._title + #: The track :class:`Album`. Read-only. + album = None + + #: The track number in album. Read-only. + track_no = 0 + + #: The track release date. Read-only. + date = None + + #: The track length in milliseconds. Read-only. + length = None + + #: The track's bitrate in kbit/s. Read-only. + bitrate = None + + #: The track ID. Read-only. + id = None + + def __init__(self, *args, **kwargs): + self._artists = kwargs.pop('artists', []) + super(Track, self).__init__(*args, **kwargs) @property def artists(self): """List of :class:`Artist`. Read-only.""" return copy(self._artists) - @property - def album(self): - """The track :class:`Album`. Read-only.""" - return self._album - - @property - def track_no(self): - """The track number in album. Read-only.""" - return self._track_no - - @property - def date(self): - """The track release date. Read-only.""" - return self._date - - @property - def length(self): - """The track length in milliseconds. Read-only.""" - return self._length - - @property - def bitrate(self): - """The track's bitrate in kbit/s. Read-only.""" - return self._bitrate - - @property - def id(self): - """The track ID. Read-only.""" - return self._id - def mpd_format(self, position=0): """ Format track for output to MPD client. @@ -171,7 +140,7 @@ class Track(object): return u', '.join([a.name for a in self.artists]) -class Playlist(object): +class Playlist(ImmutableObject): """ :param uri: playlist URI :type uri: string @@ -181,20 +150,15 @@ class Playlist(object): :type tracks: list of :class:`Track` elements """ - def __init__(self, uri=None, name=None, tracks=None): - self._uri = uri - self._name = name - self._tracks = tracks or [] + #: The playlist URI. Read-only. + uri = None - @property - def uri(self): - """The playlist URI. Read-only.""" - return self._uri + #: The playlist name. Read-only. + name = None - @property - def name(self): - """The playlist name. Read-only.""" - return self._name + def __init__(self, *args, **kwargs): + self._tracks = kwargs.pop('tracks', []) + super(Playlist, self).__init__(*args, **kwargs) @property def tracks(self): From a098704334de090ce833e99e71230d88ee37d713 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 15:50:18 +0100 Subject: [PATCH 045/341] Remove redundant docstring end --- mopidy/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/models.py b/mopidy/models.py index 2fb0d909..64dffd29 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -39,7 +39,7 @@ class Album(ImmutableObject): #: The album URI. Read-only. uri = None - #: The album name. Read-only.""" + #: The album name. Read-only. name = None #: The number of tracks in the album. Read-only. From 8adbacae098005f0054df709f2d50a7ee1aae468 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 16:03:37 +0100 Subject: [PATCH 046/341] libspotify: Stored playlists are refreshed when we get the updated metadata callback --- mopidy/backends/libspotify.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 676e883d..93065fa7 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -27,7 +27,6 @@ class LibspotifyBackend(BaseBackend): self.uri_handlers = [u'spotify:', u'http://open.spotify.com/'] self.translate = LibspotifyTranslator() self.spotify = self._connect() - self.stored_playlists.refresh() def _connect(self): logger.info(u'Connecting to Spotify') From e72166ad789afac5e652c288d861dec302383353 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 16:14:13 +0100 Subject: [PATCH 047/341] libspotify: Remove again redundant _next and _previous overrides. Let _play call stop instead of _stop. --- mopidy/backends/libspotify.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 93065fa7..d1942f3b 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -46,19 +46,13 @@ class LibspotifyLibraryController(BaseLibraryController): class LibspotifyPlaybackController(BasePlaybackController): - def _next(self, track): - if self.state == self.PLAYING: - self._stop() - self._play(track) - return True - def _pause(self): # TODO return False def _play(self, track): if self.state == self.PLAYING: - self._stop() + self.stop() if track.uri is None: return False self.backend.spotify.session.load( @@ -66,12 +60,6 @@ class LibspotifyPlaybackController(BasePlaybackController): self.backend.spotify.session.play(1) return True - def _previous(self, track): - if self.state == self.PLAYING: - self._stop() - self._play(track) - return True - def _resume(self): # TODO return False From 0a3341063b092a977cee91d5e3a0140ae4957be8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 17:41:22 +0100 Subject: [PATCH 048/341] Format search results correctly --- mopidy/models.py | 21 +++++++++++++-------- mopidy/mpd/handler.py | 3 ++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 64dffd29..86561bc3 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -110,7 +110,7 @@ class Track(ImmutableObject): """List of :class:`Artist`. Read-only.""" return copy(self._artists) - def mpd_format(self, position=0): + def mpd_format(self, position=0, search_result=False): """ Format track for output to MPD client. @@ -118,18 +118,23 @@ class Track(ImmutableObject): :type position: integer :rtype: list of two-tuples """ - return [ + result = [ ('file', self.uri or ''), ('Time', self.length and (self.length // 1000) or 0), ('Artist', self.mpd_format_artists()), ('Title', self.title or ''), ('Album', self.album and self.album.name or ''), - ('Track', '%d/%d' % (self.track_no, - self.album and self.album.num_tracks or 0)), ('Date', self.date or ''), - ('Pos', position), - ('Id', self.id or position), ] + if self.album is not None and self.album.num_tracks != 0: + result.append(('Track', '%d/%d' % ( + self.track_no, self.album.num_tracks))) + else: + result.append(('Track', self.track_no)) + if not search_result: + result.append(('Pos', position)) + result.append(('Id', self.id or position)) + return result def mpd_format_artists(self): """ @@ -170,7 +175,7 @@ class Playlist(ImmutableObject): """The number of tracks in the playlist. Read-only.""" return len(self._tracks) - def mpd_format(self, start=0, end=None): + def mpd_format(self, start=0, end=None, search_result=False): """ Format playlist for output to MPD client. @@ -186,7 +191,7 @@ class Playlist(ImmutableObject): end = self.length tracks = [] for track, position in zip(self.tracks[start:end], range(start, end)): - tracks.append(track.mpd_format(position)) + tracks.append(track.mpd_format(position, search_result)) return tracks def with_(self, uri=None, name=None, tracks=None): diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 23803059..f3395fd8 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -360,7 +360,8 @@ class MpdHandler(object): @register(r'^search "(?P(album|artist|filename|title))" "(?P[^"]+)"$') def _search(self, type, what): - return self.backend.library.search(type, what).mpd_format() + return self.backend.library.search(type, what).mpd_format( + search_result=True) @register(r'^seek "(?P\d+)" "(?P\d+)"$') def _seek(self, songpos, seconds): From 552b3ac723fcf95606ef205fb4d4310db4f89543 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 19:58:19 +0100 Subject: [PATCH 049/341] Fix tests and coverage for track status output --- tests/modelstest.py | 26 ++++++++++++++++++++++++-- tests/mpd/handlertest.py | 2 +- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/modelstest.py b/tests/modelstest.py index aaa2ee1e..29911dc7 100644 --- a/tests/modelstest.py +++ b/tests/modelstest.py @@ -106,11 +106,33 @@ class TrackTest(unittest.TestCase): self.assert_(('Artist', '') in result) self.assert_(('Title', '') in result) self.assert_(('Album', '') in result) - self.assert_(('Track', '0/0') in result) + self.assert_(('Track', 0) in result) self.assert_(('Date', '') in result) self.assert_(('Pos', 0) in result) self.assert_(('Id', 0) in result) + def test_mpd_format_for_nonempty_track(self): + track = Track( + uri=u'a uri', + artists=[Artist(name=u'an artist')], + title=u'a title', + album=Album(name=u'an album', num_tracks=13), + track_no=7, + date=dt.date(1977, 1, 1), + length=137000, + id=122, + ) + result = track.mpd_format(position=9) + self.assert_(('file', 'a uri') in result) + self.assert_(('Time', 137) in result) + self.assert_(('Artist', 'an artist') in result) + self.assert_(('Title', 'a title') in result) + self.assert_(('Album', 'an album') in result) + self.assert_(('Track', '7/13') in result) + self.assert_(('Date', dt.date(1977, 1, 1)) in result) + self.assert_(('Pos', 9) in result) + self.assert_(('Id', 122) in result) + def test_mpd_format_artists(self): track = Track(artists=[Artist(name=u'ABBA'), Artist(name=u'Beatles')]) self.assertEqual(track.mpd_format_artists(), u'ABBA, Beatles') @@ -150,7 +172,7 @@ class PlaylistTest(unittest.TestCase): Track(track_no=1), Track(track_no=2), Track(track_no=3)]) result = playlist.mpd_format(1, 2) self.assertEqual(len(result), 1) - self.assertEqual(dict(result[0])['Track'], '2/0') + self.assertEqual(dict(result[0])['Track'], 2) def test_with_new_uri(self): tracks = [Track()] diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index e9f3ec19..a6716bc2 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -101,7 +101,7 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(u'Artist: ' in result) self.assert_(u'Title: ' in result) self.assert_(u'Album: ' in result) - self.assert_(u'Track: 0/0' in result) + self.assert_(u'Track: 0' in result) self.assert_(u'Date: ' in result) self.assert_(u'Pos: 0' in result) self.assert_(u'Id: 0' in result) From 01a6751ce69e5a078d3f3b89de67467258d62731 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 20:11:42 +0100 Subject: [PATCH 050/341] Add support for 'outputs' MPD command --- mopidy/mpd/handler.py | 8 ++++++++ tests/mpd/handlertest.py | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index f3395fd8..ef7274f8 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -220,6 +220,14 @@ class MpdHandler(object): def _next(self): return self.backend.playback.next() + @register(r'^outputs$') + def _outputs(self): + return [ + ('outputid', 0), + ('outputname', self.backend.__class__.__name__), + ('outputenabled', 1), + ] + @register(r'^password "(?P[^"]+)"$') def _password(self, password): raise MpdNotImplemented # TODO diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index a6716bc2..a9a9d653 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -774,11 +774,17 @@ class ConnectionHandlerTest(unittest.TestCase): result = self.h.handle_request(u'ping') self.assert_(u'OK' in result) + class AudioOutputHandlerTest(unittest.TestCase): def setUp(self): self.h = handler.MpdHandler(backend=DummyBackend()) - pass # TODO + def test_outputs(self): + result = self.h.handle_request(u'outputs') + self.assert_(u'outputid: 0' in result) + self.assert_(u'outputname: DummyBackend' in result) + self.assert_(u'outputenabled: 1' in result) + self.assert_(u'OK' in result) class ReflectionHandlerTest(unittest.TestCase): From f59a0a042d853ce21be66580bfcf50d518d98efa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Feb 2010 20:30:33 +0100 Subject: [PATCH 051/341] libspotify: Implement search --- mopidy/backends/libspotify.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index d1942f3b..b27333fd 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -1,6 +1,7 @@ import datetime as dt import logging import threading +import time from spotify import Link from spotify.manager import SpotifySessionManager @@ -41,8 +42,22 @@ class LibspotifyCurrentPlaylistController(BaseCurrentPlaylistController): class LibspotifyLibraryController(BaseLibraryController): - def search(self, type, query): - return Playlist() # TODO + search_results = False + + def search(self, type, what): + # XXX This is slow + self.search_results = None + def callback(results, userdata): + logger.debug(u'Search results received') + self.search_results = results + query = u'%s:%s' % (type, what) + self.backend.spotify.search(query.encode(ENCODING), callback) + while self.search_results is None: + time.sleep(0.01) + result = Playlist(tracks=[self.backend.translate.to_mopidy_track(t) + for t in self.search_results.tracks()]) + self.search_results = False + return result class LibspotifyPlaybackController(BasePlaybackController): @@ -183,3 +198,6 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def end_of_track(self, session): logger.debug('End of track') self.backend.playback.next() + + def search(self, query, callback): + self.session.search(query, callback) From 85a34d943be2bf73e3bcde723b3c21909eb2c6ab Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Feb 2010 01:01:45 +0100 Subject: [PATCH 052/341] Add paragraph on code style --- docs/development.rst | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/development.rst b/docs/development.rst index 2a39b327..3f4897db 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -24,10 +24,25 @@ modular, so we can extend it with other backends in the future, like file playback and other online music services such as Last.fm. +Code style +========== + +We generally follow the `PEP-8 `_ +style guidelines, with a couple of notable exceptions: + +- We only indent continuation lines with an addition four spaces relative to + the previous line. +- An exception to the previous exception: When continuing control flow + statements like ``if``, ``for`` and ``while``, indent the continuation lines + with eight spaces, so that it is indented an additional level compared to the + following block of code. + + Running tests ============= -To run tests, you need a couple of dependiencies. Some can be installed through Debian/Ubuntu package management:: +To run tests, you need a couple of dependencies. Some can be installed through +Debian/Ubuntu package management:: sudo aptitude install python-coverage From 0e3a98b328c174e2d8571c2c3bfc9fcf9c5c2e52 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Feb 2010 01:04:27 +0100 Subject: [PATCH 053/341] Document return type of get_by_uri and get_by_id --- mopidy/backends/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 8b6de67c..962e91ba 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -74,6 +74,7 @@ class BaseCurrentPlaylistController(object): :param id: track ID :type id: int + :rtype: :class:`mopidy.models.Track` """ matches = filter(lambda t: t.id == id, self._playlist.tracks) if matches: @@ -87,6 +88,7 @@ class BaseCurrentPlaylistController(object): :param uri: track URI :type uri: string + :rtype: :class:`mopidy.models.Track` """ matches = filter(lambda t: t.uri == uri, self._playlist.tracks) if matches: From 8c983f20149cf910930f25ce36b9efc317218eb3 Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Sun, 14 Feb 2010 01:16:48 +0100 Subject: [PATCH 054/341] Only handle response in session if the response is not none --- mopidy/mpd/session.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index edd2a95c..c85ed3a0 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -48,7 +48,8 @@ class MpdSession(asynchat.async_chat): def handle_request(self, input): try: response = self.handler.handle_request(input) - self.handle_response(response) + if response is not None: + self.handle_response(response) except MpdAckError, e: logger.warning(e) return self.send_response(u'ACK %s' % e) From 527d345488ed931f16a2597f09af428bc43b064b Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Sun, 14 Feb 2010 01:19:21 +0100 Subject: [PATCH 055/341] Send OK only when no ACK in command list --- mopidy/mpd/handler.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index ef7274f8..65c00ceb 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -66,7 +66,7 @@ class MpdHandler(object): response.append(u'%s: %s' % (key, value)) else: response.append(line) - if add_ok: + if add_ok and (not response or not response[-1].startswith(u'ACK')): response.append(u'OK') return response @@ -109,6 +109,8 @@ class MpdHandler(object): response = self.handle_request(command, add_ok=False) if response is not None: result.append(response) + if response and response[-1].startswith(u'ACK'): + return result if command_list_ok: response.append(u'list_OK') return result From b1e15a3830d6fa79d4ebc2c23bdd05914ba368e7 Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Sun, 14 Feb 2010 01:20:18 +0100 Subject: [PATCH 056/341] Implemented deleteid in MPD handler --- mopidy/mpd/handler.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 65c00ceb..98a0f97b 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -144,7 +144,12 @@ class MpdHandler(object): @register(r'^deleteid "(?P\d+)"$') def _deleteid(self, songid): - raise MpdNotImplemented # TODO + songid = int(songid) + try: + track = self.backend.current_playlist.get_by_id(songid) + return self.backend.current_playlist.remove(track) + except KeyError, e: + raise MpdAckError(unicode(e)) @register(r'^$') def _empty(self): From 328191d6573c69af036a4f8b16397976349fe79b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Feb 2010 01:20:54 +0100 Subject: [PATCH 057/341] Add some missing MPD commands --- mopidy/mpd/handler.py | 24 ++++++++++++++++++++++++ tests/mpd/handlertest.py | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index ef7274f8..b5cc499e 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -113,6 +113,10 @@ class MpdHandler(object): response.append(u'list_OK') return result + @register(r'^commands$') + def _commands(self): + raise MpdNotImplemented # TODO + @register(r'^consume "(?P[01])"$') def _consume(self, state): state = int(state) @@ -135,6 +139,10 @@ class MpdHandler(object): if self.backend.playback.current_track is not None: return self.backend.playback.current_track.mpd_format() + @register(r'^decoders$') + def _decoders(self): + raise MpdNotImplemented # TODO + @register(r'^delete "(?P\d+)"$') @register(r'^delete "(?P\d+):(?P\d+)*"$') def _delete(self, songpos=None, start=None, end=None): @@ -144,10 +152,18 @@ class MpdHandler(object): def _deleteid(self, songid): raise MpdNotImplemented # TODO + @register(r'^disableoutput "(?P\d+)"$') + def _disableoutput(self, outputid): + raise MpdNotImplemented # TODO + @register(r'^$') def _empty(self): pass + @register(r'^enableoutput "(?P\d+)"$') + def _enableoutput(self, outputid): + raise MpdNotImplemented # TODO + @register(r'^find "(?P(album|artist|title))" "(?P[^"]+)"$') def _find(self, type, what): raise MpdNotImplemented # TODO @@ -220,6 +236,10 @@ class MpdHandler(object): def _next(self): return self.backend.playback.next() + @register(r'^notcommands$') + def _notcommands(self): + raise MpdNotImplemented # TODO + @register(r'^outputs$') def _outputs(self): return [ @@ -519,6 +539,10 @@ class MpdHandler(object): def _swapid(self, songid1, songid2): raise MpdNotImplemented # TODO + @register(r'^tagtypes$') + def _tagtypes(self): + raise MpdNotImplemented # TODO + @register(r'^update( "(?P[^"]+)")*$') def _update(self, uri=None, rescan_unmodified_files=False): return {'updating_db': 0} # TODO diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index a9a9d653..ccac10d2 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -779,6 +779,14 @@ class AudioOutputHandlerTest(unittest.TestCase): def setUp(self): self.h = handler.MpdHandler(backend=DummyBackend()) + def test_enableoutput(self): + result = self.h.handle_request(u'enableoutput "0"') + self.assert_(u'ACK Not implemented' in result) + + def test_disableoutput(self): + result = self.h.handle_request(u'disableoutput "0"') + self.assert_(u'ACK Not implemented' in result) + def test_outputs(self): result = self.h.handle_request(u'outputs') self.assert_(u'outputid: 0' in result) @@ -791,6 +799,22 @@ class ReflectionHandlerTest(unittest.TestCase): def setUp(self): self.h = handler.MpdHandler(backend=DummyBackend()) + def test_commands(self): + result = self.h.handle_request(u'commands') + self.assert_(u'ACK Not implemented' in result) + + def test_decoders(self): + result = self.h.handle_request(u'decoders') + self.assert_(u'ACK Not implemented' in result) + + def test_notcommands(self): + result = self.h.handle_request(u'notcommands') + self.assert_(u'ACK Not implemented' in result) + + def test_tagtypes(self): + result = self.h.handle_request(u'tagtypes') + self.assert_(u'ACK Not implemented' in result) + def test_urlhandlers(self): result = self.h.handle_request(u'urlhandlers') self.assert_(u'OK' in result) From afa107f31d22f17ad10fe63d68b8973e66ad485d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Feb 2010 01:43:00 +0100 Subject: [PATCH 058/341] Add empty handlers for sticker commands --- mopidy/mpd/handler.py | 20 ++++++++++++++++++++ tests/mpd/handlertest.py | 32 +++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 264bf747..475a6bb0 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -538,6 +538,26 @@ class MpdHandler(object): def _status_xfade(self): return 0 # TODO + @register(r'^sticker delete "(?P[^"]+)" "(?P[^"]+)"( "(?P[^"]+)")*$') + def _sticker_delete(self, type, uri, name=None): + raise MpdNotImplemented # TODO + + @register(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') + def sticker_find(self, type, uri, name): + raise MpdNotImplemented # TODO + + @register(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') + def _sticker_get(self, type, uri, name): + raise MpdNotImplemented # TODO + + @register(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') + def _sticker_list(self, type, uri): + raise MpdNotImplemented # TODO + + @register(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') + def _sticker_set(self, type, uri, name, value): + raise MpdNotImplemented # TODO + @register(r'^swap "(?P\d+)" "(?P\d+)"$') def _swap(self, songpos1, songpos2): raise MpdNotImplemented # TODO diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index ccac10d2..9dbe44e7 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -746,7 +746,35 @@ class StickersHandlerTest(unittest.TestCase): def setUp(self): self.h = handler.MpdHandler(backend=DummyBackend()) - pass # TODO + def test_sticker_get(self): + result = self.h.handle_request( + u'sticker get "song" "file:///dev/urandom" "a_name"') + self.assert_(u'ACK Not implemented' in result) + + def test_sticker_set(self): + result = self.h.handle_request( + u'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') + self.assert_(u'ACK Not implemented' in result) + + def test_sticker_delete_with_name(self): + result = self.h.handle_request( + u'sticker delete "song" "file:///dev/urandom" "a_name"') + self.assert_(u'ACK Not implemented' in result) + + def test_sticker_delete_without_name(self): + result = self.h.handle_request( + u'sticker delete "song" "file:///dev/urandom"') + self.assert_(u'ACK Not implemented' in result) + + def test_sticker_list(self): + result = self.h.handle_request( + u'sticker list "song" "file:///dev/urandom"') + self.assert_(u'ACK Not implemented' in result) + + def test_sticker_find(self): + result = self.h.handle_request( + u'sticker find "song" "file:///dev/urandom" "a_name"') + self.assert_(u'ACK Not implemented' in result) class ConnectionHandlerTest(unittest.TestCase): @@ -820,5 +848,3 @@ class ReflectionHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) result = result[0] self.assert_('dummy:' in result) - - pass # TODO From 39c44bfed63c3dd9babc0dd2e5cfa663456db158 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Feb 2010 01:47:42 +0100 Subject: [PATCH 059/341] Update tests for 'deleteid' --- tests/mpd/handlertest.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 9dbe44e7..3f5e4bb5 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -460,7 +460,8 @@ class PlaybackControlHandlerTest(unittest.TestCase): class CurrentPlaylistHandlerTest(unittest.TestCase): def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) + self.b = DummyBackend() + self.h = handler.MpdHandler(backend=self.b) def test_add(self): result = self.h.handle_request(u'add "file:///dev/urandom"') @@ -491,8 +492,13 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'ACK Not implemented' in result) def test_deleteid(self): + self.b.current_playlist.load(Playlist(tracks=[Track(id=0)])) result = self.h.handle_request(u'deleteid "0"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) + + def test_deleteid_does_not_exist(self): + result = self.h.handle_request(u'deleteid "0"') + self.assert_(u'ACK Track with ID "0" not found' in result) def test_move_songpos(self): result = self.h.handle_request(u'move "5" "0"') From 4e16e403d2a1dc4f30508ac8220e7d9bae4e8495 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Feb 2010 01:54:03 +0100 Subject: [PATCH 060/341] Test behaviour when errors happens during command_list processing --- mopidy/mpd/handler.py | 9 +++++++++ tests/mpd/handlertest.py | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 475a6bb0..aacad28c 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -70,6 +70,15 @@ class MpdHandler(object): response.append(u'OK') return response + @register(r'^ack$') + def _ack(self): + """ + Always returns an 'ACK' and not 'OK'. + + Not a part of the MPD protocol. + """ + raise MpdNotImplemented + @register(r'^add "(?P[^"]*)"$') def _add(self, uri): raise MpdNotImplemented # TODO diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 3f5e4bb5..339d73df 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -66,6 +66,12 @@ class CommandListsTest(unittest.TestCase): self.assert_(u'OK' in result) self.assertEquals(False, self.h.command_list) + def test_command_list_with_error(self): + self.h.handle_request(u'command_list_begin') + self.h.handle_request(u'ack') + result = self.h.handle_request(u'command_list_end') + self.assert_(u'ACK' in result[-1]) + def test_command_list_ok_begin(self): result = self.h.handle_request(u'command_list_ok_begin') self.assert_(result is None) From 9763cb55a6363e193b892bbb1f1658e1db78af28 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Feb 2010 02:57:05 +0100 Subject: [PATCH 061/341] Add examples of code style --- docs/development.rst | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 3f4897db..826dd215 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -30,12 +30,33 @@ Code style We generally follow the `PEP-8 `_ style guidelines, with a couple of notable exceptions: -- We only indent continuation lines with an addition four spaces relative to - the previous line. +- We indent continuation lines with four spaces more than the previous line. + For example:: + + from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, + BasePlaybackController, BaseLibraryController, + BaseStoredPlaylistsController) + + And not:: + + from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, + BasePlaybackController, BaseLibraryController, + BaseStoredPlaylistsController) + - An exception to the previous exception: When continuing control flow - statements like ``if``, ``for`` and ``while``, indent the continuation lines - with eight spaces, so that it is indented an additional level compared to the - following block of code. + statements like ``if``, ``for`` and ``while``, we indent with eight spaces + more than the previous line. In other words, the line is indented one level + further to the right than the following block of code. For example:: + + if (old_state in (self.PLAYING, self.STOPPED) + and new_state == self.PLAYING): + self._play_time_start() + + And not:: + + if (old_state in (self.PLAYING, self.STOPPED) + and new_state == self.PLAYING): + self._play_time_start() Running tests From 2eff17315c3fc8fa7bbe2477df2868f782ac5b73 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Feb 2010 03:33:06 +0100 Subject: [PATCH 062/341] docs: Highlight Premium/appkey requirements --- docs/installation.rst | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 0d5f29be..8d812310 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -27,11 +27,13 @@ despotify backend To use the despotify backend, you first need to install despotify and spytify. -*This backend requires a Spotify premium account.* +.. note:: + + This backend requires a Spotify premium account. -Installing despotify and spytify --------------------------------- +Installing despotify +-------------------- Install despotify's dependencies. At Debian/Ubuntu systems:: @@ -48,6 +50,12 @@ Build and install despotify:: make sudo make install + +Installing spytify +------------------ + +spytify's source comes bundled with despotify. + Build and install spytify:: cd despotify/src/bindings/python/ @@ -64,6 +72,7 @@ Spotify Premium account), ask for a search query, list all your playlists with tracks, play 10s from a random song from the search result, pause for two seconds, play for five more seconds, and quit. + .. _libspotify: libspotify backend @@ -73,13 +82,14 @@ As an alternative to the despotify backend, we are working on a libspotify backend. To use the libspotify backend you must install libspotify and pyspotify. -*This backend requires a Spotify premium account.* +.. note:: -*This backend requires you to get an application key from Spotify before use.* + This backend requires a Spotify premium account, and it requires you to get + an application key from Spotify before use. -Installing libspotify and pyspotify ------------------------------------ +Installing libspotify +--------------------- As libspotify's installation script at the moment is somewhat broken (see this `GetSatisfaction thread `_ @@ -87,6 +97,10 @@ for details), it is easiest to use the libspotify files bundled with pyspotify. The files bundled with pyspotify are for 64-bit, so if you run a 32-bit OS, you must get libspotify from https://developer.spotify.com/en/libspotify/. + +Installing pyspotify +-------------------- + Install pyspotify's dependencies. At Debian/Ubuntu systems:: sudo aptitude install python-alsaaudio @@ -106,9 +120,11 @@ Test your libspotify setup:: ./example1.py -u USERNAME -p PASSWORD -Until Spotify fixes their installation script, you'll have to set -``LD_LIBRARY_PATH`` every time you are going to use libspotify (in other words -before starting Mopidy). +.. note:: + + Until Spotify fixes their installation script, you'll have to set + ``LD_LIBRARY_PATH`` every time you are going to use libspotify (in other + words before starting Mopidy). Running Mopidy From b11c8475a9fb272c86bdf7d9945503aba92c0bf2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 15 Feb 2010 19:51:03 +0100 Subject: [PATCH 063/341] docs: How to install despotify on OS X --- docs/installation.rst | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 8d812310..a20fbc20 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -35,16 +35,31 @@ To use the despotify backend, you first need to install despotify and spytify. Installing despotify -------------------- -Install despotify's dependencies. At Debian/Ubuntu systems:: +*Linux:* Install despotify's dependencies. At Debian/Ubuntu systems:: sudo aptitude install libssl-dev zlib1g-dev libvorbis-dev \ libtool libncursesw5-dev libao-dev -Check out revision 503 of the despotify source code:: +*OS X:* In OS X you need to have `XCode +`_ and `MacPorts +`_ installed. Then, to install despotify's +dependencies:: - svn co https://despotify.svn.sourceforge.net/svnroot/despotify@503 despotify + sudo port install openssl zlib libvorbis libtool ncursesw libao -Build and install despotify:: +*All OS:* Check out revision 503 of the despotify source code:: + + svn co https://despotify.svn.sourceforge.net/svnroot/despotify@503 + +*OS X:* Edit ``despotify/src/Makefile.local.mk`` and uncomment the last two +lines so that it reads:: + + ## If you're on Mac OS X and have installed libvorbisfile + ## via 'port install ..', try uncommenting these lines + CFLAGS += -I/opt/local/include + LDFLAGS += -L/opt/local/lib + +*All OS:* Build and install despotify:: cd despotify/src/ make @@ -59,6 +74,7 @@ spytify's source comes bundled with despotify. Build and install spytify:: cd despotify/src/bindings/python/ + export PKG_CONFIG_PATH=../../lib # Needed on OS X make sudo make install From 1d6ecace000b71c1f73b6240011ebf611d3371ed Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 15 Feb 2010 19:51:22 +0100 Subject: [PATCH 064/341] docs: Adjust headers on installation page --- docs/installation.rst | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index a20fbc20..f924fd72 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -23,7 +23,7 @@ Dependencies .. _despotify: despotify backend -================= +----------------- To use the despotify backend, you first need to install despotify and spytify. @@ -33,7 +33,7 @@ To use the despotify backend, you first need to install despotify and spytify. Installing despotify --------------------- +^^^^^^^^^^^^^^^^^^^^ *Linux:* Install despotify's dependencies. At Debian/Ubuntu systems:: @@ -67,7 +67,7 @@ lines so that it reads:: Installing spytify ------------------- +^^^^^^^^^^^^^^^^^^ spytify's source comes bundled with despotify. @@ -92,7 +92,7 @@ seconds, play for five more seconds, and quit. .. _libspotify: libspotify backend -================== +------------------ As an alternative to the despotify backend, we are working on a libspotify backend. To use the libspotify backend you must install libspotify and @@ -105,7 +105,7 @@ pyspotify. Installing libspotify ---------------------- +^^^^^^^^^^^^^^^^^^^^^ As libspotify's installation script at the moment is somewhat broken (see this `GetSatisfaction thread `_ @@ -115,7 +115,7 @@ must get libspotify from https://developer.spotify.com/en/libspotify/. Installing pyspotify --------------------- +^^^^^^^^^^^^^^^^^^^^ Install pyspotify's dependencies. At Debian/Ubuntu systems:: @@ -143,8 +143,8 @@ Test your libspotify setup:: words before starting Mopidy). -Running Mopidy -============== +Settings +======== Create a file name ``local_settings.py`` in the same directory as ``settings.py``. Enter your Spotify Premium account's username and password @@ -160,6 +160,9 @@ libspotify backend, copy the Spotify application key to BACKEND = u'mopidy.backends.libspotify.LibspotifyBackend' +Running Mopidy +============== + To start Mopidy, go to the root of the Mopidy project, then simply run:: python mopidy From 0ab6a311e548b50e1ece97083398aa2e2daa301d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 15 Feb 2010 22:37:45 +0100 Subject: [PATCH 065/341] Document ImmutableObject class --- mopidy/models.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mopidy/models.py b/mopidy/models.py index 86561bc3..934ca8ac 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,6 +1,14 @@ from copy import copy class ImmutableObject(object): + """ + Superclass for immutable objects whose fields can only be modified via the + constructor. + + :param kwargs: kwargs to set as fields on the object + :type kwargs: any + """ + def __init__(self, *args, **kwargs): self.__dict__.update(kwargs) From a2ec5ff5b44d22cba5992128802b4334ba407ffe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 15 Feb 2010 22:59:29 +0100 Subject: [PATCH 066/341] Switch from time.sleep to threading.Event for making search non-async --- mopidy/backends/libspotify.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index b27333fd..1e11a261 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -42,21 +42,22 @@ class LibspotifyCurrentPlaylistController(BaseCurrentPlaylistController): class LibspotifyLibraryController(BaseLibraryController): - search_results = False + _search_results = None + _search_results_received = threading.Event() def search(self, type, what): - # XXX This is slow - self.search_results = None + # FIXME This is slow, like 12-14s between querying and getting results + self._search_results_received.clear() + query = u'%s:%s' % (type, what) def callback(results, userdata): logger.debug(u'Search results received') - self.search_results = results - query = u'%s:%s' % (type, what) + self._search_results = results + self._search_results_received.set() self.backend.spotify.search(query.encode(ENCODING), callback) - while self.search_results is None: - time.sleep(0.01) + self._search_results_received.wait() result = Playlist(tracks=[self.backend.translate.to_mopidy_track(t) - for t in self.search_results.tracks()]) - self.search_results = False + for t in self._search_results.tracks()]) + self._search_results = None return result From b940c4f3668eb8c5839c958f59e13c3cd7a7f194 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 15 Feb 2010 23:11:44 +0100 Subject: [PATCH 067/341] libspotify: Add new info to search slowness comment --- mopidy/backends/libspotify.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 1e11a261..ce59a7f0 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -46,7 +46,8 @@ class LibspotifyLibraryController(BaseLibraryController): _search_results_received = threading.Event() def search(self, type, what): - # FIXME This is slow, like 12-14s between querying and getting results + # FIXME When searching while playing music, this is really slow, like + # 12-14s between querying and getting results. self._search_results_received.clear() query = u'%s:%s' % (type, what) def callback(results, userdata): From 115e726dea52a3ddfbe0dbfa36f82ff3d99334f8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 15 Feb 2010 23:20:48 +0100 Subject: [PATCH 068/341] Fix missing song position in 'currentsong' output --- mopidy/mpd/handler.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index aacad28c..d9ba1713 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -148,7 +148,8 @@ class MpdHandler(object): @register(r'^currentsong$') def _currentsong(self): if self.backend.playback.current_track is not None: - return self.backend.playback.current_track.mpd_format() + return self.backend.playback.current_track.mpd_format( + position=self.backend.playback.playlist_position) @register(r'^decoders$') def _decoders(self): From 59f1307d83f89867cee1ddce3c659ebcc9fec90d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 15 Feb 2010 23:22:37 +0100 Subject: [PATCH 069/341] Update 'currentsong' test --- tests/mpd/handlertest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 339d73df..8996d6af 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -100,7 +100,9 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(u'ACK Not implemented' in result) def test_currentsong(self): - self.b.playback.current_track = Track() + track = Track() + self.b.current_playlist.playlist = Playlist(tracks=[track]) + self.b.playback.current_track = track result = self.h.handle_request(u'currentsong') self.assert_(u'file: ' in result) self.assert_(u'Time: 0' in result) From 88b4cc30472d8ba082c87646fb469a63f06b652d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Feb 2010 07:40:21 +0100 Subject: [PATCH 070/341] libspotify: Remove unused import --- mopidy/backends/libspotify.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index ce59a7f0..dfe40609 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -1,7 +1,6 @@ import datetime as dt import logging import threading -import time from spotify import Link from spotify.manager import SpotifySessionManager From 503d7a800d79a3d98f7b2b28cfcd82c870e4ee24 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 20 Feb 2010 00:57:13 +0100 Subject: [PATCH 071/341] Add notes about history handling to doc --- docs/api/history.rst | 55 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/api/history.rst diff --git a/docs/api/history.rst b/docs/api/history.rst new file mode 100644 index 00000000..d749b940 --- /dev/null +++ b/docs/api/history.rst @@ -0,0 +1,55 @@ +History handling +================ + +For correct logical handling of ``previous()`` and ``previous_track`` backends +will need to keep track of which songs they have played. + +Normal playback +---------------- + +Each time a new song is played the previous ``current_track`` should be added to +the history. The ``previous_track`` should always be the most recent history item. + +Playback with repeat enabled +----------------------------- + +History should be handled in same manner as regular playback. ``next_track`` +at end of playlist should loop to first item on playlist. + +Playback with random enabled +----------------------------- + +Each song should only be played once until entire playlist has been played, +once this has occurred a new random order should be played. History should be +handled in the same way as regular playback. + +A suggested implementation is creating a shuffled copy of the tracks and +retrieving ``next_track`` from here until it is empty. + +Playback with consume enabled +----------------------------- + +Turning on consume should set history to an empty array, and not add any new +tracks while it is on. ``previous_track`` should return ``current_track`` to +match MPD behaviour. + +Playback with repeat and random +------------------------------- + +Once the shuffled tracks array is empty it should be replaced with a new +shuffled array of tracks. + +Playback with repeat and consume +-------------------------------- + +Return ``current_track`` for ``previous_track`` to match MPD. + +Playback with random and consume +-------------------------------- + +Return ``current_track`` for ``previous_track`` to match MPD. + +Playback with repeat, random and consume +---------------------------------------- + +Return ``current_track`` for ``previous_track`` to match MPD. From 3fcbe283673b0ad48d949025eb58e0d4a491865b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 20 Feb 2010 02:11:30 +0100 Subject: [PATCH 072/341] Remove history doc and write new doc directly in backend based on MPD behavior for next and previous_track --- docs/api/history.rst | 55 ------------------------------------- mopidy/backends/__init__.py | 16 +++++++++-- 2 files changed, 14 insertions(+), 57 deletions(-) delete mode 100644 docs/api/history.rst diff --git a/docs/api/history.rst b/docs/api/history.rst deleted file mode 100644 index d749b940..00000000 --- a/docs/api/history.rst +++ /dev/null @@ -1,55 +0,0 @@ -History handling -================ - -For correct logical handling of ``previous()`` and ``previous_track`` backends -will need to keep track of which songs they have played. - -Normal playback ----------------- - -Each time a new song is played the previous ``current_track`` should be added to -the history. The ``previous_track`` should always be the most recent history item. - -Playback with repeat enabled ------------------------------ - -History should be handled in same manner as regular playback. ``next_track`` -at end of playlist should loop to first item on playlist. - -Playback with random enabled ------------------------------ - -Each song should only be played once until entire playlist has been played, -once this has occurred a new random order should be played. History should be -handled in the same way as regular playback. - -A suggested implementation is creating a shuffled copy of the tracks and -retrieving ``next_track`` from here until it is empty. - -Playback with consume enabled ------------------------------ - -Turning on consume should set history to an empty array, and not add any new -tracks while it is on. ``previous_track`` should return ``current_track`` to -match MPD behaviour. - -Playback with repeat and random -------------------------------- - -Once the shuffled tracks array is empty it should be replaced with a new -shuffled array of tracks. - -Playback with repeat and consume --------------------------------- - -Return ``current_track`` for ``previous_track`` to match MPD. - -Playback with random and consume --------------------------------- - -Return ``current_track`` for ``previous_track`` to match MPD. - -Playback with repeat, random and consume ----------------------------------------- - -Return ``current_track`` for ``previous_track`` to match MPD. diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 962e91ba..8452f2a4 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -252,7 +252,14 @@ class BasePlaybackController(object): @property def next_track(self): - """The next :class:`mopidy.models.Track` in the playlist.""" + """ + The next :class:`mopidy.models.Track` in the playlist. + + For normal playback this is the next track in the playlist. If repeat + is enabled the next track can loop around the playlist. When random is + enabled this should be a random track, all tracks should be played once + before the list repeats. + """ if self.current_track is None: return None try: @@ -274,7 +281,12 @@ class BasePlaybackController(object): @property def previous_track(self): - """The previous :class:`mopidy.models.Track` in the playlist.""" + """ + The previous :class:`mopidy.models.Track` in the playlist. + + For normal playback this is the next track in the playlist. If random + and/or consume is enabled it should return the current track instead. + """ if self.current_track is None: return None try: From cafab89f84e61d43b91c7223945b590d2b382e8e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 21 Feb 2010 15:24:49 +0100 Subject: [PATCH 073/341] Add requirements-docs.txt and update development docs accordingly --- docs/development.rst | 23 +++++++++++++++++-- requirements-docs.txt | 1 + ...requirements.txt => requirements-tests.txt | 1 + 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 requirements-docs.txt rename test-requirements.txt => requirements-tests.txt (89%) diff --git a/docs/development.rst b/docs/development.rst index 826dd215..116442e6 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -67,16 +67,35 @@ Debian/Ubuntu package management:: sudo aptitude install python-coverage -The rest can be installed using pip:: +The rest (or all dependencies if you want to) can be installed using pip:: sudo aptitude install python-pip python-setuptools bzr - sudo pip install -r test-requirements.txt + sudo pip install -r requirements-tests.txt Then, to run all tests:: python tests +Generating documentation +======================== + +To generate documentation, you also need some additional dependencies. You can either install them through Debian/Ubuntu package management:: + + sudo aptitude install python-sphinx + +Or, install them using pip:: + + sudo aptitude install python-pip python-setuptools + sudo pip install -r requirements-docs.txt + +Then, to generate docs:: + + cd docs/ + make # For help on available targets + make html # To generate HTML docs + + Music Player Daemon (MPD) ========================= diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 00000000..2806c164 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1 @@ +Sphinx diff --git a/test-requirements.txt b/requirements-tests.txt similarity index 89% rename from test-requirements.txt rename to requirements-tests.txt index 459f434a..0342fb1c 100644 --- a/test-requirements.txt +++ b/requirements-tests.txt @@ -1 +1,2 @@ +coverage -e bzr+http://liw.iki.fi/bzr/coverage-test-runner/trunk/#egg=CoverageTestRunner From 9a88cd0dfa2014354448a11a4a80c76de8bd0a54 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 21 Feb 2010 15:45:18 +0100 Subject: [PATCH 074/341] docs: Add 'mopidy.' as common prefix in module index --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 8cb63290..2e924acb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -84,7 +84,7 @@ exclude_trees = ['_build'] pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +modindex_common_prefix = ['mopidy.'] # -- Options for HTML output --------------------------------------------------- From b45fe37a0f8b321060deb9d3e8aead79a8f5d0ee Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 21 Feb 2010 16:15:46 +0100 Subject: [PATCH 075/341] docs: Document settings --- docs/index.rst | 1 + docs/installation.rst | 7 ++++-- docs/settings.rst | 8 +++++++ mopidy/__main__.py | 2 +- mopidy/settings.py | 51 +++++++++++++++++++++++++++++++++++++++---- 5 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 docs/settings.rst diff --git a/docs/index.rst b/docs/index.rst index 7c618dc3..ed92375c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,6 +8,7 @@ Contents changes installation + settings development Indices and tables diff --git a/docs/installation.rst b/docs/installation.rst index f924fd72..bdf523ea 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -143,8 +143,8 @@ Test your libspotify setup:: words before starting Mopidy). -Settings -======== +Spotify settings +================ Create a file name ``local_settings.py`` in the same directory as ``settings.py``. Enter your Spotify Premium account's username and password @@ -160,6 +160,9 @@ libspotify backend, copy the Spotify application key to BACKEND = u'mopidy.backends.libspotify.LibspotifyBackend' +For a full list of available settings, see :mod:`mopidy.settings`. + + Running Mopidy ============== diff --git a/docs/settings.rst b/docs/settings.rst new file mode 100644 index 00000000..b449b247 --- /dev/null +++ b/docs/settings.rst @@ -0,0 +1,8 @@ +******** +Settings +******** + +.. automodule:: mopidy.settings + :synopsis: Available settings and their default values. + :members: + :undoc-members: diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 0d02b416..ee1a3384 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -14,7 +14,7 @@ logger = logging.getLogger('mopidy') def main(): _setup_logging(2) - backend = _get_backend(config.BACKEND) + backend = _get_backend(config.BACKENDS[0]) MpdServer(backend=backend) asyncore.loop() diff --git a/mopidy/settings.py b/mopidy/settings.py index aa513c81..4e442432 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -1,17 +1,60 @@ +""" +Available settings and their default values. + +.. warning:: To users + + Do *not* change settings here. Instead, add a file called + ``mopidy/local_settings.py`` and redefine settings there. + +.. note:: To developers + + When you need to read a setting, import :mod:`mopidy.config` instead of + :mod:`mopidy.settings`. This way basic error handling is done for you, and + a :exc:`mopidy.exceptions.ConfigError` exception is raised if a setting is + not set or is empty when used. +""" + +#: List of playback backends to use. Default:: +#: +#: BACKENDS = (u'mopidy.backends.despotify.DespotifyBackend',) +#: +#: .. note:: +#: Currently only the first backend in the list is used. +#: +BACKENDS = ( + u'mopidy.backends.despotify.DespotifyBackend', + #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 [%(threadName)s] %(name)s\n %(message)s' + +#: Encoding used in MPD protocol. *Default:* ``utf-8`` MPD_LINE_ENCODING = u'utf-8' + +#: Line terminator character used in MPD protocol. *Default:* ``\n`` MPD_LINE_TERMINATOR = u'\n' + +#: Which address Mopidy should bind to. Examples: +#: +#: ``localhost`` +#: Listens only on the loopback interface. *Default.* +#: ``0.0.0.0`` +#: listens on all interfaces. MPD_SERVER_HOSTNAME = u'localhost' + +#: Which TCP port Mopidy should listen to. *Default: 6600* MPD_SERVER_PORT = 6600 -BACKEND=u'mopidy.backends.despotify.DespotifyBackend' -#BACKEND=u'mopidy.backends.libspotify.LibspotifyBackend' - +#: Your Spotify Premium username. Used by all Spotify backends. SPOTIFY_USERNAME = u'' + +#: Your Spotify Premium password. Used by all Spotify backends. SPOTIFY_PASSWORD = u'' try: from mopidy.local_settings import * except ImportError: pass - From 9ee17677f1588498fed4de0a25aeb7eb5d476608 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 21 Feb 2010 16:18:19 +0100 Subject: [PATCH 076/341] Change docs version from 0.1 to 0.1.dev as we haven't released yet --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 2e924acb..841260e7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,7 +47,7 @@ copyright = u'2010, Stein Magnus Jodal' # The short X.Y version. version = '0.1' # The full version, including alpha/beta/rc tags. -release = '0.1' +release = '0.1.dev' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. From c96d9d910813f22b3824240ed63140d0f374af47 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 21 Feb 2010 16:28:49 +0100 Subject: [PATCH 077/341] MpdHandler: sticker_find should be private --- mopidy/mpd/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index d9ba1713..aa50ee90 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -553,7 +553,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @register(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') - def sticker_find(self, type, uri, name): + def _sticker_find(self, type, uri, name): raise MpdNotImplemented # TODO @register(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') From b67a58eb6d1ad91628555d2abdd7bc01be759194 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 21 Feb 2010 23:31:58 +0100 Subject: [PATCH 078/341] docs: Add module which turns on autodoc for private members with docstrings --- docs/autodoc_private_members.py | 10 ++++++++++ docs/conf.py | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 docs/autodoc_private_members.py diff --git a/docs/autodoc_private_members.py b/docs/autodoc_private_members.py new file mode 100644 index 00000000..9cb2e49b --- /dev/null +++ b/docs/autodoc_private_members.py @@ -0,0 +1,10 @@ +def setup(app): + app.connect('autodoc-skip-member', autodoc_private_members_with_doc) + +def autodoc_private_members_with_doc(app, what, name, obj, skip, options): + if not skip: + return skip + if (name.startswith('_') and obj.__doc__ is not None + and not (name.startswith('__') and name.endswith('__'))): + return False + return skip diff --git a/docs/conf.py b/docs/conf.py index 841260e7..94c08e90 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,13 +16,14 @@ import sys, os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.append(os.path.abspath(os.path.dirname(__file__))) sys.path.append(os.path.abspath(os.path.dirname(__file__) + '/../')) # -- General configuration ----------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc'] +extensions = ['sphinx.ext.autodoc', 'autodoc_private_members'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -192,3 +193,4 @@ latex_documents = [ # If false, no module index is generated. #latex_use_modindex = True + From f105b854288b1d5bcad2a8fadb3bfff541407675 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 21 Feb 2010 23:32:41 +0100 Subject: [PATCH 079/341] Add MpdHandler patterns to docstrings automatically --- mopidy/mpd/handler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index aa50ee90..a37960db 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -15,6 +15,9 @@ def register(pattern): raise ValueError(u'Tried to redefine handler for %s with %s' % ( pattern, func)) _request_handlers[pattern] = func + if func.__doc__ is None: + func.__doc__ = '' + func.__doc__ += '\n\n- **Pattern:** ``%s``' % pattern return func return decorator From f9a9a0e82b661980bd6c341a82e558c06e7000e2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 21 Feb 2010 23:33:11 +0100 Subject: [PATCH 080/341] docs: Add automodule doc of MpdHandler --- docs/development.rst | 5 +++++ docs/mpd/handler.rst | 8 ++++++++ 2 files changed, 13 insertions(+) create mode 100644 docs/mpd/handler.rst diff --git a/docs/development.rst b/docs/development.rst index 116442e6..29d2d9ce 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -106,6 +106,11 @@ requests and responses. Thus we have to talk a great deal with the the original to implement our own MPD server which is compatible with the numerous existing `MPD clients `_. +.. toctree:: + :glob: + + mpd/* + spytify ======= diff --git a/docs/mpd/handler.rst b/docs/mpd/handler.rst new file mode 100644 index 00000000..2fcd2302 --- /dev/null +++ b/docs/mpd/handler.rst @@ -0,0 +1,8 @@ +******************************************************** +:mod:`mopidy.mpd.handler` -- MPD protocol implementation +******************************************************** + +.. automodule:: mopidy.mpd.handler + :synopsis: Our implementation of the MPD protocol. + :members: + :undoc-members: From 0a6545f6d0515735da1443e19a04f45a0de40c29 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Feb 2010 19:47:25 +0100 Subject: [PATCH 081/341] Add volume control support to BaseBackend through alsaaudio.Mixer --- docs/installation.rst | 1 + mopidy/backends/__init__.py | 22 +++++++++++++++++----- mopidy/backends/dummy.py | 11 ++++++++++- mopidy/mpd/handler.py | 5 +++-- tests/mpd/handlertest.py | 23 +++++++++++++++++------ 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index bdf523ea..004454e8 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -14,6 +14,7 @@ Dependencies ============ * Python >= 2.5 +* pyalsaaudio >= 0.2 (Debian/Ubuntu: python-alsaaudio) * Dependencies for at least one Mopidy backend: * :ref:`despotify` diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 962e91ba..f0d700c9 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -3,6 +3,8 @@ import logging import random import time +import alsaaudio + from mopidy.models import Playlist logger = logging.getLogger('backends.base') @@ -242,13 +244,10 @@ class BasePlaybackController(object): #: The current track is played once. repeat = False - #: The audio volume as an int in the range [0, 100]. :class:`None` if - #: unknown. - volume = None - - def __init__(self, backend): + def __init__(self, backend, mixer=alsaaudio.Mixer): self.backend = backend self._state = self.STOPPED + self._mixer = mixer() @property def next_track(self): @@ -325,6 +324,19 @@ class BasePlaybackController(object): def _play_time_resume(self): self._play_time_started = int(time.time()) + @property + def volume(self): + """ + The audio volume as an int in the range [0, 100]. + + :class:`None` if unknown. + """ + return self._mixer.getvolume()[0] + + @volume.setter + def volume(self, volume): + self._mixer.setvolume(volume) + def new_playlist_loaded_callback(self): """Tell the playback controller that a new playlist has been loaded.""" self.current_track = None diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 93c8f218..76e59fc5 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -7,7 +7,7 @@ class DummyBackend(BaseBackend): def __init__(self): self.current_playlist = DummyCurrentPlaylistController(backend=self) self.library = DummyLibraryController(backend=self) - self.playback = DummyPlaybackController(backend=self) + self.playback = DummyPlaybackController(backend=self, mixer=DummyMixer) self.stored_playlists = DummyStoredPlaylistsController(backend=self) self.uri_handlers = [u'dummy:'] @@ -37,3 +37,12 @@ class DummyPlaybackController(BasePlaybackController): class DummyStoredPlaylistsController(BaseStoredPlaylistsController): def search(self, query): return [Playlist(name=query)] + +class DummyMixer(object): + volume = 0 + + def getvolume(self): + return [self.volume, self.volume] + + def setvolume(self, volume): + self.volume = volume diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index a37960db..b9b032a2 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -419,14 +419,15 @@ class MpdHandler(object): def _seekid(self, songid, seconds): raise MpdNotImplemented # TODO - @register(r'^setvol "(?P-*\d+)"$') + @register(r'^setvol (?P[-+]*\d+)$') + @register(r'^setvol "(?P[-+]*\d+)"$') def _setvol(self, volume): volume = int(volume) if volume < 0: volume = 0 if volume > 100: volume = 100 - raise MpdNotImplemented # TODO + self.backend.playback.volume = volume @register(r'^shuffle$') @register(r'^shuffle "(?P\d+):(?P\d+)*"$') diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 8996d6af..1cef5718 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -290,7 +290,8 @@ class StatusHandlerTest(unittest.TestCase): class PlaybackOptionsHandlerTest(unittest.TestCase): def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) + self.b = DummyBackend() + self.h = handler.MpdHandler(backend=self.b) def test_consume_off(self): result = self.h.handle_request(u'consume "0"') @@ -322,23 +323,33 @@ class PlaybackOptionsHandlerTest(unittest.TestCase): def test_setvol_below_min(self): result = self.h.handle_request(u'setvol "-10"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) + self.assertEqual(0, self.b.playback.volume) def test_setvol_min(self): result = self.h.handle_request(u'setvol "0"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) + self.assertEqual(0, self.b.playback.volume) def test_setvol_middle(self): result = self.h.handle_request(u'setvol "50"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) + self.assertEqual(50, self.b.playback.volume) def test_setvol_max(self): result = self.h.handle_request(u'setvol "100"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) + self.assertEqual(100, self.b.playback.volume) def test_setvol_above_max(self): result = self.h.handle_request(u'setvol "110"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) + self.assertEqual(100, self.b.playback.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) def test_single_off(self): result = self.h.handle_request(u'single "0"') From daab3e216a479700986bfdaa7d36829cccee4c4b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Feb 2010 20:21:25 +0100 Subject: [PATCH 082/341] Rename decorator from 'register' to 'handle_pattern' --- mopidy/mpd/handler.py | 184 +++++++++++++++++++++--------------------- 1 file changed, 92 insertions(+), 92 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index b9b032a2..83453489 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -9,7 +9,7 @@ logger = logging.getLogger('mpd.handler') _request_handlers = {} -def register(pattern): +def handle_pattern(pattern): def decorator(func): if pattern in _request_handlers: raise ValueError(u'Tried to redefine handler for %s with %s' % ( @@ -73,7 +73,7 @@ class MpdHandler(object): response.append(u'OK') return response - @register(r'^ack$') + @handle_pattern(r'^ack$') def _ack(self): """ Always returns an 'ACK' and not 'OK'. @@ -82,37 +82,37 @@ class MpdHandler(object): """ raise MpdNotImplemented - @register(r'^add "(?P[^"]*)"$') + @handle_pattern(r'^add "(?P[^"]*)"$') def _add(self, uri): raise MpdNotImplemented # TODO - @register(r'^addid "(?P[^"]*)"( (?P\d+))*$') + @handle_pattern(r'^addid "(?P[^"]*)"( (?P\d+))*$') def _add(self, uri, songpos=None): raise MpdNotImplemented # TODO - @register(r'^clear$') + @handle_pattern(r'^clear$') def _clear(self): raise MpdNotImplemented # TODO - @register(r'^clearerror$') + @handle_pattern(r'^clearerror$') def _clearerror(self): raise MpdNotImplemented # TODO - @register(r'^close$') + @handle_pattern(r'^close$') def _close(self): self.session.do_close() - @register(r'^command_list_begin$') + @handle_pattern(r'^command_list_begin$') def _command_list_begin(self): self.command_list = [] self.command_list_ok = False - @register(r'^command_list_ok_begin$') + @handle_pattern(r'^command_list_ok_begin$') def _command_list_ok_begin(self): self.command_list = [] self.command_list_ok = True - @register(r'^command_list_end$') + @handle_pattern(r'^command_list_end$') def _command_list_end(self): (command_list, self.command_list) = (self.command_list, False) (command_list_ok, self.command_list_ok) = (self.command_list_ok, False) @@ -127,11 +127,11 @@ class MpdHandler(object): response.append(u'list_OK') return result - @register(r'^commands$') + @handle_pattern(r'^commands$') def _commands(self): raise MpdNotImplemented # TODO - @register(r'^consume "(?P[01])"$') + @handle_pattern(r'^consume "(?P[01])"$') def _consume(self, state): state = int(state) if state: @@ -139,31 +139,31 @@ class MpdHandler(object): else: raise MpdNotImplemented # TODO - @register(r'^count "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^count "(?P[^"]+)" "(?P[^"]+)"$') def _count(self, tag, needle): raise MpdNotImplemented # TODO - @register(r'^crossfade "(?P\d+)"$') + @handle_pattern(r'^crossfade "(?P\d+)"$') def _crossfade(self, seconds): seconds = int(seconds) raise MpdNotImplemented # TODO - @register(r'^currentsong$') + @handle_pattern(r'^currentsong$') def _currentsong(self): if self.backend.playback.current_track is not None: return self.backend.playback.current_track.mpd_format( position=self.backend.playback.playlist_position) - @register(r'^decoders$') + @handle_pattern(r'^decoders$') def _decoders(self): raise MpdNotImplemented # TODO - @register(r'^delete "(?P\d+)"$') - @register(r'^delete "(?P\d+):(?P\d+)*"$') + @handle_pattern(r'^delete "(?P\d+)"$') + @handle_pattern(r'^delete "(?P\d+):(?P\d+)*"$') def _delete(self, songpos=None, start=None, end=None): raise MpdNotImplemented # TODO - @register(r'^deleteid "(?P\d+)"$') + @handle_pattern(r'^deleteid "(?P\d+)"$') def _deleteid(self, songid): songid = int(songid) try: @@ -172,95 +172,95 @@ class MpdHandler(object): except KeyError, e: raise MpdAckError(unicode(e)) - @register(r'^disableoutput "(?P\d+)"$') + @handle_pattern(r'^disableoutput "(?P\d+)"$') def _disableoutput(self, outputid): raise MpdNotImplemented # TODO - @register(r'^$') + @handle_pattern(r'^$') def _empty(self): pass - @register(r'^enableoutput "(?P\d+)"$') + @handle_pattern(r'^enableoutput "(?P\d+)"$') def _enableoutput(self, outputid): raise MpdNotImplemented # TODO - @register(r'^find "(?P(album|artist|title))" "(?P[^"]+)"$') + @handle_pattern(r'^find "(?P(album|artist|title))" "(?P[^"]+)"$') def _find(self, type, what): raise MpdNotImplemented # TODO - @register(r'^findadd "(?P(album|artist|title))" "(?P[^"]+)"$') + @handle_pattern(r'^findadd "(?P(album|artist|title))" "(?P[^"]+)"$') def _findadd(self, type, what): result = self._find(type, what) # TODO Add result to current playlist #return result - @register(r'^idle$') - @register(r'^idle (?P.+)$') + @handle_pattern(r'^idle$') + @handle_pattern(r'^idle (?P.+)$') def _idle(self, subsystems=None): raise MpdNotImplemented # TODO - @register(r'^kill$') + @handle_pattern(r'^kill$') def _kill(self): self.session.do_kill() - @register(r'^list "(?Partist)"$') - @register(r'^list "(?Palbum)"( "(?P[^"]+)")*$') + @handle_pattern(r'^list "(?Partist)"$') + @handle_pattern(r'^list "(?Palbum)"( "(?P[^"]+)")*$') def _list(self, type, artist=None): raise MpdNotImplemented # TODO - @register(r'^listall "(?P[^"]+)"') + @handle_pattern(r'^listall "(?P[^"]+)"') def _listall(self, uri): raise MpdNotImplemented # TODO - @register(r'^listallinfo "(?P[^"]+)"') + @handle_pattern(r'^listallinfo "(?P[^"]+)"') def _listallinfo(self, uri): raise MpdNotImplemented # TODO - @register(r'^listplaylist "(?P[^"]+)"$') + @handle_pattern(r'^listplaylist "(?P[^"]+)"$') def _listplaylist(self, name): raise MpdNotImplemented # TODO - @register(r'^listplaylistinfo "(?P[^"]+)"$') + @handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') def _listplaylistinfo(self, name): raise MpdNotImplemented # TODO - @register(r'^listplaylists$') + @handle_pattern(r'^listplaylists$') def _listplaylists(self): return [u'playlist: %s' % p.name for p in self.backend.stored_playlists.playlists] - @register(r'^load "(?P[^"]+)"$') + @handle_pattern(r'^load "(?P[^"]+)"$') def _load(self, name): matches = self.backend.stored_playlists.search(name) if matches: self.backend.current_playlist.load(matches[0]) self.backend.playback.new_playlist_loaded_callback() - @register(r'^lsinfo$') - @register(r'^lsinfo "(?P[^"]*)"$') + @handle_pattern(r'^lsinfo$') + @handle_pattern(r'^lsinfo "(?P[^"]*)"$') def _lsinfo(self, uri=None): if uri == u'/' or uri is None: return self._listplaylists() raise MpdNotImplemented # TODO - @register(r'^move "(?P\d+)" "(?P\d+)"$') - @register(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') + @handle_pattern(r'^move "(?P\d+)" "(?P\d+)"$') + @handle_pattern(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') def _move(self, songpos=None, start=None, end=None, to=None): raise MpdNotImplemented # TODO - @register(r'^moveid "(?P\d+)" "(?P\d+)"$') + @handle_pattern(r'^moveid "(?P\d+)" "(?P\d+)"$') def _moveid(self, songid, to): raise MpdNotImplemented # TODO - @register(r'^next$') + @handle_pattern(r'^next$') def _next(self): return self.backend.playback.next() - @register(r'^notcommands$') + @handle_pattern(r'^notcommands$') def _notcommands(self): raise MpdNotImplemented # TODO - @register(r'^outputs$') + @handle_pattern(r'^outputs$') def _outputs(self): return [ ('outputid', 0), @@ -268,26 +268,26 @@ class MpdHandler(object): ('outputenabled', 1), ] - @register(r'^password "(?P[^"]+)"$') + @handle_pattern(r'^password "(?P[^"]+)"$') def _password(self, password): raise MpdNotImplemented # TODO - @register(r'^pause "(?P[01])"$') + @handle_pattern(r'^pause "(?P[01])"$') def _pause(self, state): if int(state): self.backend.playback.pause() else: self.backend.playback.resume() - @register(r'^ping$') + @handle_pattern(r'^ping$') def _ping(self): pass - @register(r'^play$') + @handle_pattern(r'^play$') def _play(self): return self.backend.playback.play() - @register(r'^play "(?P\d+)"$') + @handle_pattern(r'^play "(?P\d+)"$') def _playpos(self, songpos): songpos = int(songpos) try: @@ -296,7 +296,7 @@ class MpdHandler(object): except IndexError: raise MpdAckError(u'Position out of bounds') - @register(r'^playid "(?P\d+)"$') + @handle_pattern(r'^playid "(?P\d+)"$') def _playid(self, songid): songid = int(songid) try: @@ -305,33 +305,33 @@ class MpdHandler(object): except KeyError, e: raise MpdAckError(unicode(e)) - @register(r'^playlist$') + @handle_pattern(r'^playlist$') def _playlist(self): return self._playlistinfo() - @register(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') def _playlistadd(self, name, uri): raise MpdNotImplemented # TODO - @register(r'^playlistclear "(?P[^"]+)"$') + @handle_pattern(r'^playlistclear "(?P[^"]+)"$') def _playlistclear(self, name): raise MpdNotImplemented # TODO - @register(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') + @handle_pattern(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') def _playlistdelete(self, name, songpos): raise MpdNotImplemented # TODO - @register(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') def _playlistfind(self, tag, needle): raise MpdNotImplemented # TODO - @register(r'^playlistid( "(?P\S+)")*$') + @handle_pattern(r'^playlistid( "(?P\S+)")*$') def _playlistid(self, songid=None): return self.backend.current_playlist.playlist.mpd_format() - @register(r'^playlistinfo$') - @register(r'^playlistinfo "(?P\d+)"$') - @register(r'^playlistinfo "(?P\d+):(?P\d+)*"$') + @handle_pattern(r'^playlistinfo$') + @handle_pattern(r'^playlistinfo "(?P\d+)"$') + @handle_pattern(r'^playlistinfo "(?P\d+):(?P\d+)*"$') def _playlistinfo(self, songpos=None, start=None, end=None): if songpos is not None: songpos = int(songpos) @@ -345,32 +345,32 @@ class MpdHandler(object): end = int(end) return self.backend.current_playlist.playlist.mpd_format(start, end) - @register(r'^playlistmove "(?P[^"]+)" "(?P\d+)" "(?P\d+)"$') + @handle_pattern(r'^playlistmove "(?P[^"]+)" "(?P\d+)" "(?P\d+)"$') def _playlistdelete(self, name, songid, songpos): raise MpdNotImplemented # TODO - @register(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') def _playlistsearch(self, tag, needle): raise MpdNotImplemented # TODO - @register(r'^plchanges "(?P\d+)"$') + @handle_pattern(r'^plchanges "(?P\d+)"$') def _plchanges(self, version): if int(version) < self.backend.current_playlist.version: return self.backend.current_playlist.playlist.mpd_format() - @register(r'^plchangesposid "(?P\d+)"$') + @handle_pattern(r'^plchangesposid "(?P\d+)"$') def _plchangesposid(self, version): raise MpdNotImplemented # TODO - @register(r'^previous$') + @handle_pattern(r'^previous$') def _previous(self): return self.backend.playback.previous() - @register(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') def _rename(self, old_name, new_name): raise MpdNotImplemented # TODO - @register(r'^random "(?P[01])"$') + @handle_pattern(r'^random "(?P[01])"$') def _random(self, state): state = int(state) if state: @@ -378,7 +378,7 @@ class MpdHandler(object): else: raise MpdNotImplemented # TODO - @register(r'^repeat "(?P[01])"$') + @handle_pattern(r'^repeat "(?P[01])"$') def _repeat(self, state): state = int(state) if state: @@ -386,41 +386,41 @@ class MpdHandler(object): else: raise MpdNotImplemented # TODO - @register(r'^replay_gain_mode "(?P(off|track|album))"$') + @handle_pattern(r'^replay_gain_mode "(?P(off|track|album))"$') def _replay_gain_mode(self, mode): raise MpdNotImplemented # TODO - @register(r'^replay_gain_status$') + @handle_pattern(r'^replay_gain_status$') def _replay_gain_status(self): return u'off' # TODO - @register(r'^rescan( "(?P[^"]+)")*$') + @handle_pattern(r'^rescan( "(?P[^"]+)")*$') def _update(self, uri=None): return self._update(uri, rescan_unmodified_files=True) - @register(r'^rm "(?P[^"]+)"$') + @handle_pattern(r'^rm "(?P[^"]+)"$') def _rm(self, name): raise MpdNotImplemented # TODO - @register(r'^save "(?P[^"]+)"$') + @handle_pattern(r'^save "(?P[^"]+)"$') def _save(self, name): raise MpdNotImplemented # TODO - @register(r'^search "(?P(album|artist|filename|title))" "(?P[^"]+)"$') + @handle_pattern(r'^search "(?P(album|artist|filename|title))" "(?P[^"]+)"$') def _search(self, type, what): return self.backend.library.search(type, what).mpd_format( search_result=True) - @register(r'^seek "(?P\d+)" "(?P\d+)"$') + @handle_pattern(r'^seek "(?P\d+)" "(?P\d+)"$') def _seek(self, songpos, seconds): raise MpdNotImplemented # TODO - @register(r'^seekid "(?P\d+)" "(?P\d+)"$') + @handle_pattern(r'^seekid "(?P\d+)" "(?P\d+)"$') def _seekid(self, songid, seconds): raise MpdNotImplemented # TODO - @register(r'^setvol (?P[-+]*\d+)$') - @register(r'^setvol "(?P[-+]*\d+)"$') + @handle_pattern(r'^setvol (?P[-+]*\d+)$') + @handle_pattern(r'^setvol "(?P[-+]*\d+)"$') def _setvol(self, volume): volume = int(volume) if volume < 0: @@ -429,12 +429,12 @@ class MpdHandler(object): volume = 100 self.backend.playback.volume = volume - @register(r'^shuffle$') - @register(r'^shuffle "(?P\d+):(?P\d+)*"$') + @handle_pattern(r'^shuffle$') + @handle_pattern(r'^shuffle "(?P\d+):(?P\d+)*"$') def _shuffle(self, start=None, end=None): raise MpdNotImplemented # TODO - @register(r'^single "(?P[01])"$') + @handle_pattern(r'^single "(?P[01])"$') def _single(self, state): state = int(state) if state: @@ -442,7 +442,7 @@ class MpdHandler(object): else: raise MpdNotImplemented # TODO - @register(r'^stats$') + @handle_pattern(r'^stats$') def _stats(self): return { 'artists': 0, # TODO @@ -454,11 +454,11 @@ class MpdHandler(object): 'playtime': 0, # TODO } - @register(r'^stop$') + @handle_pattern(r'^stop$') def _stop(self): self.backend.playback.stop() - @register(r'^status$') + @handle_pattern(r'^status$') def _status(self): result = [ ('volume', self._status_volume()), @@ -552,42 +552,42 @@ class MpdHandler(object): def _status_xfade(self): return 0 # TODO - @register(r'^sticker delete "(?P[^"]+)" "(?P[^"]+)"( "(?P[^"]+)")*$') + @handle_pattern(r'^sticker delete "(?P[^"]+)" "(?P[^"]+)"( "(?P[^"]+)")*$') def _sticker_delete(self, type, uri, name=None): raise MpdNotImplemented # TODO - @register(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') def _sticker_find(self, type, uri, name): raise MpdNotImplemented # TODO - @register(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') def _sticker_get(self, type, uri, name): raise MpdNotImplemented # TODO - @register(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') def _sticker_list(self, type, uri): raise MpdNotImplemented # TODO - @register(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') def _sticker_set(self, type, uri, name, value): raise MpdNotImplemented # TODO - @register(r'^swap "(?P\d+)" "(?P\d+)"$') + @handle_pattern(r'^swap "(?P\d+)" "(?P\d+)"$') def _swap(self, songpos1, songpos2): raise MpdNotImplemented # TODO - @register(r'^swapid "(?P\d+)" "(?P\d+)"$') + @handle_pattern(r'^swapid "(?P\d+)" "(?P\d+)"$') def _swapid(self, songid1, songid2): raise MpdNotImplemented # TODO - @register(r'^tagtypes$') + @handle_pattern(r'^tagtypes$') def _tagtypes(self): raise MpdNotImplemented # TODO - @register(r'^update( "(?P[^"]+)")*$') + @handle_pattern(r'^update( "(?P[^"]+)")*$') def _update(self, uri=None, rescan_unmodified_files=False): return {'updating_db': 0} # TODO - @register(r'^urlhandlers$') + @handle_pattern(r'^urlhandlers$') def _urlhandlers(self): return self.backend.uri_handlers From 1b231c671eb082d5ae1f75cc5946955f539990f1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Feb 2010 20:26:35 +0100 Subject: [PATCH 083/341] docs: Move MpdHandler docs into API docs --- docs/{ => api}/mpd/handler.rst | 0 docs/development.rst | 19 +------------------ mopidy/mpd/handler.py | 12 ++++++++++++ 3 files changed, 13 insertions(+), 18 deletions(-) rename docs/{ => api}/mpd/handler.rst (100%) diff --git a/docs/mpd/handler.rst b/docs/api/mpd/handler.rst similarity index 100% rename from docs/mpd/handler.rst rename to docs/api/mpd/handler.rst diff --git a/docs/development.rst b/docs/development.rst index 29d2d9ce..5c838f86 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -12,8 +12,7 @@ API documentation .. toctree:: :glob: - api/* - + api/** Scope ===== @@ -96,22 +95,6 @@ Then, to generate docs:: make html # To generate HTML docs -Music Player Daemon (MPD) -========================= - -The `MPD protocol documentation `_ is a -useful resource. It is rather incomplete with regards to data formats, both for -requests and responses. Thus we have to talk a great deal with the the original -`MPD server `_ using telnet to get the details we need -to implement our own MPD server which is compatible with the numerous existing -`MPD clients `_. - -.. toctree:: - :glob: - - mpd/* - - spytify ======= diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 83453489..17d11aef 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -1,3 +1,15 @@ +""" +Our MPD protocol implementation + +This is partly based upon the `MPD protocol documentation +`_, which is a useful resource, but it is +rather incomplete with regards to data formats, both for requests and +responses. Thus, we have had to talk a great deal with the the original `MPD +server `_ using telnet to get the details we need to +implement our own MPD server which is compatible with the numerous existing +`MPD clients `_. +""" + import logging import re import sys From 4a29ef3159fecdaaf89cbd1c0c12920220d5c362 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Feb 2010 20:38:19 +0100 Subject: [PATCH 084/341] docs: Refresh development docs a bit --- docs/development.rst | 53 +++++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 5c838f86..69d6420c 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -6,6 +6,15 @@ Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at ``irc.freenode.net`` and through `GitHub `_. +Scope for the first release +=========================== + +To limit scope, we will start by implementing an MPD server which only +supports Spotify, and not playback of files from disk. We will make Mopidy +modular, so we can extend it with other backends in the future, like file +playback and other online music services such as Last.fm. + + API documentation ================= @@ -14,14 +23,6 @@ API documentation api/** -Scope -===== - -To limit scope, we will start by implementing an MPD server which only -supports Spotify, and not playback of files from disk. We will make Mopidy -modular, so we can extend it with other backends in the future, like file -playback and other online music services such as Last.fm. - Code style ========== @@ -36,7 +37,7 @@ style guidelines, with a couple of notable exceptions: BasePlaybackController, BaseLibraryController, BaseStoredPlaylistsController) - And not:: + And *not*:: from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, BasePlaybackController, BaseLibraryController, @@ -51,7 +52,7 @@ style guidelines, with a couple of notable exceptions: and new_state == self.PLAYING): self._play_time_start() - And not:: + And *not*:: if (old_state in (self.PLAYING, self.STOPPED) and new_state == self.PLAYING): @@ -76,10 +77,14 @@ Then, to run all tests:: python tests -Generating documentation -======================== +Writing documentation +===================== -To generate documentation, you also need some additional dependencies. You can either install them through Debian/Ubuntu package management:: +To write documentation, we use `Sphinx `_. See their +site for lots of documentation on how to use Sphinx. To generate HTML or LaTeX +from the documentation files, you need some additional dependencies. + +You can either install them through Debian/Ubuntu package management:: sudo aptitude install python-sphinx @@ -94,20 +99,21 @@ Then, to generate docs:: make # For help on available targets make html # To generate HTML docs +The documentation at http://www.mopidy.com/docs/ is automatically updated +within 10 minutes after a documentation update is pushed to +``jodal/mopidy/master`` at GitHub. -spytify -======= + +Notes on despotify/spytify +========================== `spytify `_ is the Python bindings for the open source `despotify `_ library. It got no documentation to speak of, but a couple of examples are available. -Issues ------- - A list of the issues we currently experience with spytify, both bugs and -features we wished was there. +features we wished was there: * r483: Sometimes segfaults when traversing stored playlists, their tracks, artists, and albums. As it is not predictable, it may be a concurrency issue. @@ -121,17 +127,14 @@ features we wished was there. Segmentation fault -pyspotify -========= +Notes on libspotify/libopenspotify/pyspotify +============================================ `pyspotify `_ is the Python bindings for the official Spotify library, libspotify. It got no documentation to speak of, but multiple examples are available. -Issues ------- - A list of the issues we currently experience with pyspotify, both bugs and -features we wished was there. +features we wished was there: * None at the moment. From 0d80259c2baf27f897cc9881ca91dc2b9a7b14dd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Feb 2010 21:07:19 +0100 Subject: [PATCH 085/341] docs: Add last updated date to footer --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 94c08e90..bb403459 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -125,7 +125,7 @@ html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. From 31a05e6518d7dd4c50620fe2c120c0185653683b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Feb 2010 23:33:10 +0100 Subject: [PATCH 086/341] Update test after decorator rename --- tests/mpd/handlertest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 1cef5718..c80b1104 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -23,8 +23,8 @@ class RequestHandlerTest(unittest.TestCase): def test_register_same_pattern_twice_fails(self): func = lambda: None try: - handler.register('a pattern')(func) - handler.register('a pattern')(func) + handler.handle_pattern('a pattern')(func) + handler.handle_pattern('a pattern')(func) self.fail('Registering a pattern twice shoulde raise ValueError') except ValueError: pass From f7ee5b70bb9cb0a63e1df72d42a34274a7ed0a0a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Feb 2010 23:45:41 +0100 Subject: [PATCH 087/341] docs: Add MPD command descriptions from musicpd.org --- mopidy/mpd/handler.py | 718 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 708 insertions(+), 10 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 17d11aef..e75fb00f 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -27,9 +27,8 @@ def handle_pattern(pattern): raise ValueError(u'Tried to redefine handler for %s with %s' % ( pattern, func)) _request_handlers[pattern] = func - if func.__doc__ is None: - func.__doc__ = '' - func.__doc__ += '\n\n- **Pattern:** ``%s``' % pattern + func.__doc__ = ' - **Pattern:** ``%s``\n\n%s' % ( + pattern, func.__doc__ or '') return func return decorator @@ -88,44 +87,102 @@ class MpdHandler(object): @handle_pattern(r'^ack$') def _ack(self): """ - Always returns an 'ACK' and not 'OK'. - - Not a part of the MPD protocol. + Always returns an 'ACK'. Not a part of the MPD protocol. """ raise MpdNotImplemented @handle_pattern(r'^add "(?P[^"]*)"$') def _add(self, uri): + """ + *musicpd.org, current playlist section:* + + ``add {URI}`` + + Adds the file ``URI`` to the playlist (directories add recursively). + ``URI`` can also be a single file. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^addid "(?P[^"]*)"( (?P\d+))*$') - def _add(self, uri, songpos=None): + def _addid(self, uri, songpos=None): + """ + *musicpd.org, current playlist section:* + + ``addid {URI} [POSITION]`` + + Adds a song to the playlist (non-recursive) and returns the song id. + + ``URI`` is always a single file or URL. For example:: + + addid "foo.mp3" + Id: 999 + OK + """ raise MpdNotImplemented # TODO @handle_pattern(r'^clear$') def _clear(self): + """ + *musicpd.org, current playlist section:* + + ``clear`` + + Clears the current playlist. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^clearerror$') def _clearerror(self): + """ + *musicpd.org, status section:* + + ``clearerror`` + + Clears the current error message in status (this is also + accomplished by any command that starts playback). + """ raise MpdNotImplemented # TODO @handle_pattern(r'^close$') def _close(self): + """ + *musicpd.org, connection section:* + + ``close`` + + Closes the connection to MPD. + """ self.session.do_close() @handle_pattern(r'^command_list_begin$') def _command_list_begin(self): + """ + *musicpd.org, command list section:* + + To facilitate faster adding of files etc. you can pass a list of + commands all at once using a command list. The command list begins + with ``command_list_begin`` or ``command_list_ok_begin`` and ends + with ``command_list_end``. + + It does not execute any commands until the list has ended. The + return value is whatever the return for a list of commands is. On + success for all commands, ``OK`` is returned. If a command fails, + no more commands are executed and the appropriate ``ACK`` error is + returned. If ``command_list_ok_begin`` is used, ``list_OK`` is + returned for each successful command executed in the command list. + """ self.command_list = [] self.command_list_ok = False @handle_pattern(r'^command_list_ok_begin$') def _command_list_ok_begin(self): + """See :meth:`_command_list_begin`.""" self.command_list = [] self.command_list_ok = True @handle_pattern(r'^command_list_end$') def _command_list_end(self): + """See :meth:`_command_list_begin`.""" (command_list, self.command_list) = (self.command_list, False) (command_list_ok, self.command_list_ok) = (self.command_list_ok, False) result = [] @@ -141,10 +198,26 @@ class MpdHandler(object): @handle_pattern(r'^commands$') def _commands(self): + """ + *musicpd.org, reflection section:* + + ``commands`` + + Shows which commands the current user has access to. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^consume "(?P[01])"$') def _consume(self, state): + """ + *musicpd.org, playback section:* + + ``consume {STATE}`` + + Sets consume state to ``STATE``, ``STATE`` should be 0 or + 1. When consume is activated, each song played is removed from + playlist. + """ state = int(state) if state: raise MpdNotImplemented # TODO @@ -153,30 +226,82 @@ class MpdHandler(object): @handle_pattern(r'^count "(?P[^"]+)" "(?P[^"]+)"$') def _count(self, tag, needle): + """ + *musicpd.org, music database section:* + + ``count {TAG} {NEEDLE}`` + + Counts the number of songs and their total playtime in the db + matching ``TAG`` exactly. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^crossfade "(?P\d+)"$') def _crossfade(self, seconds): + """ + *musicpd.org, playback section:* + + ``crossfade {SECONDS}`` + + Sets crossfading between songs. + """ seconds = int(seconds) raise MpdNotImplemented # TODO @handle_pattern(r'^currentsong$') def _currentsong(self): + """ + *musicpd.org, status section:* + + ``currentsong`` + + Displays the song info of the current song (same song that is + identified in status). + """ if self.backend.playback.current_track is not None: return self.backend.playback.current_track.mpd_format( position=self.backend.playback.playlist_position) @handle_pattern(r'^decoders$') def _decoders(self): + """ + *musicpd.org, reflection section:* + + ``decoders`` + + Print a list of decoder plugins, followed by their supported + suffixes and MIME types. Example response:: + + plugin: mad + suffix: mp3 + suffix: mp2 + mime_type: audio/mpeg + plugin: mpcdec + suffix: mpc + """ raise MpdNotImplemented # TODO @handle_pattern(r'^delete "(?P\d+)"$') @handle_pattern(r'^delete "(?P\d+):(?P\d+)*"$') def _delete(self, songpos=None, start=None, end=None): + """ + *musicpd.org, current playlist section:* + + ``delete [{POS} | {START:END}]`` + + Deletes a song from the playlist. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^deleteid "(?P\d+)"$') def _deleteid(self, songid): + """ + *musicpd.org, current playlist section:* + + ``deleteid {SONGID}`` + + Deletes the song ``SONGID`` from the playlist + """ songid = int(songid) try: track = self.backend.current_playlist.get_by_id(songid) @@ -186,22 +311,54 @@ class MpdHandler(object): @handle_pattern(r'^disableoutput "(?P\d+)"$') def _disableoutput(self, outputid): + """ + *musicpd.org, audio output section:* + + ``disableoutput`` + + Turns an output off. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^$') def _empty(self): + """The original MPD server returns ``OK`` on an empty request.``""" pass @handle_pattern(r'^enableoutput "(?P\d+)"$') def _enableoutput(self, outputid): + """ + *musicpd.org, audio output section:* + + ``enableoutput`` + + Turns an output on. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^find "(?P(album|artist|title))" "(?P[^"]+)"$') def _find(self, type, what): + """ + *musicpd.org, music database section:* + + ``find {TYPE} {WHAT}`` + + Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be + ``album``, ``artist``, or ``title``. ``WHAT`` is what to find. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^findadd "(?P(album|artist|title))" "(?P[^"]+)"$') def _findadd(self, type, what): + """ + *musicpd.org, music database section:* + + ``findadd {TYPE} {WHAT}`` + + Finds songs in the db that are exactly ``WHAT`` and adds them to + current playlist. ``TYPE`` can be any tag supported by MPD. + ``WHAT`` is what to find. + """ result = self._find(type, what) # TODO Add result to current playlist #return result @@ -209,40 +366,139 @@ class MpdHandler(object): @handle_pattern(r'^idle$') @handle_pattern(r'^idle (?P.+)$') def _idle(self, subsystems=None): + """ + *musicpd.org, status section:* + + ``idle [SUBSYSTEMS...]`` + + Waits until there is a noteworthy change in one or more of MPD's + subsystems. As soon as there is one, it lists all changed systems + in a line in the format ``changed: SUBSYSTEM``, where ``SUBSYSTEM`` + is one of the following: + + - ``database``: the song database has been modified after update. + - ``update``: a database update has started or finished. If the + database was modified during the update, the database event is + also emitted. + - ``stored_playlist``: a stored playlist has been modified, + renamed, created or deleted + - ``playlist``: the current playlist has been modified + - ``player``: the player has been started, stopped or seeked + - ``mixer``: the volume has been changed + - ``output``: an audio output has been enabled or disabled + - ``options``: options like repeat, random, crossfade, replay gain + + While a client is waiting for idle results, the server disables + timeouts, allowing a client to wait for events as long as MPD runs. + The idle command can be canceled by sending the command ``noidle`` + (no other commands are allowed). MPD will then leave idle mode and + print results immediately; might be empty at this time. + + If the optional ``SUBSYSTEMS`` argument is used, MPD will only send + notifications when something changed in one of the specified + subsystems. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^kill$') def _kill(self): + """ + *musicpd.org, connection section:* + + ``kill`` + + Kills MPD. + """ self.session.do_kill() @handle_pattern(r'^list "(?Partist)"$') @handle_pattern(r'^list "(?Palbum)"( "(?P[^"]+)")*$') def _list(self, type, artist=None): + """ + *musicpd.org, music database section:* + + ``list {TYPE} [ARTIST]`` + + Lists all tags of the specified type. ``TYPE`` should be ``album`` + or artist. + + ``ARTIST`` is an optional parameter when type is ``album``, this + specifies to list albums by an artist. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^listall "(?P[^"]+)"') def _listall(self, uri): + """ + *musicpd.org, music database section:* + + ``listall [URI]`` + + Lists all songs and directories in ``URI``. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^listallinfo "(?P[^"]+)"') def _listallinfo(self, uri): + """ + *musicpd.org, music database section:* + + ``listallinfo [URI]`` + + Same as ``listall``, except it also returns metadata info in the + same format as ``lsinfo``. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^listplaylist "(?P[^"]+)"$') def _listplaylist(self, name): + """ + *musicpd.org, stored playlists section:* + + ``listplaylist {NAME}`` + + Lists the files in the playlist ``NAME.m3u``. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') def _listplaylistinfo(self, name): + """ + *musicpd.org, stored playlists section:* + + ``listplaylistinfo {NAME}`` + + Lists songs in the playlist ``NAME.m3u``. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^listplaylists$') def _listplaylists(self): + """ + *musicpd.org, stored playlists section:* + + ``listplaylists`` + + Prints a list of the playlist directory. + + After each playlist name the server sends its last modification + time as attribute ``Last-Modified`` in ISO 8601 format. To avoid + problems due to clock differences between clients and the server, + clients should not compare this value with their local clock. + """ + # TODO Add Last-Modified attribute to output return [u'playlist: %s' % p.name for p in self.backend.stored_playlists.playlists] @handle_pattern(r'^load "(?P[^"]+)"$') def _load(self, name): + """ + *musicpd.org, stored playlists section:* + + ``load {NAME}`` + + Loads the playlist ``NAME.m3u`` from the playlist directory. + """ matches = self.backend.stored_playlists.search(name) if matches: self.backend.current_playlist.load(matches[0]) @@ -251,6 +507,17 @@ class MpdHandler(object): @handle_pattern(r'^lsinfo$') @handle_pattern(r'^lsinfo "(?P[^"]*)"$') def _lsinfo(self, uri=None): + """ + *musicpd.org, music database section:* + + ``lsinfo [URI]`` + + Lists the contents of the directory ``URI``. + + When listing the root directory, this currently returns the list of + stored playlists. This behavior is deprecated; use + ``listplaylists`` instead. + """ if uri == u'/' or uri is None: return self._listplaylists() raise MpdNotImplemented # TODO @@ -258,22 +525,65 @@ class MpdHandler(object): @handle_pattern(r'^move "(?P\d+)" "(?P\d+)"$') @handle_pattern(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') def _move(self, songpos=None, start=None, end=None, to=None): + """ + *musicpd.org, current playlist section:* + + ``move [{FROM} | {START:END}] {TO}`` + + Moves the song at ``FROM`` or range of songs at ``START:END`` to + ``TO`` in the playlist. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^moveid "(?P\d+)" "(?P\d+)"$') def _moveid(self, songid, to): + """ + *musicpd.org, current playlist section:* + + ``moveid {FROM} {TO}`` + + Moves the song with ``FROM`` (songid) to ``TO` (playlist index) in + the playlist. If ``TO`` is negative, it is relative to the current + song in the playlist (if there is one). + """ raise MpdNotImplemented # TODO @handle_pattern(r'^next$') def _next(self): + """ + *musicpd.org, playback section:* + + ``next`` + + Plays next song in the playlist. + """ return self.backend.playback.next() + @handle_pattern(r'^noidle$') + def _noidle(self): + """See :meth:`_idle`.""" + raise MpdNotImplemented # TODO + @handle_pattern(r'^notcommands$') def _notcommands(self): + """ + *musicpd.org, reflection section:* + + ``notcommands`` + + Shows which commands the current user does not have access to. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^outputs$') def _outputs(self): + """ + *musicpd.org, audio output section:* + + ``outputs`` + + Shows information about all outputs. + """ return [ ('outputid', 0), ('outputname', self.backend.__class__.__name__), @@ -282,10 +592,25 @@ class MpdHandler(object): @handle_pattern(r'^password "(?P[^"]+)"$') def _password(self, password): + """ + *musicpd.org, connection section:* + + ``password {PASSWORD}`` + + This is used for authentication with the server. ``PASSWORD`` is + simply the plaintext password. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^pause "(?P[01])"$') def _pause(self, state): + """ + *musicpd.org, playback section:* + + ``pause {PAUSE}`` + + Toggles pause/resumes playing, ``PAUSE`` is 0 or 1. + """ if int(state): self.backend.playback.pause() else: @@ -293,14 +618,32 @@ class MpdHandler(object): @handle_pattern(r'^ping$') def _ping(self): + """ + *musicpd.org, connection section:* + + ``ping`` + + Does nothing but return ``OK``. + """ pass @handle_pattern(r'^play$') def _play(self): + """ + The original MPD server resumes from the paused state on ``play`` + without arguments. + """ return self.backend.playback.play() @handle_pattern(r'^play "(?P\d+)"$') def _playpos(self, songpos): + """ + *musicpd.org, playback section:* + + ``play [SONGPOS]`` + + Begins playing the playlist at song number ``SONGPOS``. + """ songpos = int(songpos) try: track = self.backend.current_playlist.playlist.tracks[songpos] @@ -310,6 +653,13 @@ class MpdHandler(object): @handle_pattern(r'^playid "(?P\d+)"$') def _playid(self, songid): + """ + *musicpd.org, playback section:* + + ``playid [SONGID]`` + + Begins playing the playlist at song ``SONGID``. + """ songid = int(songid) try: track = self.backend.current_playlist.get_by_id(songid) @@ -319,32 +669,91 @@ class MpdHandler(object): @handle_pattern(r'^playlist$') def _playlist(self): + """ + *musicpd.org, current playlist section:* + + ``playlist`` + + Displays the current playlist. + + .. note:: + + Do not use this, instead use ``playlistinfo``. + """ return self._playlistinfo() @handle_pattern(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') def _playlistadd(self, name, uri): + """ + *musicpd.org, stored playlists section:* + + ``playlistadd {NAME} {URI}`` + + Adds ``URI`` to the playlist ``NAME.m3u``. + + ``NAME.m3u`` will be created if it does not exist. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^playlistclear "(?P[^"]+)"$') def _playlistclear(self, name): + """ + *musicpd.org, stored playlists section:* + + ``playlistclear {NAME}`` + + Clears the playlist ``NAME.m3u``. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') def _playlistdelete(self, name, songpos): + """ + *musicpd.org, stored playlists section:* + + ``playlistdelete {NAME} {SONGPOS}`` + + Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') def _playlistfind(self, tag, needle): + """ + *musicpd.org, current playlist section:* + + ``playlistfind {TAG} {NEEDLE}`` + + Finds songs in the current playlist with strict matching. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^playlistid( "(?P\S+)")*$') def _playlistid(self, songid=None): + """ + *musicpd.org, current playlist section:* + + ``playlistid {SONGID}`` + + Displays a list of songs in the playlist. ``SONGID`` is optional + and specifies a single song to display info for. + """ + # TODO Limit selection to songid return self.backend.current_playlist.playlist.mpd_format() @handle_pattern(r'^playlistinfo$') @handle_pattern(r'^playlistinfo "(?P\d+)"$') @handle_pattern(r'^playlistinfo "(?P\d+):(?P\d+)*"$') def _playlistinfo(self, songpos=None, start=None, end=None): + """ + *musicpd.org, current playlist section:* + + ``playlistinfo [[SONGPOS] | [START:END]]`` + + Displays a list of all songs in the playlist, or if the optional + argument is given, displays information only for the song + ``SONGPOS`` or the range of songs ``START:END``. + """ if songpos is not None: songpos = int(songpos) return self.backend.current_playlist.playlist.mpd_format( @@ -358,32 +767,91 @@ class MpdHandler(object): return self.backend.current_playlist.playlist.mpd_format(start, end) @handle_pattern(r'^playlistmove "(?P[^"]+)" "(?P\d+)" "(?P\d+)"$') - def _playlistdelete(self, name, songid, songpos): + def _playlistmove(self, name, songid, songpos): + """ + *musicpd.org, stored playlists section:* + + ``playlistmove {NAME} {SONGID} {SONGPOS}`` + + Moves ``SONGID`` in the playlist ``NAME.m3u`` to the position + ``SONGPOS``. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') def _playlistsearch(self, tag, needle): + """ + *musicpd.org, current playlist section:* + + ``playlistsearch {TAG} {NEEDLE}`` + + Searches case-sensitively for partial matches in the current + playlist. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^plchanges "(?P\d+)"$') def _plchanges(self, version): + """ + *musicpd.org, current playlist section:* + + ``plchanges {VERSION}`` + + Displays changed songs currently in the playlist since ``VERSION``. + + To detect songs that were deleted at the end of the playlist, use + ``playlistlength`` returned by status command. + """ if int(version) < self.backend.current_playlist.version: return self.backend.current_playlist.playlist.mpd_format() @handle_pattern(r'^plchangesposid "(?P\d+)"$') def _plchangesposid(self, version): + """ + *musicpd.org, current playlist section:* + + ``plchangesposid {VERSION}`` + + Displays changed songs currently in the playlist since ``VERSION``. + This function only returns the position and the id of the changed + song, not the complete metadata. This is more bandwidth efficient. + + To detect songs that were deleted at the end of the playlist, use + ``playlistlength`` returned by status command. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^previous$') def _previous(self): + """ + *musicpd.org, playback section:* + + ``previous`` + + Plays previous song in the playlist. + """ return self.backend.playback.previous() @handle_pattern(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') def _rename(self, old_name, new_name): + """ + *musicpd.org, stored playlists section:* + + ``rename {NAME} {NEW_NAME}`` + + Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^random "(?P[01])"$') def _random(self, state): + """ + *musicpd.org, playback section:* + + ``random {STATE}`` + + Sets random state to ``STATE``, ``STATE`` should be 0 or 1. + """ state = int(state) if state: raise MpdNotImplemented # TODO @@ -392,6 +860,13 @@ class MpdHandler(object): @handle_pattern(r'^repeat "(?P[01])"$') def _repeat(self, state): + """ + *musicpd.org, playback section:* + + ``repeat {STATE}`` + + Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. + """ state = int(state) if state: raise MpdNotImplemented # TODO @@ -400,40 +875,112 @@ class MpdHandler(object): @handle_pattern(r'^replay_gain_mode "(?P(off|track|album))"$') def _replay_gain_mode(self, mode): + """ + *musicpd.org, playback section:* + + ``replay_gain_mode {MODE}`` + + Sets the replay gain mode. One of ``off``, ``track``, ``album``. + + Changing the mode during playback may take several seconds, because + the new settings does not affect the buffered data. + + This command triggers the options idle event. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^replay_gain_status$') def _replay_gain_status(self): + """ + *musicpd.org, playback section:* + + ``replay_gain_status`` + + Prints replay gain options. Currently, only the variable + ``replay_gain_mode`` is returned. + """ return u'off' # TODO @handle_pattern(r'^rescan( "(?P[^"]+)")*$') - def _update(self, uri=None): + def _rescan(self, uri=None): + """ + *musicpd.org, music database section:* + + ``rescan [URI]`` + + Same as ``update``, but also rescans unmodified files. + """ return self._update(uri, rescan_unmodified_files=True) @handle_pattern(r'^rm "(?P[^"]+)"$') def _rm(self, name): + """ + *musicpd.org, stored playlists section:* + + ``rm {NAME}`` + + Removes the playlist ``NAME.m3u`` from the playlist directory. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^save "(?P[^"]+)"$') def _save(self, name): + """ + *musicpd.org, stored playlists section:* + + ``save {NAME}`` + + Saves the current playlist to ``NAME.m3u`` in the playlist + directory. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^search "(?P(album|artist|filename|title))" "(?P[^"]+)"$') def _search(self, type, what): + """ + *musicpd.org, music database section:* + + ``search {TYPE} {WHAT}`` + + Searches for any song that contains ``WHAT``. ``TYPE`` can be + ``title``, ``artist``, ``album`` or ``filename``. Search is not + case sensitive. + """ return self.backend.library.search(type, what).mpd_format( search_result=True) @handle_pattern(r'^seek "(?P\d+)" "(?P\d+)"$') def _seek(self, songpos, seconds): + """ + *musicpd.org, playback section:* + + ``seek {SONGPOS} {TIME}`` + + Seeks to the position ``TIME`` (in seconds) of entry ``SONGPOS`` in + the playlist. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^seekid "(?P\d+)" "(?P\d+)"$') def _seekid(self, songid, seconds): + """ + *musicpd.org, playback section:* + + ``seekid {SONGID} {TIME}`` + + Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. + """ raise MpdNotImplemented # TODO - @handle_pattern(r'^setvol (?P[-+]*\d+)$') @handle_pattern(r'^setvol "(?P[-+]*\d+)"$') def _setvol(self, volume): + """ + *musicpd.org, playback section:* + + ``setvol {VOL}`` + + Sets volume to ``VOL``, the range of volume is 0-100. + """ volume = int(volume) if volume < 0: volume = 0 @@ -444,10 +991,27 @@ class MpdHandler(object): @handle_pattern(r'^shuffle$') @handle_pattern(r'^shuffle "(?P\d+):(?P\d+)*"$') def _shuffle(self, start=None, end=None): + """ + *musicpd.org, current playlist section:* + + ``shuffle [START:END]`` + + Shuffles the current playlist. ``START:END`` is optional and + specifies a range of songs. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^single "(?P[01])"$') def _single(self, state): + """ + *musicpd.org, playback section:* + + ``single {STATE}`` + + Sets single state to ``STATE``, ``STATE`` should be 0 or 1. When + single is activated, playback is stopped after current song, or + song is repeated if the ``repeat`` mode is enabled. + """ state = int(state) if state: raise MpdNotImplemented # TODO @@ -456,6 +1020,20 @@ class MpdHandler(object): @handle_pattern(r'^stats$') def _stats(self): + """ + *musicpd.org, status section:* + + ``stats`` + + Displays statistics. + + - ``artists``: number of artists + - ``songs``: number of albums + - ``uptime``: daemon uptime in seconds + - ``db_playtime``: sum of all song times in the db + - ``db_update``: last db update in UNIX time + - ``playtime``: time length of music played + """ return { 'artists': 0, # TODO 'albums': 0, # TODO @@ -468,10 +1046,47 @@ class MpdHandler(object): @handle_pattern(r'^stop$') def _stop(self): + """ + *musicpd.org, playback section:* + + ``stop`` + + Stops playing. + """ self.backend.playback.stop() @handle_pattern(r'^status$') def _status(self): + """ + *musicpd.org, status section:* + + ``status`` + + Reports the current status of the player and the volume level. + + - ``volume``: 0-100 + - ``repeat``: 0 or 1 + - ``single``: 0 or 1 + - ``consume``: 0 or 1 + - ``playlist``: 31-bit unsigned integer, the playlist version + number + - ``playlistlength``: integer, the length of the playlist + - ``state``: play, stop, or pause + - ``song``: playlist song number of the current song stopped on or + playing + - ``songid``: playlist songid of the current song stopped on or + playing + - ``nextsong``: playlist song number of the next song to be played + - ``nextsongid``: playlist songid of the next song to be played + - ``time``: total time elapsed (of current playing/paused song) + - ``elapsed``: Total time elapsed within the current song, but with + higher resolution. + - ``bitrate``: instantaneous bitrate in kbps + - ``xfade``: crossfade in seconds + - ``audio``: sampleRate``:bits``:channels + - ``updatings_db``: job id + - ``error``: if there is an error, returns message here + """ result = [ ('volume', self._status_volume()), ('repeat', self._status_repeat()), @@ -489,6 +1104,7 @@ class MpdHandler(object): if self.backend.playback.state in ( self.backend.playback.PLAYING, self.backend.playback.PAUSED): result.append(('time', self._status_time())) + # TODO Add 'elapsed' here when moving to MPD 0.16.0 result.append(('bitrate', self._status_bitrate())) return result @@ -566,40 +1182,122 @@ class MpdHandler(object): @handle_pattern(r'^sticker delete "(?P[^"]+)" "(?P[^"]+)"( "(?P[^"]+)")*$') def _sticker_delete(self, type, uri, name=None): + """ + *musicpd.org, sticker section:* + + ``sticker delete {TYPE} {URI} [NAME]`` + + Deletes a sticker value from the specified object. If you do not + specify a sticker name, all sticker values are deleted. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') def _sticker_find(self, type, uri, name): + """ + *musicpd.org, sticker section:* + + ``sticker find {TYPE} {URI} {NAME}`` + + Searches the sticker database for stickers with the specified name, + below the specified directory (``URI``). For each matching song, it + prints the ``URI`` and that one sticker's value. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') def _sticker_get(self, type, uri, name): + """ + *musicpd.org, sticker section:* + + ``sticker get {TYPE} {URI} {NAME}`` + + Reads a sticker value for the specified object. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') def _sticker_list(self, type, uri): + """ + *musicpd.org, sticker section:* + + ``sticker list {TYPE} {URI}`` + + Lists the stickers for the specified object. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') def _sticker_set(self, type, uri, name, value): + """ + *musicpd.org, sticker section:* + + ``sticker set {TYPE} {URI} {NAME} {VALUE}`` + + Adds a sticker value to the specified object. If a sticker item + with that name already exists, it is replaced. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^swap "(?P\d+)" "(?P\d+)"$') def _swap(self, songpos1, songpos2): + """ + *musicpd.org, current playlist section:* + + ``swap {SONG1} {SONG2}`` + + Swaps the positions of ``SONG1`` and ``SONG2``. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^swapid "(?P\d+)" "(?P\d+)"$') def _swapid(self, songid1, songid2): + """ + *musicpd.org, current playlist section:* + + ``swapid {SONG1} {SONG2}`` + + Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). + """ raise MpdNotImplemented # TODO @handle_pattern(r'^tagtypes$') def _tagtypes(self): + """ + *musicpd.org, reflection section:* + + ``tagtypes`` + + Shows a list of available song metadata. + """ raise MpdNotImplemented # TODO @handle_pattern(r'^update( "(?P[^"]+)")*$') def _update(self, uri=None, rescan_unmodified_files=False): + """ + *musicpd.org, music database section:* + + ``update [URI]`` + + Updates the music database: find new files, remove deleted files, + update modified files. + + ``URI`` is a particular directory or song/file to update. If you do + not specify it, everything is updated. + + Prints ``updating_db: JOBID`` where ``JOBID`` is a positive number + identifying the update job. You can read the current job id in the + ``status`` response. + """ return {'updating_db': 0} # TODO @handle_pattern(r'^urlhandlers$') def _urlhandlers(self): + """ + *musicpd.org, reflection section:* + + ``urlhandlers`` + + Gets a list of available URL handlers. + """ return self.backend.uri_handlers From 3cff0edc9e9c126f9f774021a21b0dceb93b389a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Feb 2010 23:46:05 +0100 Subject: [PATCH 088/341] Remove unused import --- mopidy/mpd/handler.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index e75fb00f..776242eb 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -14,7 +14,6 @@ import logging import re import sys -from mopidy import settings from mopidy.exceptions import MpdAckError, MpdNotImplemented logger = logging.getLogger('mpd.handler') From cade4b67f84ae217700163dd9242fe2ebe46fbd7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Feb 2010 23:48:43 +0100 Subject: [PATCH 089/341] MPD encoding is declared as UTF-8 in the standard, so it should be a constant instead of a setting --- mopidy/mpd/session.py | 10 ++++++---- mopidy/settings.py | 3 --- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index c85ed3a0..51aae70b 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -7,6 +7,9 @@ from mopidy.mpd.handler import MpdHandler logger = logging.getLogger(u'mpd.session') +#: All data between the client and the server is encoded in UTF-8. +ENCODING = 'utf-8' + def indent(string, places=4, linebreak=config.MPD_LINE_TERMINATOR): lines = string.split(linebreak) if len(lines) == 1: @@ -23,8 +26,7 @@ class MpdSession(asynchat.async_chat): self.server = server self.client_address = client_address self.input_buffer = [] - self.set_terminator(config.MPD_LINE_TERMINATOR.encode( - config.MPD_LINE_ENCODING)) + self.set_terminator(config.MPD_LINE_TERMINATOR.encode(ENCODING)) self.handler = handler_class(session=self, backend=backend) self.send_response(u'OK MPD %s' % get_mpd_protocol_version()) @@ -41,7 +43,7 @@ class MpdSession(asynchat.async_chat): def found_terminator(self): data = ''.join(self.input_buffer).strip() self.input_buffer = [] - input = data.decode(config.MPD_LINE_ENCODING) + input = data.decode(ENCODING) logger.debug(u'Input: %s', indent(input)) self.handle_request(input) @@ -60,7 +62,7 @@ class MpdSession(asynchat.async_chat): def send_response(self, output): logger.debug(u'Output: %s', indent(output)) output = u'%s%s' % (output, config.MPD_LINE_TERMINATOR) - data = output.encode(config.MPD_LINE_ENCODING) + data = output.encode(ENCODING) self.push(data) def stats_uptime(self): diff --git a/mopidy/settings.py b/mopidy/settings.py index 4e442432..818370a3 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -31,9 +31,6 @@ BACKENDS = ( #: the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n %(message)s' -#: Encoding used in MPD protocol. *Default:* ``utf-8`` -MPD_LINE_ENCODING = u'utf-8' - #: Line terminator character used in MPD protocol. *Default:* ``\n`` MPD_LINE_TERMINATOR = u'\n' From 3f0b216d19bc6ef927db40f16d13b3e12006d22d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Feb 2010 23:52:50 +0100 Subject: [PATCH 090/341] Assuming that the MPD line terminator does not vary neither --- mopidy/mpd/session.py | 14 ++++++++------ mopidy/settings.py | 3 --- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index 51aae70b..d7ecfeaa 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -1,16 +1,18 @@ import asynchat import logging -from mopidy import get_mpd_protocol_version, config +from mopidy import get_mpd_protocol_version from mopidy.exceptions import MpdAckError from mopidy.mpd.handler import MpdHandler logger = logging.getLogger(u'mpd.session') #: All data between the client and the server is encoded in UTF-8. -ENCODING = 'utf-8' +ENCODING = u'utf-8' -def indent(string, places=4, linebreak=config.MPD_LINE_TERMINATOR): +LINE_TERMINATOR = u'\n' + +def indent(string, places=4, linebreak=LINE_TERMINATOR): lines = string.split(linebreak) if len(lines) == 1: return string @@ -26,7 +28,7 @@ class MpdSession(asynchat.async_chat): self.server = server self.client_address = client_address self.input_buffer = [] - self.set_terminator(config.MPD_LINE_TERMINATOR.encode(ENCODING)) + self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) self.handler = handler_class(session=self, backend=backend) self.send_response(u'OK MPD %s' % get_mpd_protocol_version()) @@ -57,11 +59,11 @@ class MpdSession(asynchat.async_chat): return self.send_response(u'ACK %s' % e) def handle_response(self, response): - self.send_response(config.MPD_LINE_TERMINATOR.join(response)) + self.send_response(LINE_TERMINATOR.join(response)) def send_response(self, output): logger.debug(u'Output: %s', indent(output)) - output = u'%s%s' % (output, config.MPD_LINE_TERMINATOR) + output = u'%s%s' % (output, LINE_TERMINATOR) data = output.encode(ENCODING) self.push(data) diff --git a/mopidy/settings.py b/mopidy/settings.py index 818370a3..fc335ae3 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -31,9 +31,6 @@ BACKENDS = ( #: the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n %(message)s' -#: Line terminator character used in MPD protocol. *Default:* ``\n`` -MPD_LINE_TERMINATOR = u'\n' - #: Which address Mopidy should bind to. Examples: #: #: ``localhost`` From a7850dbabe674aaed2f65f682a8985790d8d2461 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Feb 2010 00:03:46 +0100 Subject: [PATCH 091/341] Make mopidy.settings a module --- .gitignore | 2 +- docs/installation.rst | 11 +++++------ docs/settings.rst | 2 +- mopidy/__init__.py | 4 ++-- mopidy/settings/__init__.py | 6 ++++++ mopidy/{settings.py => settings/default.py} | 7 +------ 6 files changed, 16 insertions(+), 16 deletions(-) create mode 100644 mopidy/settings/__init__.py rename mopidy/{settings.py => settings/default.py} (91%) diff --git a/.gitignore b/.gitignore index 65ed3f37..0ddcf2a3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,8 @@ .coverage .idea docs/_build -local_settings.py pip-log.txt +mopidy/settings/local.py spotify_appkey.key src/ tmp/ diff --git a/docs/installation.rst b/docs/installation.rst index 004454e8..4f15f101 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -147,9 +147,8 @@ Test your libspotify setup:: Spotify settings ================ -Create a file name ``local_settings.py`` in the same directory as -``settings.py``. Enter your Spotify Premium account's username and password -into the file, like this:: +Create a file named ``local.py`` in the directory ``mopidy/settings/``. Enter +your Spotify Premium account's username and password into the file, like this:: SPOTIFY_USERNAME = u'myusername' SPOTIFY_PASSWORD = u'mysecret' @@ -157,11 +156,11 @@ into the file, like this:: Currently the despotify backend is the default. If you want to use the libspotify backend, copy the Spotify application key to ``mopidy/spotify_appkey.key``, and add the following to -``mopidy/mopidy/local_settings.py``:: +``mopidy/mopidy/settings/local.py``:: - BACKEND = u'mopidy.backends.libspotify.LibspotifyBackend' + BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',) -For a full list of available settings, see :mod:`mopidy.settings`. +For a full list of available settings, see :mod:`mopidy.settings.default`. Running Mopidy diff --git a/docs/settings.rst b/docs/settings.rst index b449b247..7e67de50 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -2,7 +2,7 @@ Settings ******** -.. automodule:: mopidy.settings +.. automodule:: mopidy.settings.default :synopsis: Available settings and their default values. :members: :undoc-members: diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 9cd068a6..0c54ab33 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,8 +1,8 @@ -from mopidy import settings from mopidy.exceptions import ConfigError +from mopidy import settings def get_version(): - return u'0' + return u'0.1.dev' def get_mpd_protocol_version(): return u'0.15.0' diff --git a/mopidy/settings/__init__.py b/mopidy/settings/__init__.py new file mode 100644 index 00000000..58715fd0 --- /dev/null +++ b/mopidy/settings/__init__.py @@ -0,0 +1,6 @@ +from mopidy.settings.default import * + +try: + from mopidy.settings.local import * +except ImportError: + pass diff --git a/mopidy/settings.py b/mopidy/settings/default.py similarity index 91% rename from mopidy/settings.py rename to mopidy/settings/default.py index fc335ae3..e07bde02 100644 --- a/mopidy/settings.py +++ b/mopidy/settings/default.py @@ -4,7 +4,7 @@ Available settings and their default values. .. warning:: To users Do *not* change settings here. Instead, add a file called - ``mopidy/local_settings.py`` and redefine settings there. + ``mopidy/settings/local.py`` and redefine settings there. .. note:: To developers @@ -47,8 +47,3 @@ SPOTIFY_USERNAME = u'' #: Your Spotify Premium password. Used by all Spotify backends. SPOTIFY_PASSWORD = u'' - -try: - from mopidy.local_settings import * -except ImportError: - pass From be647625b17694aaf4deb23b49818a1aa7dfc357 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Feb 2010 00:06:56 +0100 Subject: [PATCH 092/341] docs: Get docs version from mopidy.get_version() --- docs/conf.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index bb403459..5f6b7ef2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,6 +19,8 @@ import sys, os sys.path.append(os.path.abspath(os.path.dirname(__file__))) sys.path.append(os.path.abspath(os.path.dirname(__file__) + '/../')) +import mopidy + # -- General configuration ----------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be extensions @@ -45,10 +47,11 @@ copyright = u'2010, Stein Magnus Jodal' # |version| and |release|, also used in various other places throughout the # built documents. # -# The short X.Y version. -version = '0.1' # The full version, including alpha/beta/rc tags. -release = '0.1.dev' +release = mopidy.get_version() +# The short X.Y version. +version = '.'.join(release.split('.')[:2]) + # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. From 7fde4ef054886717142cacdc20751ddd1f252bd7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Feb 2010 00:08:14 +0100 Subject: [PATCH 093/341] Remove encoding of unicode objects from mopidy.config --- mopidy/__init__.py | 2 -- mopidy/backends/despotify.py | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 0c54ab33..2def5cfe 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -14,8 +14,6 @@ class Config(object): value = getattr(settings, attr) if type(value) != bool and not value: raise ConfigError(u'Setting "%s" is empty.' % attr) - if type(value) == unicode: - value = value.encode('utf-8') return value config = Config() diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index dc786d64..062d492e 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -27,7 +27,8 @@ class DespotifyBackend(BaseBackend): def _connect(self): logger.info(u'Connecting to Spotify') return spytify.Spytify( - config.SPOTIFY_USERNAME, config.SPOTIFY_PASSWORD) + config.SPOTIFY_USERNAME.encode(ENCODING), + config.SPOTIFY_PASSWORD.encode(ENCODING)) class DespotifyCurrentPlaylistController(BaseCurrentPlaylistController): From 5fa21d237d6dc09a4fd71a0afa53839a7b1e0dd3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Feb 2010 00:11:53 +0100 Subject: [PATCH 094/341] Get rid of mopidy.config --- mopidy/__init__.py | 16 ++++++++-------- mopidy/__main__.py | 10 +++++----- mopidy/backends/despotify.py | 6 +++--- mopidy/backends/libspotify.py | 4 ++-- mopidy/exceptions.py | 2 +- mopidy/mpd/server.py | 6 +++--- mopidy/settings/default.py | 9 +-------- 7 files changed, 23 insertions(+), 30 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 2def5cfe..7316ab6f 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,5 +1,5 @@ -from mopidy.exceptions import ConfigError -from mopidy import settings +from mopidy.exceptions import SettingError +from mopidy import settings as original_settings def get_version(): return u'0.1.dev' @@ -7,13 +7,13 @@ def get_version(): def get_mpd_protocol_version(): return u'0.15.0' -class Config(object): +class Settings(object): def __getattr__(self, attr): - if not hasattr(settings, attr): - raise ConfigError(u'Setting "%s" is not set.' % attr) - value = getattr(settings, attr) + if not hasattr(original_settings, attr): + raise SettingError(u'Setting "%s" is not set.' % attr) + value = getattr(original_settings, attr) if type(value) != bool and not value: - raise ConfigError(u'Setting "%s" is empty.' % attr) + raise SettingError(u'Setting "%s" is empty.' % attr) return value -config = Config() +settings = Settings() diff --git a/mopidy/__main__.py b/mopidy/__main__.py index ee1a3384..edc5c808 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -6,15 +6,15 @@ import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import config -from mopidy.exceptions import ConfigError +from mopidy import settings +from mopidy.exceptions import SettingError from mopidy.mpd.server import MpdServer logger = logging.getLogger('mopidy') def main(): _setup_logging(2) - backend = _get_backend(config.BACKENDS[0]) + backend = _get_backend(settings.BACKENDS[0]) MpdServer(backend=backend) asyncore.loop() @@ -26,7 +26,7 @@ def _setup_logging(verbosity_level): else: level = logging.INFO logging.basicConfig( - format=config.CONSOLE_LOG_FORMAT, + format=settings.CONSOLE_LOG_FORMAT, level=level, ) @@ -44,5 +44,5 @@ if __name__ == '__main__': main() except KeyboardInterrupt: sys.exit('\nInterrupted by user') - except ConfigError, e: + except SettingError, e: sys.exit('%s' % e) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 062d492e..178699b7 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -3,7 +3,7 @@ import logging import spytify -from mopidy import config +from mopidy import settings from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, BaseLibraryController, BasePlaybackController, BaseStoredPlaylistsController) @@ -27,8 +27,8 @@ class DespotifyBackend(BaseBackend): def _connect(self): logger.info(u'Connecting to Spotify') return spytify.Spytify( - config.SPOTIFY_USERNAME.encode(ENCODING), - config.SPOTIFY_PASSWORD.encode(ENCODING)) + settings.SPOTIFY_USERNAME.encode(ENCODING), + settings.SPOTIFY_PASSWORD.encode(ENCODING)) class DespotifyCurrentPlaylistController(BaseCurrentPlaylistController): diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index dfe40609..4b99c49e 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -6,7 +6,7 @@ from spotify import Link from spotify.manager import SpotifySessionManager from spotify.alsahelper import AlsaController -from mopidy import config +from mopidy import settings from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, BaseLibraryController, BasePlaybackController, BaseStoredPlaylistsController) @@ -31,7 +31,7 @@ class LibspotifyBackend(BaseBackend): def _connect(self): logger.info(u'Connecting to Spotify') spotify = LibspotifySessionManager( - config.SPOTIFY_USERNAME, config.SPOTIFY_PASSWORD, backend=self) + settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD, backend=self) spotify.start() return spotify diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index c6b85845..ac9b9dfe 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -1,4 +1,4 @@ -class ConfigError(Exception): +class SettingError(Exception): pass class MpdAckError(Exception): diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index dff365ec..e0173574 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -4,7 +4,7 @@ import socket import sys import time -from mopidy import config +from mopidy import settings from mopidy.mpd.session import MpdSession logger = logging.getLogger(u'mpd.server') @@ -16,11 +16,11 @@ class MpdServer(asyncore.dispatcher): self.backend = backend self.create_socket(socket.AF_INET, socket.SOCK_STREAM) self.set_reuse_addr() - self.bind((config.MPD_SERVER_HOSTNAME, config.MPD_SERVER_PORT)) + self.bind((settings.MPD_SERVER_HOSTNAME, settings.MPD_SERVER_PORT)) self.listen(1) self.started_at = int(time.time()) logger.info(u'Please connect to %s port %s using an MPD client.', - config.MPD_SERVER_HOSTNAME, config.MPD_SERVER_PORT) + settings.MPD_SERVER_HOSTNAME, settings.MPD_SERVER_PORT) def handle_accept(self): (client_socket, client_address) = self.accept() diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index e07bde02..c027c878 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -1,17 +1,10 @@ """ Available settings and their default values. -.. warning:: To users +.. warning:: Do *not* change settings here. Instead, add a file called ``mopidy/settings/local.py`` and redefine settings there. - -.. note:: To developers - - When you need to read a setting, import :mod:`mopidy.config` instead of - :mod:`mopidy.settings`. This way basic error handling is done for you, and - a :exc:`mopidy.exceptions.ConfigError` exception is raised if a setting is - not set or is empty when used. """ #: List of playback backends to use. Default:: From ba999c7d3ff6b3d08f3d991641ade4d7aae84218 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Feb 2010 19:11:45 +0100 Subject: [PATCH 095/341] Add output format for 3 stored playlists commands --- mopidy/mpd/handler.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 776242eb..135b2b17 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -457,6 +457,12 @@ class MpdHandler(object): ``listplaylist {NAME}`` Lists the files in the playlist ``NAME.m3u``. + + Output format:: + + file: relative/path/to/file1.flac + file: relative/path/to/file2.ogg + file: relative/path/to/file3.mp3 """ raise MpdNotImplemented # TODO @@ -468,6 +474,11 @@ class MpdHandler(object): ``listplaylistinfo {NAME}`` Lists songs in the playlist ``NAME.m3u``. + + Output format: + + Standard track listing, with fields: file, Time, Title, Date, + Album, Artist, Track """ raise MpdNotImplemented # TODO @@ -484,6 +495,13 @@ class MpdHandler(object): time as attribute ``Last-Modified`` in ISO 8601 format. To avoid problems due to clock differences between clients and the server, clients should not compare this value with their local clock. + + Output format:: + + playlist: a + Last-Modified: 2010-02-06T02:10:25Z + playlist: b + Last-Modified: 2010-02-06T02:11:08Z """ # TODO Add Last-Modified attribute to output return [u'playlist: %s' % p.name From ea7dc65ceb42091fa3fc967f3992b0f0bcc407d1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Feb 2010 19:15:04 +0100 Subject: [PATCH 096/341] Implement 'listplaylistinfo' --- mopidy/mpd/handler.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 135b2b17..07e008c6 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -480,7 +480,8 @@ class MpdHandler(object): Standard track listing, with fields: file, Time, Title, Date, Album, Artist, Track """ - raise MpdNotImplemented # TODO + return self.backend.stored_playlists.playlist.mpd_format( + search_result=True) @handle_pattern(r'^listplaylists$') def _listplaylists(self): From fda997b6a4107767ccff761df343bdbf1e0eef87 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Feb 2010 19:46:56 +0100 Subject: [PATCH 097/341] Add BaseStoredPlaylistController.get_by_name() --- mopidy/backends/__init__.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index f0d700c9..e10dd120 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -430,6 +430,10 @@ class BaseStoredPlaylistsController(object): """List of :class:`mopidy.models.Playlist`.""" return copy(self._playlists) + @playlists.setter + def playlists(self, playlists): + self._playlists = playlists + def create(self, name): """ Create a new playlist. @@ -449,6 +453,24 @@ class BaseStoredPlaylistsController(object): """ raise NotImplementedError + def get_by_name(self, name): + """ + Get playlist with given name from the set of stored playlists. + + Raises :exc:`KeyError` if not a unique match is found. + + :param name: playlist name + :type name: string + :rtype: :class:`mopidy.models.Playlist` + """ + matches = filter(lambda p: name == p.name, self._playlists) + if len(matches) == 1: + return matches[0] + elif len(matches) == 0: + raise KeyError('Name "%s" not found' % name) + else: + raise KeyError('Name "%s" matched multiple elements' % name) + def lookup(self, uri): """ Lookup playlist with given URI in both the set of stored playlists and From ca7152d62c9f1e7a7228927f5d85a267e5ed237f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Feb 2010 19:47:22 +0100 Subject: [PATCH 098/341] Implement and test 'listplaylist' and 'listplaylistinfo' --- mopidy/mpd/handler.py | 13 ++++++++++--- tests/mpd/handlertest.py | 23 ++++++++++++++++++++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 07e008c6..83ce9937 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -464,7 +464,11 @@ class MpdHandler(object): file: relative/path/to/file2.ogg file: relative/path/to/file3.mp3 """ - raise MpdNotImplemented # TODO + try: + return ['file: %s' % t.uri + for t in self.backend.stored_playlists.get_by_name(name).tracks] + except KeyError, e: + raise MpdAckError(e) @handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') def _listplaylistinfo(self, name): @@ -480,8 +484,11 @@ class MpdHandler(object): Standard track listing, with fields: file, Time, Title, Date, Album, Artist, Track """ - return self.backend.stored_playlists.playlist.mpd_format( - search_result=True) + try: + return self.backend.stored_playlists.get_by_name(name).mpd_format( + search_result=True) + except KeyError, e: + raise MpdAckError(e) @handle_pattern(r'^listplaylists$') def _listplaylists(self): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index c80b1104..fe93ff3f 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -603,15 +603,32 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): class StoredPlaylistsHandlerTest(unittest.TestCase): def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) + self.b = DummyBackend() + self.h = handler.MpdHandler(backend=self.b) def test_listplaylist(self): + self.b.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] result = self.h.handle_request(u'listplaylist "name"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'file: file:///dev/urandom' in result) + self.assert_(u'OK' in result) + + def test_listplaylist_fails_if_no_playlist_is_found(self): + result = self.h.handle_request(u'listplaylist "name"') + self.assert_(u'ACK Name "name" not found' in result) def test_listplaylistinfo(self): + self.b.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] result = self.h.handle_request(u'listplaylistinfo "name"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'file: file:///dev/urandom' in result) + self.assert_(u'Track: 0' in result) + self.assert_(u'Pos: 0' not in result) + self.assert_(u'OK' in result) + + def test_listplaylistinfo_fails_if_no_playlist_is_found(self): + result = self.h.handle_request(u'listplaylistinfo "name"') + self.assert_(u'ACK Name "name" not found' in result) def test_listplaylists(self): result = self.h.handle_request(u'listplaylists') From 635adabb7f069d7ebd4ddc8c0cf401b36949d9cc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Feb 2010 19:48:27 +0100 Subject: [PATCH 099/341] Add test for 'noidle' --- tests/mpd/handlertest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index fe93ff3f..f608d26a 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -127,6 +127,10 @@ class StatusHandlerTest(unittest.TestCase): result = self.h.handle_request(u'idle database playlist') self.assert_(u'ACK Not implemented' in result) + def test_noidle(self): + result = self.h.handle_request(u'noidle') + self.assert_(u'ACK Not implemented' in result) + def test_stats_command(self): result = self.h.handle_request(u'stats') self.assert_(u'OK' in result) From 25a3da22db12e92c3624acf2f8428fe6fdcfd7d8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Feb 2010 20:04:15 +0100 Subject: [PATCH 100/341] Change return type of BaseLibraryController.find_exact() to match search() --- mopidy/backends/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index e10dd120..1a0fcc44 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -172,7 +172,7 @@ class BaseLibraryController(object): :type type: string :param query: the search query :type query: string - :rtype: list of :class:`mopidy.models.Track` + :rtype: :class:`mopidy.models.Playlist` """ raise NotImplementedError From d1ff2e9aea30b57712d5e7005842961519098e59 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Feb 2010 20:05:07 +0100 Subject: [PATCH 101/341] Use search() as find_exact() implementation in dummy, despotify, and libspotify backend --- mopidy/backends/despotify.py | 2 ++ mopidy/backends/dummy.py | 2 ++ mopidy/backends/libspotify.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 178699b7..ef8f85a6 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -43,6 +43,8 @@ class DespotifyLibraryController(BaseLibraryController): return Playlist() return self.backend.translate.to_mopidy_playlist(result.playlist) + find_exact = search + class DespotifyPlaybackController(BasePlaybackController): def _pause(self): diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 76e59fc5..4f7c1c9c 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -18,6 +18,8 @@ class DummyLibraryController(BaseLibraryController): def search(self, type, query): return Playlist() + find_exact = search + class DummyPlaybackController(BasePlaybackController): def _next(self): return True diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 4b99c49e..0d0350cf 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -60,6 +60,8 @@ class LibspotifyLibraryController(BaseLibraryController): self._search_results = None return result + find_exact = search + class LibspotifyPlaybackController(BasePlaybackController): def _pause(self): From e61ce617412eff832fa3c3adf3f8648e1bba653f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 24 Feb 2010 08:40:28 +0100 Subject: [PATCH 102/341] Implement MpdHandler.find_exact() --- mopidy/mpd/handler.py | 7 ++++++- tests/mpd/handlertest.py | 11 ++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 83ce9937..640b6453 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -345,7 +345,10 @@ class MpdHandler(object): Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be ``album``, ``artist``, or ``title``. ``WHAT`` is what to find. """ - raise MpdNotImplemented # TODO + if type == u'title': + type = u'track' + return self.backend.library.find_exact(type, what).mpd_format( + search_result=True) @handle_pattern(r'^findadd "(?P(album|artist|title))" "(?P[^"]+)"$') def _findadd(self, type, what): @@ -971,6 +974,8 @@ class MpdHandler(object): ``title``, ``artist``, ``album`` or ``filename``. Search is not case sensitive. """ + if type == u'title': + type = u'track' return self.backend.library.search(type, what).mpd_format( search_result=True) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index f608d26a..4f20d7a4 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -674,7 +674,8 @@ class StoredPlaylistsHandlerTest(unittest.TestCase): class MusicDatabaseHandlerTest(unittest.TestCase): def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) + self.b = DummyBackend() + self.h = handler.MpdHandler(backend=self.b) def test_count(self): result = self.h.handle_request(u'count "tag" "needle"') @@ -682,15 +683,15 @@ class MusicDatabaseHandlerTest(unittest.TestCase): def test_find_album(self): result = self.h.handle_request(u'find "album" "what"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) def test_find_artist(self): result = self.h.handle_request(u'find "artist" "what"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) def test_find_title(self): result = self.h.handle_request(u'find "title" "what"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) def test_find_else_should_fail(self): try: @@ -701,7 +702,7 @@ class MusicDatabaseHandlerTest(unittest.TestCase): def test_findadd(self): result = self.h.handle_request(u'findadd "album" "what"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) def test_list_artist(self): result = self.h.handle_request(u'list "artist"') From db7e5b645cd4aa654d448d6042ed83f8a3c19a27 Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Thu, 25 Feb 2010 11:13:02 +0100 Subject: [PATCH 103/341] Changed from title to track in base library controller --- mopidy/backends/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 1a0fcc44..3592bd30 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -168,7 +168,7 @@ class BaseLibraryController(object): """ Find tracks in the library where ``type`` matches ``query`` exactly. - :param type: 'title', 'artist', or 'album' + :param type: 'track', 'artist', or 'album' :type type: string :param query: the search query :type query: string @@ -199,7 +199,7 @@ class BaseLibraryController(object): """ Search the library for tracks where ``type`` contains ``query``. - :param type: 'title', 'artist', 'album', or 'uri' + :param type: 'track', 'artist', 'album', or 'uri' :type type: string :param query: the search query :type query: string From 259d5e64433bea87a757947d426d9138b8376cba Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Thu, 25 Feb 2010 11:14:25 +0100 Subject: [PATCH 104/341] Pass instead of raise not implemented on commands, notcommands and tagtypes mpd commands to have gmpc working --- mopidy/mpd/handler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 640b6453..bd8d8fe9 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -204,7 +204,7 @@ class MpdHandler(object): Shows which commands the current user has access to. """ - raise MpdNotImplemented # TODO + pass # TODO @handle_pattern(r'^consume "(?P[01])"$') def _consume(self, state): @@ -601,7 +601,7 @@ class MpdHandler(object): Shows which commands the current user does not have access to. """ - raise MpdNotImplemented # TODO + pass # TODO @handle_pattern(r'^outputs$') def _outputs(self): @@ -1300,7 +1300,7 @@ class MpdHandler(object): Shows a list of available song metadata. """ - raise MpdNotImplemented # TODO + pass # TODO @handle_pattern(r'^update( "(?P[^"]+)")*$') def _update(self, uri=None, rescan_unmodified_files=False): From 1b9231d38403f994e5704f4202a7a00b990fd1c5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 13:43:40 +0100 Subject: [PATCH 105/341] Update tests to match changes from knutz3n --- tests/mpd/handlertest.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 4f20d7a4..f85a8c9c 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -876,7 +876,7 @@ class ReflectionHandlerTest(unittest.TestCase): def test_commands(self): result = self.h.handle_request(u'commands') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) def test_decoders(self): result = self.h.handle_request(u'decoders') @@ -884,11 +884,11 @@ class ReflectionHandlerTest(unittest.TestCase): def test_notcommands(self): result = self.h.handle_request(u'notcommands') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) def test_tagtypes(self): result = self.h.handle_request(u'tagtypes') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) def test_urlhandlers(self): result = self.h.handle_request(u'urlhandlers') From 3727ea214b3c6f7d6981629f784320ddd6eabac7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 14:04:50 +0100 Subject: [PATCH 106/341] Activate Sphinx' graphviz extension --- docs/conf.py | 3 ++- docs/development.rst | 4 ++-- requirements-docs.txt | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 5f6b7ef2..1f4cc3a2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,8 @@ import mopidy # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'autodoc_private_members'] +extensions = ['sphinx.ext.autodoc', 'autodoc_private_members', + 'sphinx.ext.graphviz'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/development.rst b/docs/development.rst index 69d6420c..5fb3f494 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -86,11 +86,11 @@ from the documentation files, you need some additional dependencies. You can either install them through Debian/Ubuntu package management:: - sudo aptitude install python-sphinx + sudo aptitude install python-sphinx python-pygraphviz graphviz Or, install them using pip:: - sudo aptitude install python-pip python-setuptools + sudo aptitude install python-pip python-setuptools graphviz sudo pip install -r requirements-docs.txt Then, to generate docs:: diff --git a/requirements-docs.txt b/requirements-docs.txt index 2806c164..0d593422 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1 +1,2 @@ Sphinx +pygraphviz From 747a981dd7a2c76517e888930d6b3501d9ed80e1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 14:19:18 +0100 Subject: [PATCH 107/341] Add state transition diagram --- mopidy/backends/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 3592bd30..97042468 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -287,6 +287,17 @@ class BasePlaybackController(object): """ The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or :attr:`STOPPED`. + + Possible states and transitions: + + .. digraph:: state_transitions + + "STOPPED" -> "PLAYING" [ label="play" ] + "PLAYING" -> "STOPPED" [ label="stop" ] + "PLAYING" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "PLAYING" [ label="play" ] + "PAUSED" -> "PLAYING" [ label="resume" ] + "PAUSED" -> "STOPPED" [ label="stop" ] """ return self._state From 072653e301200a427a44ee6e0b4d53d0cf8e1bc1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 14:38:02 +0100 Subject: [PATCH 108/341] Add class instantiation and usage graph --- docs/development.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/development.rst b/docs/development.rst index 5fb3f494..7a0dc574 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -138,3 +138,22 @@ A list of the issues we currently experience with pyspotify, both bugs and features we wished was there: * None at the moment. + + +Class instantiation and usage +============================= + +The following diagram shows how Mopidy with the despotify backend is wired +together. The gray nodes are part of external dependencies, and not Mopidy. + +.. digraph:: class_instantiation_and_usage + + "spytify" [ color="gray" ] + "despotify" [ color="gray" ] + "__main__" -> "MpdServer" [ label="create 1" ] + "__main__" -> "DespotifyBackend" [ label="create 1" ] + "MpdServer" -> "MpdSession" [ label="create 1 per client" ] + "MpdSession" -> "MpdHandler" [ label="pass MPD requests to" ] + "MpdHandler" -> "DespotifyBackend" [ label="use backend API" ] + "DespotifyBackend" -> "spytify" [ label="use Python wrapper" ] + "spytify" -> "despotify" [ label="use C library" ] From fd4aac375e72076bcaa56f81c19fe30ee01b4452 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 14:57:16 +0100 Subject: [PATCH 109/341] Fix capitalization --- mopidy/settings/default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index c027c878..c136bc2e 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -29,7 +29,7 @@ CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n #: ``localhost`` #: Listens only on the loopback interface. *Default.* #: ``0.0.0.0`` -#: listens on all interfaces. +#: Listens on all interfaces. MPD_SERVER_HOSTNAME = u'localhost' #: Which TCP port Mopidy should listen to. *Default: 6600* From 39ecf2b5763562fd934210867baa4b9ef8e66bce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 15:03:36 +0100 Subject: [PATCH 110/341] Make API docs a top-level section --- docs/api/index.rst | 8 ++++++++ docs/development.rst | 9 --------- docs/index.rst | 1 + 3 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 docs/api/index.rst diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 00000000..86f4e06e --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,8 @@ +***************** +API documentation +***************** + +.. toctree:: + :glob: + + ** diff --git a/docs/development.rst b/docs/development.rst index 7a0dc574..60004533 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -15,15 +15,6 @@ modular, so we can extend it with other backends in the future, like file playback and other online music services such as Last.fm. -API documentation -================= - -.. toctree:: - :glob: - - api/** - - Code style ========== diff --git a/docs/index.rst b/docs/index.rst index ed92375c..72da0107 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,6 +10,7 @@ Contents installation settings development + api/index Indices and tables ================== From d62ea932317b99dd7859ec19828e4d2e1b8be987 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 15:23:25 +0100 Subject: [PATCH 111/341] Rename internal MpdHandler methods to include MPD protocol section --- mopidy/mpd/handler.py | 220 ++++++++++++++++++++------------------- tests/mpd/handlertest.py | 44 ++++---- 2 files changed, 134 insertions(+), 130 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index bd8d8fe9..9ea4dff1 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -91,7 +91,7 @@ class MpdHandler(object): raise MpdNotImplemented @handle_pattern(r'^add "(?P[^"]*)"$') - def _add(self, uri): + def _current_playlist_add(self, uri): """ *musicpd.org, current playlist section:* @@ -103,7 +103,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^addid "(?P[^"]*)"( (?P\d+))*$') - def _addid(self, uri, songpos=None): + def _current_playlist_addid(self, uri, songpos=None): """ *musicpd.org, current playlist section:* @@ -131,7 +131,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^clearerror$') - def _clearerror(self): + def _status_clearerror(self): """ *musicpd.org, status section:* @@ -143,7 +143,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^close$') - def _close(self): + def _connection_close(self): """ *musicpd.org, connection section:* @@ -196,7 +196,7 @@ class MpdHandler(object): return result @handle_pattern(r'^commands$') - def _commands(self): + def _reflection_commands(self): """ *musicpd.org, reflection section:* @@ -207,7 +207,7 @@ class MpdHandler(object): pass # TODO @handle_pattern(r'^consume "(?P[01])"$') - def _consume(self, state): + def _playback_consume(self, state): """ *musicpd.org, playback section:* @@ -224,7 +224,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^count "(?P[^"]+)" "(?P[^"]+)"$') - def _count(self, tag, needle): + def _music_db_count(self, tag, needle): """ *musicpd.org, music database section:* @@ -236,7 +236,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^crossfade "(?P\d+)"$') - def _crossfade(self, seconds): + def _playback_crossfade(self, seconds): """ *musicpd.org, playback section:* @@ -248,7 +248,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^currentsong$') - def _currentsong(self): + def _status_currentsong(self): """ *musicpd.org, status section:* @@ -262,7 +262,7 @@ class MpdHandler(object): position=self.backend.playback.playlist_position) @handle_pattern(r'^decoders$') - def _decoders(self): + def _reflection_decoders(self): """ *musicpd.org, reflection section:* @@ -282,7 +282,7 @@ class MpdHandler(object): @handle_pattern(r'^delete "(?P\d+)"$') @handle_pattern(r'^delete "(?P\d+):(?P\d+)*"$') - def _delete(self, songpos=None, start=None, end=None): + def _current_playlist_delete(self, songpos=None, start=None, end=None): """ *musicpd.org, current playlist section:* @@ -293,7 +293,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^deleteid "(?P\d+)"$') - def _deleteid(self, songid): + def _current_playlist_deleteid(self, songid): """ *musicpd.org, current playlist section:* @@ -309,7 +309,7 @@ class MpdHandler(object): raise MpdAckError(unicode(e)) @handle_pattern(r'^disableoutput "(?P\d+)"$') - def _disableoutput(self, outputid): + def _audio_output_disableoutput(self, outputid): """ *musicpd.org, audio output section:* @@ -325,7 +325,7 @@ class MpdHandler(object): pass @handle_pattern(r'^enableoutput "(?P\d+)"$') - def _enableoutput(self, outputid): + def _audio_output_enableoutput(self, outputid): """ *musicpd.org, audio output section:* @@ -336,7 +336,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^find "(?P(album|artist|title))" "(?P[^"]+)"$') - def _find(self, type, what): + def _music_db_find(self, type, what): """ *musicpd.org, music database section:* @@ -351,7 +351,7 @@ class MpdHandler(object): search_result=True) @handle_pattern(r'^findadd "(?P(album|artist|title))" "(?P[^"]+)"$') - def _findadd(self, type, what): + def _music_db_findadd(self, type, what): """ *musicpd.org, music database section:* @@ -361,13 +361,13 @@ class MpdHandler(object): current playlist. ``TYPE`` can be any tag supported by MPD. ``WHAT`` is what to find. """ - result = self._find(type, what) + result = self._music_db_find(type, what) # TODO Add result to current playlist #return result @handle_pattern(r'^idle$') @handle_pattern(r'^idle (?P.+)$') - def _idle(self, subsystems=None): + def _status_idle(self, subsystems=None): """ *musicpd.org, status section:* @@ -403,7 +403,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^kill$') - def _kill(self): + def _connection_kill(self): """ *musicpd.org, connection section:* @@ -415,7 +415,7 @@ class MpdHandler(object): @handle_pattern(r'^list "(?Partist)"$') @handle_pattern(r'^list "(?Palbum)"( "(?P[^"]+)")*$') - def _list(self, type, artist=None): + def _music_db_list(self, type, artist=None): """ *musicpd.org, music database section:* @@ -430,7 +430,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^listall "(?P[^"]+)"') - def _listall(self, uri): + def _music_db_listall(self, uri): """ *musicpd.org, music database section:* @@ -441,7 +441,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^listallinfo "(?P[^"]+)"') - def _listallinfo(self, uri): + def _music_db_listallinfo(self, uri): """ *musicpd.org, music database section:* @@ -453,7 +453,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^listplaylist "(?P[^"]+)"$') - def _listplaylist(self, name): + def _stored_playlists_listplaylist(self, name): """ *musicpd.org, stored playlists section:* @@ -474,7 +474,7 @@ class MpdHandler(object): raise MpdAckError(e) @handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') - def _listplaylistinfo(self, name): + def _stored_playlists_listplaylistinfo(self, name): """ *musicpd.org, stored playlists section:* @@ -494,7 +494,7 @@ class MpdHandler(object): raise MpdAckError(e) @handle_pattern(r'^listplaylists$') - def _listplaylists(self): + def _stored_playlists_listplaylists(self): """ *musicpd.org, stored playlists section:* @@ -519,7 +519,7 @@ class MpdHandler(object): for p in self.backend.stored_playlists.playlists] @handle_pattern(r'^load "(?P[^"]+)"$') - def _load(self, name): + def _stored_playlists_load(self, name): """ *musicpd.org, stored playlists section:* @@ -534,7 +534,7 @@ class MpdHandler(object): @handle_pattern(r'^lsinfo$') @handle_pattern(r'^lsinfo "(?P[^"]*)"$') - def _lsinfo(self, uri=None): + def _music_db_lsinfo(self, uri=None): """ *musicpd.org, music database section:* @@ -547,12 +547,13 @@ class MpdHandler(object): ``listplaylists`` instead. """ if uri == u'/' or uri is None: - return self._listplaylists() + return self._stored_playlists_listplaylists() raise MpdNotImplemented # TODO @handle_pattern(r'^move "(?P\d+)" "(?P\d+)"$') @handle_pattern(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') - def _move(self, songpos=None, start=None, end=None, to=None): + def _current_playlist_move(self, songpos=None, + start=None, end=None, to=None): """ *musicpd.org, current playlist section:* @@ -564,7 +565,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^moveid "(?P\d+)" "(?P\d+)"$') - def _moveid(self, songid, to): + def _current_playlist_moveid(self, songid, to): """ *musicpd.org, current playlist section:* @@ -577,7 +578,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^next$') - def _next(self): + def _playback_next(self): """ *musicpd.org, playback section:* @@ -588,12 +589,12 @@ class MpdHandler(object): return self.backend.playback.next() @handle_pattern(r'^noidle$') - def _noidle(self): + def _status_noidle(self): """See :meth:`_idle`.""" raise MpdNotImplemented # TODO @handle_pattern(r'^notcommands$') - def _notcommands(self): + def _reflection_notcommands(self): """ *musicpd.org, reflection section:* @@ -604,7 +605,7 @@ class MpdHandler(object): pass # TODO @handle_pattern(r'^outputs$') - def _outputs(self): + def _audio_ouput_outputs(self): """ *musicpd.org, audio output section:* @@ -619,7 +620,7 @@ class MpdHandler(object): ] @handle_pattern(r'^password "(?P[^"]+)"$') - def _password(self, password): + def _connection_password(self, password): """ *musicpd.org, connection section:* @@ -631,7 +632,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^pause "(?P[01])"$') - def _pause(self, state): + def _playback_pause(self, state): """ *musicpd.org, playback section:* @@ -645,7 +646,7 @@ class MpdHandler(object): self.backend.playback.resume() @handle_pattern(r'^ping$') - def _ping(self): + def _connection_ping(self): """ *musicpd.org, connection section:* @@ -656,7 +657,7 @@ class MpdHandler(object): pass @handle_pattern(r'^play$') - def _play(self): + def _playback_play(self): """ The original MPD server resumes from the paused state on ``play`` without arguments. @@ -664,7 +665,7 @@ class MpdHandler(object): return self.backend.playback.play() @handle_pattern(r'^play "(?P\d+)"$') - def _playpos(self, songpos): + def _playback_playpos(self, songpos): """ *musicpd.org, playback section:* @@ -680,7 +681,7 @@ class MpdHandler(object): raise MpdAckError(u'Position out of bounds') @handle_pattern(r'^playid "(?P\d+)"$') - def _playid(self, songid): + def _playback_playid(self, songid): """ *musicpd.org, playback section:* @@ -696,7 +697,7 @@ class MpdHandler(object): raise MpdAckError(unicode(e)) @handle_pattern(r'^playlist$') - def _playlist(self): + def _current_playlist_playlist(self): """ *musicpd.org, current playlist section:* @@ -708,10 +709,10 @@ class MpdHandler(object): Do not use this, instead use ``playlistinfo``. """ - return self._playlistinfo() + return self._current_playlist_playlistinfo() @handle_pattern(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') - def _playlistadd(self, name, uri): + def _stored_playlist_playlistadd(self, name, uri): """ *musicpd.org, stored playlists section:* @@ -724,7 +725,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^playlistclear "(?P[^"]+)"$') - def _playlistclear(self, name): + def _stored_playlist_playlistclear(self, name): """ *musicpd.org, stored playlists section:* @@ -735,7 +736,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') - def _playlistdelete(self, name, songpos): + def _stored_playlist_playlistdelete(self, name, songpos): """ *musicpd.org, stored playlists section:* @@ -746,7 +747,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') - def _playlistfind(self, tag, needle): + def _current_playlist_playlistfind(self, tag, needle): """ *musicpd.org, current playlist section:* @@ -757,7 +758,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^playlistid( "(?P\S+)")*$') - def _playlistid(self, songid=None): + def _current_playlist_playlistid(self, songid=None): """ *musicpd.org, current playlist section:* @@ -772,7 +773,8 @@ class MpdHandler(object): @handle_pattern(r'^playlistinfo$') @handle_pattern(r'^playlistinfo "(?P\d+)"$') @handle_pattern(r'^playlistinfo "(?P\d+):(?P\d+)*"$') - def _playlistinfo(self, songpos=None, start=None, end=None): + def _current_playlist_playlistinfo(self, songpos=None, + start=None, end=None): """ *musicpd.org, current playlist section:* @@ -794,8 +796,9 @@ class MpdHandler(object): end = int(end) return self.backend.current_playlist.playlist.mpd_format(start, end) - @handle_pattern(r'^playlistmove "(?P[^"]+)" "(?P\d+)" "(?P\d+)"$') - def _playlistmove(self, name, songid, songpos): + @handle_pattern(r'^playlistmove "(?P[^"]+)" ' + r'"(?P\d+)" "(?P\d+)"$') + def _stored_playlist_playlistmove(self, name, songid, songpos): """ *musicpd.org, stored playlists section:* @@ -807,7 +810,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') - def _playlistsearch(self, tag, needle): + def _current_playlist_playlistsearch(self, tag, needle): """ *musicpd.org, current playlist section:* @@ -819,7 +822,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^plchanges "(?P\d+)"$') - def _plchanges(self, version): + def _current_playlist_plchanges(self, version): """ *musicpd.org, current playlist section:* @@ -834,7 +837,7 @@ class MpdHandler(object): return self.backend.current_playlist.playlist.mpd_format() @handle_pattern(r'^plchangesposid "(?P\d+)"$') - def _plchangesposid(self, version): + def _current_playlist_plchangesposid(self, version): """ *musicpd.org, current playlist section:* @@ -850,7 +853,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^previous$') - def _previous(self): + def _playback_previous(self): """ *musicpd.org, playback section:* @@ -861,7 +864,7 @@ class MpdHandler(object): return self.backend.playback.previous() @handle_pattern(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') - def _rename(self, old_name, new_name): + def _stored_playlists_rename(self, old_name, new_name): """ *musicpd.org, stored playlists section:* @@ -872,7 +875,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^random "(?P[01])"$') - def _random(self, state): + def _playback_random(self, state): """ *musicpd.org, playback section:* @@ -887,7 +890,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^repeat "(?P[01])"$') - def _repeat(self, state): + def _playback_repeat(self, state): """ *musicpd.org, playback section:* @@ -902,7 +905,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^replay_gain_mode "(?P(off|track|album))"$') - def _replay_gain_mode(self, mode): + def _playback_replay_gain_mode(self, mode): """ *musicpd.org, playback section:* @@ -918,7 +921,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^replay_gain_status$') - def _replay_gain_status(self): + def _playback_replay_gain_status(self): """ *musicpd.org, playback section:* @@ -930,7 +933,7 @@ class MpdHandler(object): return u'off' # TODO @handle_pattern(r'^rescan( "(?P[^"]+)")*$') - def _rescan(self, uri=None): + def _music_db_rescan(self, uri=None): """ *musicpd.org, music database section:* @@ -938,10 +941,10 @@ class MpdHandler(object): Same as ``update``, but also rescans unmodified files. """ - return self._update(uri, rescan_unmodified_files=True) + return self._music_db_update(uri, rescan_unmodified_files=True) @handle_pattern(r'^rm "(?P[^"]+)"$') - def _rm(self, name): + def _stored_playlists_rm(self, name): """ *musicpd.org, stored playlists section:* @@ -952,7 +955,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^save "(?P[^"]+)"$') - def _save(self, name): + def _stored_playlists_save(self, name): """ *musicpd.org, stored playlists section:* @@ -964,7 +967,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^search "(?P(album|artist|filename|title))" "(?P[^"]+)"$') - def _search(self, type, what): + def _music_db_search(self, type, what): """ *musicpd.org, music database section:* @@ -980,7 +983,7 @@ class MpdHandler(object): search_result=True) @handle_pattern(r'^seek "(?P\d+)" "(?P\d+)"$') - def _seek(self, songpos, seconds): + def _playback_seek(self, songpos, seconds): """ *musicpd.org, playback section:* @@ -992,7 +995,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^seekid "(?P\d+)" "(?P\d+)"$') - def _seekid(self, songid, seconds): + def _playback_seekid(self, songid, seconds): """ *musicpd.org, playback section:* @@ -1003,7 +1006,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^setvol "(?P[-+]*\d+)"$') - def _setvol(self, volume): + def _playback_setvol(self, volume): """ *musicpd.org, playback section:* @@ -1020,7 +1023,7 @@ class MpdHandler(object): @handle_pattern(r'^shuffle$') @handle_pattern(r'^shuffle "(?P\d+):(?P\d+)*"$') - def _shuffle(self, start=None, end=None): + def _current_playlist_shuffle(self, start=None, end=None): """ *musicpd.org, current playlist section:* @@ -1032,7 +1035,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^single "(?P[01])"$') - def _single(self, state): + def _playback_single(self, state): """ *musicpd.org, playback section:* @@ -1049,7 +1052,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^stats$') - def _stats(self): + def _status_stats(self): """ *musicpd.org, status section:* @@ -1075,7 +1078,7 @@ class MpdHandler(object): } @handle_pattern(r'^stop$') - def _stop(self): + def _playback_stop(self): """ *musicpd.org, playback section:* @@ -1086,7 +1089,7 @@ class MpdHandler(object): self.backend.playback.stop() @handle_pattern(r'^status$') - def _status(self): + def _status_status(self): """ *musicpd.org, status section:* @@ -1118,67 +1121,67 @@ class MpdHandler(object): - ``error``: if there is an error, returns message here """ result = [ - ('volume', self._status_volume()), - ('repeat', self._status_repeat()), - ('random', self._status_random()), - ('single', self._status_single()), - ('consume', self._status_consume()), - ('playlist', self._status_playlist_version()), - ('playlistlength', self._status_playlist_length()), - ('xfade', self._status_xfade()), - ('state', self._status_state()), + ('volume', self.__status_status_volume()), + ('repeat', self.__status_status_repeat()), + ('random', self.__status_status_random()), + ('single', self.__status_status_single()), + ('consume', self.__status_status_consume()), + ('playlist', self.__status_status_playlist_version()), + ('playlistlength', self.__status_status_playlist_length()), + ('xfade', self.__status_status_xfade()), + ('state', self.__status_status_state()), ] if self.backend.playback.current_track is not None: - result.append(('song', self._status_songpos())) - result.append(('songid', self._status_songid())) + result.append(('song', self.__status_status_songpos())) + result.append(('songid', self.__status_status_songid())) if self.backend.playback.state in ( self.backend.playback.PLAYING, self.backend.playback.PAUSED): - result.append(('time', self._status_time())) + result.append(('time', self.__status_status_time())) # TODO Add 'elapsed' here when moving to MPD 0.16.0 - result.append(('bitrate', self._status_bitrate())) + result.append(('bitrate', self.__status_status_bitrate())) return result - def _status_bitrate(self): + def __status_status_bitrate(self): if self.backend.playback.current_track is not None: return self.backend.playback.current_track.bitrate - def _status_consume(self): + def __status_status_consume(self): if self.backend.playback.consume: return 1 else: return 0 - def _status_playlist_length(self): + def __status_status_playlist_length(self): return self.backend.current_playlist.playlist.length - def _status_playlist_version(self): + def __status_status_playlist_version(self): return self.backend.current_playlist.version - def _status_random(self): + def __status_status_random(self): if self.backend.playback.random: return 1 else: return 0 - def _status_repeat(self): + def __status_status_repeat(self): if self.backend.playback.repeat: return 1 else: return 0 - def _status_single(self): + def __status_status_single(self): return 0 # TODO - def _status_songid(self): + def __status_status_songid(self): if self.backend.playback.current_track.id is not None: return self.backend.playback.current_track.id else: - return self._status_songpos() + return self.__status_status_songpos() - def _status_songpos(self): + def __status_status_songpos(self): return self.backend.playback.playlist_position - def _status_state(self): + def __status_status_state(self): if self.backend.playback.state == self.backend.playback.PLAYING: return u'play' elif self.backend.playback.state == self.backend.playback.STOPPED: @@ -1186,14 +1189,14 @@ class MpdHandler(object): elif self.backend.playback.state == self.backend.playback.PAUSED: return u'pause' - def _status_time(self): - return u'%s:%s' % ( - self._status_time_elapsed(), self._status_time_total()) + def __status_status_time(self): + return u'%s:%s' % (self.__status_status_time_elapsed(), + self.__status_status_time_total()) - def _status_time_elapsed(self): + def __status_status_time_elapsed(self): return self.backend.playback.time_position - def _status_time_total(self): + def __status_status_time_total(self): if self.backend.playback.current_track is None: return 0 elif self.backend.playback.current_track.length is None: @@ -1201,16 +1204,17 @@ class MpdHandler(object): else: return self.backend.playback.current_track.length // 1000 - def _status_volume(self): + def __status_status_volume(self): if self.backend.playback.volume is not None: return self.backend.playback.volume else: return 0 - def _status_xfade(self): + def __status_status_xfade(self): return 0 # TODO - @handle_pattern(r'^sticker delete "(?P[^"]+)" "(?P[^"]+)"( "(?P[^"]+)")*$') + @handle_pattern(r'^sticker delete "(?P[^"]+)" ' + r'"(?P[^"]+)"( "(?P[^"]+)")*$') def _sticker_delete(self, type, uri, name=None): """ *musicpd.org, sticker section:* @@ -1281,7 +1285,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^swapid "(?P\d+)" "(?P\d+)"$') - def _swapid(self, songid1, songid2): + def _current_playlist_swapid(self, songid1, songid2): """ *musicpd.org, current playlist section:* @@ -1292,7 +1296,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^tagtypes$') - def _tagtypes(self): + def _reflection_tagtypes(self): """ *musicpd.org, reflection section:* @@ -1303,7 +1307,7 @@ class MpdHandler(object): pass # TODO @handle_pattern(r'^update( "(?P[^"]+)")*$') - def _update(self, uri=None, rescan_unmodified_files=False): + def _music_db_update(self, uri=None, rescan_unmodified_files=False): """ *musicpd.org, music database section:* @@ -1322,7 +1326,7 @@ class MpdHandler(object): return {'updating_db': 0} # TODO @handle_pattern(r'^urlhandlers$') - def _urlhandlers(self): + def _reflection_urlhandlers(self): """ *musicpd.org, reflection section:* diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index f85a8c9c..a6891c93 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -136,7 +136,7 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_stats_method(self): - result = self.h._stats() + result = self.h._status_stats() self.assert_('artists' in result) self.assert_(int(result['artists']) >= 0) self.assert_('albums' in result) @@ -158,85 +158,85 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_contains_volume_which_defaults_to_0(self): self.b.playback.volume = None - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('volume' in result) self.assertEquals(int(result['volume']), 0) def test_status_method_contains_volume(self): self.b.playback.volume = 17 - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('volume' in result) self.assertEquals(int(result['volume']), 17) def test_status_method_contains_repeat_is_0(self): - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('repeat' in result) self.assertEquals(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): self.b.playback.repeat = 1 - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('repeat' in result) self.assertEquals(int(result['repeat']), 1) def test_status_method_contains_random_is_0(self): - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('random' in result) self.assertEquals(int(result['random']), 0) def test_status_method_contains_random_is_1(self): self.b.playback.random = 1 - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('random' in result) self.assertEquals(int(result['random']), 1) def test_status_method_contains_single(self): - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('single' in result) self.assert_(int(result['single']) in (0, 1)) def test_status_method_contains_consume_is_0(self): - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('consume' in result) self.assertEquals(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): self.b.playback.consume = 1 - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('consume' in result) self.assertEquals(int(result['consume']), 1) def test_status_method_contains_playlist(self): - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('playlist' in result) self.assert_(int(result['playlist']) in xrange(0, 2**31)) def test_status_method_contains_playlistlength(self): - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('playlistlength' in result) self.assert_(int(result['playlistlength']) >= 0) def test_status_method_contains_xfade(self): - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('xfade' in result) self.assert_(int(result['xfade']) >= 0) def test_status_method_contains_state_is_play(self): self.b.playback.state = self.b.playback.PLAYING - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('state' in result) self.assertEquals(result['state'], 'play') def test_status_method_contains_state_is_stop(self): self.b.playback.state = self.b.playback.STOPPED - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('state' in result) self.assertEquals(result['state'], 'stop') def test_status_method_contains_state_is_pause(self): self.b.playback.state = self.b.playback.PLAYING self.b.playback.state = self.b.playback.PAUSED - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('state' in result) self.assertEquals(result['state'], 'pause') @@ -244,7 +244,7 @@ class StatusHandlerTest(unittest.TestCase): track = Track() self.b.current_playlist.load(Playlist(tracks=[track])) self.b.playback.current_track = track - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('song' in result) self.assert_(int(result['song']) >= 0) @@ -252,7 +252,7 @@ class StatusHandlerTest(unittest.TestCase): track = Track() self.b.current_playlist.load(Playlist(tracks=[track])) self.b.playback.current_track = track - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('songid' in result) self.assert_(int(result['songid']) >= 0) @@ -260,14 +260,14 @@ class StatusHandlerTest(unittest.TestCase): track = Track(id=1) self.b.current_playlist.load(Playlist(tracks=[track])) self.b.playback.current_track = track - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('songid' in result) self.assertEquals(int(result['songid']), 1) def test_status_method_when_playing_contains_time_with_no_length(self): self.b.playback.current_track = Track(length=None) self.b.playback.state = self.b.playback.PLAYING - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('time' in result) (position, total) = result['time'].split(':') position = int(position) @@ -277,7 +277,7 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_when_playing_contains_time_with_length(self): self.b.playback.current_track = Track(length=10000) self.b.playback.state = self.b.playback.PLAYING - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('time' in result) (position, total) = result['time'].split(':') position = int(position) @@ -287,7 +287,7 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_when_playing_contains_bitrate(self): self.b.playback.state = self.b.playback.PLAYING self.b.playback.current_track = Track(bitrate=320) - result = dict(self.h._status()) + result = dict(self.h._status_status()) self.assert_('bitrate' in result) self.assertEquals(int(result['bitrate']), 320) From a1eccc49734a1cc357318d5a62d286217cb21a18 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 15:41:08 +0100 Subject: [PATCH 112/341] Break too long lines --- mopidy/mpd/handler.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 9ea4dff1..2f688449 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -335,7 +335,8 @@ class MpdHandler(object): """ raise MpdNotImplemented # TODO - @handle_pattern(r'^find "(?P(album|artist|title))" "(?P[^"]+)"$') + @handle_pattern(r'^find "(?P(album|artist|title))" ' + r'"(?P[^"]+)"$') def _music_db_find(self, type, what): """ *musicpd.org, music database section:* @@ -350,7 +351,8 @@ class MpdHandler(object): return self.backend.library.find_exact(type, what).mpd_format( search_result=True) - @handle_pattern(r'^findadd "(?P(album|artist|title))" "(?P[^"]+)"$') + @handle_pattern(r'^findadd "(?P(album|artist|title))" ' + r'"(?P[^"]+)"$') def _music_db_findadd(self, type, what): """ *musicpd.org, music database section:* @@ -966,7 +968,8 @@ class MpdHandler(object): """ raise MpdNotImplemented # TODO - @handle_pattern(r'^search "(?P(album|artist|filename|title))" "(?P[^"]+)"$') + @handle_pattern(r'^search "(?P(album|artist|filename|title))" ' + r'"(?P[^"]+)"$') def _music_db_search(self, type, what): """ *musicpd.org, music database section:* @@ -1226,7 +1229,8 @@ class MpdHandler(object): """ raise MpdNotImplemented # TODO - @handle_pattern(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' + r'"(?P[^"]+)"$') def _sticker_find(self, type, uri, name): """ *musicpd.org, sticker section:* @@ -1239,7 +1243,8 @@ class MpdHandler(object): """ raise MpdNotImplemented # TODO - @handle_pattern(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' + r'"(?P[^"]+)"$') def _sticker_get(self, type, uri, name): """ *musicpd.org, sticker section:* @@ -1261,7 +1266,8 @@ class MpdHandler(object): """ raise MpdNotImplemented # TODO - @handle_pattern(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' + r'"(?P[^"]+)" "(?P[^"]+)"$') def _sticker_set(self, type, uri, name, value): """ *musicpd.org, sticker section:* From e670644bf9830922a5cb11d97a9e604133df7a8a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 15:42:38 +0100 Subject: [PATCH 113/341] Rename forgotten method --- mopidy/mpd/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 2f688449..23df6438 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -120,7 +120,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^clear$') - def _clear(self): + def _current_playlist_clear(self): """ *musicpd.org, current playlist section:* From 1e8f478efdcf9a4504ad727337dc113c2688e7d4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 16:02:54 +0100 Subject: [PATCH 114/341] Sort MpdHandler methods alphabetically, so they are grouped by functionality area --- mopidy/mpd/handler.py | 1378 ++++++++++++++++++++--------------------- 1 file changed, 688 insertions(+), 690 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 23df6438..220c35a7 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -90,6 +90,130 @@ class MpdHandler(object): """ raise MpdNotImplemented + @handle_pattern(r'^disableoutput "(?P\d+)"$') + def _audio_output_disableoutput(self, outputid): + """ + *musicpd.org, audio output section:* + + ``disableoutput`` + + Turns an output off. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^enableoutput "(?P\d+)"$') + def _audio_output_enableoutput(self, outputid): + """ + *musicpd.org, audio output section:* + + ``enableoutput`` + + Turns an output on. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^outputs$') + def _audio_ouput_outputs(self): + """ + *musicpd.org, audio output section:* + + ``outputs`` + + Shows information about all outputs. + """ + return [ + ('outputid', 0), + ('outputname', self.backend.__class__.__name__), + ('outputenabled', 1), + ] + + @handle_pattern(r'^command_list_begin$') + def _command_list_begin(self): + """ + *musicpd.org, command list section:* + + To facilitate faster adding of files etc. you can pass a list of + commands all at once using a command list. The command list begins + with ``command_list_begin`` or ``command_list_ok_begin`` and ends + with ``command_list_end``. + + It does not execute any commands until the list has ended. The + return value is whatever the return for a list of commands is. On + success for all commands, ``OK`` is returned. If a command fails, + no more commands are executed and the appropriate ``ACK`` error is + returned. If ``command_list_ok_begin`` is used, ``list_OK`` is + returned for each successful command executed in the command list. + """ + self.command_list = [] + self.command_list_ok = False + + @handle_pattern(r'^command_list_end$') + def _command_list_end(self): + """See :meth:`_command_list_begin`.""" + (command_list, self.command_list) = (self.command_list, False) + (command_list_ok, self.command_list_ok) = (self.command_list_ok, False) + result = [] + for command in command_list: + response = self.handle_request(command, add_ok=False) + if response is not None: + result.append(response) + if response and response[-1].startswith(u'ACK'): + return result + if command_list_ok: + response.append(u'list_OK') + return result + + @handle_pattern(r'^command_list_ok_begin$') + def _command_list_ok_begin(self): + """See :meth:`_command_list_begin`.""" + self.command_list = [] + self.command_list_ok = True + + @handle_pattern(r'^close$') + def _connection_close(self): + """ + *musicpd.org, connection section:* + + ``close`` + + Closes the connection to MPD. + """ + self.session.do_close() + + @handle_pattern(r'^kill$') + def _connection_kill(self): + """ + *musicpd.org, connection section:* + + ``kill`` + + Kills MPD. + """ + self.session.do_kill() + + @handle_pattern(r'^password "(?P[^"]+)"$') + def _connection_password(self, password): + """ + *musicpd.org, connection section:* + + ``password {PASSWORD}`` + + This is used for authentication with the server. ``PASSWORD`` is + simply the plaintext password. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^ping$') + def _connection_ping(self): + """ + *musicpd.org, connection section:* + + ``ping`` + + Does nothing but return ``OK``. + """ + pass + @handle_pattern(r'^add "(?P[^"]*)"$') def _current_playlist_add(self, uri): """ @@ -119,167 +243,6 @@ class MpdHandler(object): """ raise MpdNotImplemented # TODO - @handle_pattern(r'^clear$') - def _current_playlist_clear(self): - """ - *musicpd.org, current playlist section:* - - ``clear`` - - Clears the current playlist. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^clearerror$') - def _status_clearerror(self): - """ - *musicpd.org, status section:* - - ``clearerror`` - - Clears the current error message in status (this is also - accomplished by any command that starts playback). - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^close$') - def _connection_close(self): - """ - *musicpd.org, connection section:* - - ``close`` - - Closes the connection to MPD. - """ - self.session.do_close() - - @handle_pattern(r'^command_list_begin$') - def _command_list_begin(self): - """ - *musicpd.org, command list section:* - - To facilitate faster adding of files etc. you can pass a list of - commands all at once using a command list. The command list begins - with ``command_list_begin`` or ``command_list_ok_begin`` and ends - with ``command_list_end``. - - It does not execute any commands until the list has ended. The - return value is whatever the return for a list of commands is. On - success for all commands, ``OK`` is returned. If a command fails, - no more commands are executed and the appropriate ``ACK`` error is - returned. If ``command_list_ok_begin`` is used, ``list_OK`` is - returned for each successful command executed in the command list. - """ - self.command_list = [] - self.command_list_ok = False - - @handle_pattern(r'^command_list_ok_begin$') - def _command_list_ok_begin(self): - """See :meth:`_command_list_begin`.""" - self.command_list = [] - self.command_list_ok = True - - @handle_pattern(r'^command_list_end$') - def _command_list_end(self): - """See :meth:`_command_list_begin`.""" - (command_list, self.command_list) = (self.command_list, False) - (command_list_ok, self.command_list_ok) = (self.command_list_ok, False) - result = [] - for command in command_list: - response = self.handle_request(command, add_ok=False) - if response is not None: - result.append(response) - if response and response[-1].startswith(u'ACK'): - return result - if command_list_ok: - response.append(u'list_OK') - return result - - @handle_pattern(r'^commands$') - def _reflection_commands(self): - """ - *musicpd.org, reflection section:* - - ``commands`` - - Shows which commands the current user has access to. - """ - pass # TODO - - @handle_pattern(r'^consume "(?P[01])"$') - def _playback_consume(self, state): - """ - *musicpd.org, playback section:* - - ``consume {STATE}`` - - Sets consume state to ``STATE``, ``STATE`` should be 0 or - 1. When consume is activated, each song played is removed from - playlist. - """ - state = int(state) - if state: - raise MpdNotImplemented # TODO - else: - raise MpdNotImplemented # TODO - - @handle_pattern(r'^count "(?P[^"]+)" "(?P[^"]+)"$') - def _music_db_count(self, tag, needle): - """ - *musicpd.org, music database section:* - - ``count {TAG} {NEEDLE}`` - - Counts the number of songs and their total playtime in the db - matching ``TAG`` exactly. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^crossfade "(?P\d+)"$') - def _playback_crossfade(self, seconds): - """ - *musicpd.org, playback section:* - - ``crossfade {SECONDS}`` - - Sets crossfading between songs. - """ - seconds = int(seconds) - raise MpdNotImplemented # TODO - - @handle_pattern(r'^currentsong$') - def _status_currentsong(self): - """ - *musicpd.org, status section:* - - ``currentsong`` - - Displays the song info of the current song (same song that is - identified in status). - """ - if self.backend.playback.current_track is not None: - return self.backend.playback.current_track.mpd_format( - position=self.backend.playback.playlist_position) - - @handle_pattern(r'^decoders$') - def _reflection_decoders(self): - """ - *musicpd.org, reflection section:* - - ``decoders`` - - Print a list of decoder plugins, followed by their supported - suffixes and MIME types. Example response:: - - plugin: mad - suffix: mp3 - suffix: mp2 - mime_type: audio/mpeg - plugin: mpcdec - suffix: mpc - """ - raise MpdNotImplemented # TODO - @handle_pattern(r'^delete "(?P\d+)"$') @handle_pattern(r'^delete "(?P\d+):(?P\d+)*"$') def _current_playlist_delete(self, songpos=None, start=None, end=None): @@ -308,250 +271,17 @@ class MpdHandler(object): except KeyError, e: raise MpdAckError(unicode(e)) - @handle_pattern(r'^disableoutput "(?P\d+)"$') - def _audio_output_disableoutput(self, outputid): + @handle_pattern(r'^clear$') + def _current_playlist_clear(self): """ - *musicpd.org, audio output section:* + *musicpd.org, current playlist section:* - ``disableoutput`` + ``clear`` - Turns an output off. + Clears the current playlist. """ raise MpdNotImplemented # TODO - @handle_pattern(r'^$') - def _empty(self): - """The original MPD server returns ``OK`` on an empty request.``""" - pass - - @handle_pattern(r'^enableoutput "(?P\d+)"$') - def _audio_output_enableoutput(self, outputid): - """ - *musicpd.org, audio output section:* - - ``enableoutput`` - - Turns an output on. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^find "(?P(album|artist|title))" ' - r'"(?P[^"]+)"$') - def _music_db_find(self, type, what): - """ - *musicpd.org, music database section:* - - ``find {TYPE} {WHAT}`` - - Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be - ``album``, ``artist``, or ``title``. ``WHAT`` is what to find. - """ - if type == u'title': - type = u'track' - return self.backend.library.find_exact(type, what).mpd_format( - search_result=True) - - @handle_pattern(r'^findadd "(?P(album|artist|title))" ' - r'"(?P[^"]+)"$') - def _music_db_findadd(self, type, what): - """ - *musicpd.org, music database section:* - - ``findadd {TYPE} {WHAT}`` - - Finds songs in the db that are exactly ``WHAT`` and adds them to - current playlist. ``TYPE`` can be any tag supported by MPD. - ``WHAT`` is what to find. - """ - result = self._music_db_find(type, what) - # TODO Add result to current playlist - #return result - - @handle_pattern(r'^idle$') - @handle_pattern(r'^idle (?P.+)$') - def _status_idle(self, subsystems=None): - """ - *musicpd.org, status section:* - - ``idle [SUBSYSTEMS...]`` - - Waits until there is a noteworthy change in one or more of MPD's - subsystems. As soon as there is one, it lists all changed systems - in a line in the format ``changed: SUBSYSTEM``, where ``SUBSYSTEM`` - is one of the following: - - - ``database``: the song database has been modified after update. - - ``update``: a database update has started or finished. If the - database was modified during the update, the database event is - also emitted. - - ``stored_playlist``: a stored playlist has been modified, - renamed, created or deleted - - ``playlist``: the current playlist has been modified - - ``player``: the player has been started, stopped or seeked - - ``mixer``: the volume has been changed - - ``output``: an audio output has been enabled or disabled - - ``options``: options like repeat, random, crossfade, replay gain - - While a client is waiting for idle results, the server disables - timeouts, allowing a client to wait for events as long as MPD runs. - The idle command can be canceled by sending the command ``noidle`` - (no other commands are allowed). MPD will then leave idle mode and - print results immediately; might be empty at this time. - - If the optional ``SUBSYSTEMS`` argument is used, MPD will only send - notifications when something changed in one of the specified - subsystems. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^kill$') - def _connection_kill(self): - """ - *musicpd.org, connection section:* - - ``kill`` - - Kills MPD. - """ - self.session.do_kill() - - @handle_pattern(r'^list "(?Partist)"$') - @handle_pattern(r'^list "(?Palbum)"( "(?P[^"]+)")*$') - def _music_db_list(self, type, artist=None): - """ - *musicpd.org, music database section:* - - ``list {TYPE} [ARTIST]`` - - Lists all tags of the specified type. ``TYPE`` should be ``album`` - or artist. - - ``ARTIST`` is an optional parameter when type is ``album``, this - specifies to list albums by an artist. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^listall "(?P[^"]+)"') - def _music_db_listall(self, uri): - """ - *musicpd.org, music database section:* - - ``listall [URI]`` - - Lists all songs and directories in ``URI``. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^listallinfo "(?P[^"]+)"') - def _music_db_listallinfo(self, uri): - """ - *musicpd.org, music database section:* - - ``listallinfo [URI]`` - - Same as ``listall``, except it also returns metadata info in the - same format as ``lsinfo``. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^listplaylist "(?P[^"]+)"$') - def _stored_playlists_listplaylist(self, name): - """ - *musicpd.org, stored playlists section:* - - ``listplaylist {NAME}`` - - Lists the files in the playlist ``NAME.m3u``. - - Output format:: - - file: relative/path/to/file1.flac - file: relative/path/to/file2.ogg - file: relative/path/to/file3.mp3 - """ - try: - return ['file: %s' % t.uri - for t in self.backend.stored_playlists.get_by_name(name).tracks] - except KeyError, e: - raise MpdAckError(e) - - @handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') - def _stored_playlists_listplaylistinfo(self, name): - """ - *musicpd.org, stored playlists section:* - - ``listplaylistinfo {NAME}`` - - Lists songs in the playlist ``NAME.m3u``. - - Output format: - - Standard track listing, with fields: file, Time, Title, Date, - Album, Artist, Track - """ - try: - return self.backend.stored_playlists.get_by_name(name).mpd_format( - search_result=True) - except KeyError, e: - raise MpdAckError(e) - - @handle_pattern(r'^listplaylists$') - def _stored_playlists_listplaylists(self): - """ - *musicpd.org, stored playlists section:* - - ``listplaylists`` - - Prints a list of the playlist directory. - - After each playlist name the server sends its last modification - time as attribute ``Last-Modified`` in ISO 8601 format. To avoid - problems due to clock differences between clients and the server, - clients should not compare this value with their local clock. - - Output format:: - - playlist: a - Last-Modified: 2010-02-06T02:10:25Z - playlist: b - Last-Modified: 2010-02-06T02:11:08Z - """ - # TODO Add Last-Modified attribute to output - return [u'playlist: %s' % p.name - for p in self.backend.stored_playlists.playlists] - - @handle_pattern(r'^load "(?P[^"]+)"$') - def _stored_playlists_load(self, name): - """ - *musicpd.org, stored playlists section:* - - ``load {NAME}`` - - Loads the playlist ``NAME.m3u`` from the playlist directory. - """ - matches = self.backend.stored_playlists.search(name) - if matches: - self.backend.current_playlist.load(matches[0]) - self.backend.playback.new_playlist_loaded_callback() - - @handle_pattern(r'^lsinfo$') - @handle_pattern(r'^lsinfo "(?P[^"]*)"$') - def _music_db_lsinfo(self, uri=None): - """ - *musicpd.org, music database section:* - - ``lsinfo [URI]`` - - Lists the contents of the directory ``URI``. - - When listing the root directory, this currently returns the list of - stored playlists. This behavior is deprecated; use - ``listplaylists`` instead. - """ - if uri == u'/' or uri is None: - return self._stored_playlists_listplaylists() - raise MpdNotImplemented # TODO - @handle_pattern(r'^move "(?P\d+)" "(?P\d+)"$') @handle_pattern(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') def _current_playlist_move(self, songpos=None, @@ -579,125 +309,6 @@ class MpdHandler(object): """ raise MpdNotImplemented # TODO - @handle_pattern(r'^next$') - def _playback_next(self): - """ - *musicpd.org, playback section:* - - ``next`` - - Plays next song in the playlist. - """ - return self.backend.playback.next() - - @handle_pattern(r'^noidle$') - def _status_noidle(self): - """See :meth:`_idle`.""" - raise MpdNotImplemented # TODO - - @handle_pattern(r'^notcommands$') - def _reflection_notcommands(self): - """ - *musicpd.org, reflection section:* - - ``notcommands`` - - Shows which commands the current user does not have access to. - """ - pass # TODO - - @handle_pattern(r'^outputs$') - def _audio_ouput_outputs(self): - """ - *musicpd.org, audio output section:* - - ``outputs`` - - Shows information about all outputs. - """ - return [ - ('outputid', 0), - ('outputname', self.backend.__class__.__name__), - ('outputenabled', 1), - ] - - @handle_pattern(r'^password "(?P[^"]+)"$') - def _connection_password(self, password): - """ - *musicpd.org, connection section:* - - ``password {PASSWORD}`` - - This is used for authentication with the server. ``PASSWORD`` is - simply the plaintext password. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^pause "(?P[01])"$') - def _playback_pause(self, state): - """ - *musicpd.org, playback section:* - - ``pause {PAUSE}`` - - Toggles pause/resumes playing, ``PAUSE`` is 0 or 1. - """ - if int(state): - self.backend.playback.pause() - else: - self.backend.playback.resume() - - @handle_pattern(r'^ping$') - def _connection_ping(self): - """ - *musicpd.org, connection section:* - - ``ping`` - - Does nothing but return ``OK``. - """ - pass - - @handle_pattern(r'^play$') - def _playback_play(self): - """ - The original MPD server resumes from the paused state on ``play`` - without arguments. - """ - return self.backend.playback.play() - - @handle_pattern(r'^play "(?P\d+)"$') - def _playback_playpos(self, songpos): - """ - *musicpd.org, playback section:* - - ``play [SONGPOS]`` - - Begins playing the playlist at song number ``SONGPOS``. - """ - songpos = int(songpos) - try: - track = self.backend.current_playlist.playlist.tracks[songpos] - return self.backend.playback.play(track) - except IndexError: - raise MpdAckError(u'Position out of bounds') - - @handle_pattern(r'^playid "(?P\d+)"$') - def _playback_playid(self, songid): - """ - *musicpd.org, playback section:* - - ``playid [SONGID]`` - - Begins playing the playlist at song ``SONGID``. - """ - songid = int(songid) - try: - track = self.backend.current_playlist.get_by_id(songid) - return self.backend.playback.play(track) - except KeyError, e: - raise MpdAckError(unicode(e)) - @handle_pattern(r'^playlist$') def _current_playlist_playlist(self): """ @@ -713,41 +324,6 @@ class MpdHandler(object): """ return self._current_playlist_playlistinfo() - @handle_pattern(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') - def _stored_playlist_playlistadd(self, name, uri): - """ - *musicpd.org, stored playlists section:* - - ``playlistadd {NAME} {URI}`` - - Adds ``URI`` to the playlist ``NAME.m3u``. - - ``NAME.m3u`` will be created if it does not exist. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^playlistclear "(?P[^"]+)"$') - def _stored_playlist_playlistclear(self, name): - """ - *musicpd.org, stored playlists section:* - - ``playlistclear {NAME}`` - - Clears the playlist ``NAME.m3u``. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') - def _stored_playlist_playlistdelete(self, name, songpos): - """ - *musicpd.org, stored playlists section:* - - ``playlistdelete {NAME} {SONGPOS}`` - - Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. - """ - raise MpdNotImplemented # TODO - @handle_pattern(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') def _current_playlist_playlistfind(self, tag, needle): """ @@ -797,20 +373,6 @@ class MpdHandler(object): if end is not None: end = int(end) return self.backend.current_playlist.playlist.mpd_format(start, end) - - @handle_pattern(r'^playlistmove "(?P[^"]+)" ' - r'"(?P\d+)" "(?P\d+)"$') - def _stored_playlist_playlistmove(self, name, songid, songpos): - """ - *musicpd.org, stored playlists section:* - - ``playlistmove {NAME} {SONGID} {SONGPOS}`` - - Moves ``SONGID`` in the playlist ``NAME.m3u`` to the position - ``SONGPOS``. - """ - raise MpdNotImplemented # TODO - @handle_pattern(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') def _current_playlist_playlistsearch(self, tag, needle): """ @@ -854,6 +416,287 @@ class MpdHandler(object): """ raise MpdNotImplemented # TODO + @handle_pattern(r'^shuffle$') + @handle_pattern(r'^shuffle "(?P\d+):(?P\d+)*"$') + def _current_playlist_shuffle(self, start=None, end=None): + """ + *musicpd.org, current playlist section:* + + ``shuffle [START:END]`` + + Shuffles the current playlist. ``START:END`` is optional and + specifies a range of songs. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^swap "(?P\d+)" "(?P\d+)"$') + def _current_playlist_swap(self, songpos1, songpos2): + """ + *musicpd.org, current playlist section:* + + ``swap {SONG1} {SONG2}`` + + Swaps the positions of ``SONG1`` and ``SONG2``. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^swapid "(?P\d+)" "(?P\d+)"$') + def _current_playlist_swapid(self, songid1, songid2): + """ + *musicpd.org, current playlist section:* + + ``swapid {SONG1} {SONG2}`` + + Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^$') + def _empty(self): + """The original MPD server returns ``OK`` on an empty request.``""" + pass + + @handle_pattern(r'^count "(?P[^"]+)" "(?P[^"]+)"$') + def _music_db_count(self, tag, needle): + """ + *musicpd.org, music database section:* + + ``count {TAG} {NEEDLE}`` + + Counts the number of songs and their total playtime in the db + matching ``TAG`` exactly. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^find "(?P(album|artist|title))" ' + r'"(?P[^"]+)"$') + def _music_db_find(self, type, what): + """ + *musicpd.org, music database section:* + + ``find {TYPE} {WHAT}`` + + Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be + ``album``, ``artist``, or ``title``. ``WHAT`` is what to find. + """ + if type == u'title': + type = u'track' + return self.backend.library.find_exact(type, what).mpd_format( + search_result=True) + + @handle_pattern(r'^findadd "(?P(album|artist|title))" ' + r'"(?P[^"]+)"$') + def _music_db_findadd(self, type, what): + """ + *musicpd.org, music database section:* + + ``findadd {TYPE} {WHAT}`` + + Finds songs in the db that are exactly ``WHAT`` and adds them to + current playlist. ``TYPE`` can be any tag supported by MPD. + ``WHAT`` is what to find. + """ + result = self._music_db_find(type, what) + # TODO Add result to current playlist + #return result + + @handle_pattern(r'^list "(?Partist)"$') + @handle_pattern(r'^list "(?Palbum)"( "(?P[^"]+)")*$') + def _music_db_list(self, type, artist=None): + """ + *musicpd.org, music database section:* + + ``list {TYPE} [ARTIST]`` + + Lists all tags of the specified type. ``TYPE`` should be ``album`` + or artist. + + ``ARTIST`` is an optional parameter when type is ``album``, this + specifies to list albums by an artist. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^listall "(?P[^"]+)"') + def _music_db_listall(self, uri): + """ + *musicpd.org, music database section:* + + ``listall [URI]`` + + Lists all songs and directories in ``URI``. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^listallinfo "(?P[^"]+)"') + def _music_db_listallinfo(self, uri): + """ + *musicpd.org, music database section:* + + ``listallinfo [URI]`` + + Same as ``listall``, except it also returns metadata info in the + same format as ``lsinfo``. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^lsinfo$') + @handle_pattern(r'^lsinfo "(?P[^"]*)"$') + def _music_db_lsinfo(self, uri=None): + """ + *musicpd.org, music database section:* + + ``lsinfo [URI]`` + + Lists the contents of the directory ``URI``. + + When listing the root directory, this currently returns the list of + stored playlists. This behavior is deprecated; use + ``listplaylists`` instead. + """ + if uri == u'/' or uri is None: + return self._stored_playlists_listplaylists() + raise MpdNotImplemented # TODO + + @handle_pattern(r'^rescan( "(?P[^"]+)")*$') + def _music_db_rescan(self, uri=None): + """ + *musicpd.org, music database section:* + + ``rescan [URI]`` + + Same as ``update``, but also rescans unmodified files. + """ + return self._music_db_update(uri, rescan_unmodified_files=True) + + @handle_pattern(r'^search "(?P(album|artist|filename|title))" ' + r'"(?P[^"]+)"$') + def _music_db_search(self, type, what): + """ + *musicpd.org, music database section:* + + ``search {TYPE} {WHAT}`` + + Searches for any song that contains ``WHAT``. ``TYPE`` can be + ``title``, ``artist``, ``album`` or ``filename``. Search is not + case sensitive. + """ + if type == u'title': + type = u'track' + return self.backend.library.search(type, what).mpd_format( + search_result=True) + + @handle_pattern(r'^update( "(?P[^"]+)")*$') + def _music_db_update(self, uri=None, rescan_unmodified_files=False): + """ + *musicpd.org, music database section:* + + ``update [URI]`` + + Updates the music database: find new files, remove deleted files, + update modified files. + + ``URI`` is a particular directory or song/file to update. If you do + not specify it, everything is updated. + + Prints ``updating_db: JOBID`` where ``JOBID`` is a positive number + identifying the update job. You can read the current job id in the + ``status`` response. + """ + return {'updating_db': 0} # TODO + + @handle_pattern(r'^consume "(?P[01])"$') + def _playback_consume(self, state): + """ + *musicpd.org, playback section:* + + ``consume {STATE}`` + + Sets consume state to ``STATE``, ``STATE`` should be 0 or + 1. When consume is activated, each song played is removed from + playlist. + """ + state = int(state) + if state: + raise MpdNotImplemented # TODO + else: + raise MpdNotImplemented # TODO + + @handle_pattern(r'^crossfade "(?P\d+)"$') + def _playback_crossfade(self, seconds): + """ + *musicpd.org, playback section:* + + ``crossfade {SECONDS}`` + + Sets crossfading between songs. + """ + seconds = int(seconds) + raise MpdNotImplemented # TODO + + @handle_pattern(r'^next$') + def _playback_next(self): + """ + *musicpd.org, playback section:* + + ``next`` + + Plays next song in the playlist. + """ + return self.backend.playback.next() + + @handle_pattern(r'^pause "(?P[01])"$') + def _playback_pause(self, state): + """ + *musicpd.org, playback section:* + + ``pause {PAUSE}`` + + Toggles pause/resumes playing, ``PAUSE`` is 0 or 1. + """ + if int(state): + self.backend.playback.pause() + else: + self.backend.playback.resume() + + @handle_pattern(r'^play$') + def _playback_play(self): + """ + The original MPD server resumes from the paused state on ``play`` + without arguments. + """ + return self.backend.playback.play() + @handle_pattern(r'^playid "(?P\d+)"$') + def _playback_playid(self, songid): + """ + *musicpd.org, playback section:* + + ``playid [SONGID]`` + + Begins playing the playlist at song ``SONGID``. + """ + songid = int(songid) + try: + track = self.backend.current_playlist.get_by_id(songid) + return self.backend.playback.play(track) + except KeyError, e: + raise MpdAckError(unicode(e)) + + @handle_pattern(r'^play "(?P\d+)"$') + def _playback_playpos(self, songpos): + """ + *musicpd.org, playback section:* + + ``play [SONGPOS]`` + + Begins playing the playlist at song number ``SONGPOS``. + """ + songpos = int(songpos) + try: + track = self.backend.current_playlist.playlist.tracks[songpos] + return self.backend.playback.play(track) + except IndexError: + raise MpdAckError(u'Position out of bounds') + @handle_pattern(r'^previous$') def _playback_previous(self): """ @@ -865,17 +708,6 @@ class MpdHandler(object): """ return self.backend.playback.previous() - @handle_pattern(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') - def _stored_playlists_rename(self, old_name, new_name): - """ - *musicpd.org, stored playlists section:* - - ``rename {NAME} {NEW_NAME}`` - - Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. - """ - raise MpdNotImplemented # TODO - @handle_pattern(r'^random "(?P[01])"$') def _playback_random(self, state): """ @@ -934,57 +766,6 @@ class MpdHandler(object): """ return u'off' # TODO - @handle_pattern(r'^rescan( "(?P[^"]+)")*$') - def _music_db_rescan(self, uri=None): - """ - *musicpd.org, music database section:* - - ``rescan [URI]`` - - Same as ``update``, but also rescans unmodified files. - """ - return self._music_db_update(uri, rescan_unmodified_files=True) - - @handle_pattern(r'^rm "(?P[^"]+)"$') - def _stored_playlists_rm(self, name): - """ - *musicpd.org, stored playlists section:* - - ``rm {NAME}`` - - Removes the playlist ``NAME.m3u`` from the playlist directory. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^save "(?P[^"]+)"$') - def _stored_playlists_save(self, name): - """ - *musicpd.org, stored playlists section:* - - ``save {NAME}`` - - Saves the current playlist to ``NAME.m3u`` in the playlist - directory. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^search "(?P(album|artist|filename|title))" ' - r'"(?P[^"]+)"$') - def _music_db_search(self, type, what): - """ - *musicpd.org, music database section:* - - ``search {TYPE} {WHAT}`` - - Searches for any song that contains ``WHAT``. ``TYPE`` can be - ``title``, ``artist``, ``album`` or ``filename``. Search is not - case sensitive. - """ - if type == u'title': - type = u'track' - return self.backend.library.search(type, what).mpd_format( - search_result=True) - @handle_pattern(r'^seek "(?P\d+)" "(?P\d+)"$') def _playback_seek(self, songpos, seconds): """ @@ -1024,19 +805,6 @@ class MpdHandler(object): volume = 100 self.backend.playback.volume = volume - @handle_pattern(r'^shuffle$') - @handle_pattern(r'^shuffle "(?P\d+):(?P\d+)*"$') - def _current_playlist_shuffle(self, start=None, end=None): - """ - *musicpd.org, current playlist section:* - - ``shuffle [START:END]`` - - Shuffles the current playlist. ``START:END`` is optional and - specifies a range of songs. - """ - raise MpdNotImplemented # TODO - @handle_pattern(r'^single "(?P[01])"$') def _playback_single(self, state): """ @@ -1054,6 +822,148 @@ class MpdHandler(object): else: raise MpdNotImplemented # TODO + @handle_pattern(r'^stop$') + def _playback_stop(self): + """ + *musicpd.org, playback section:* + + ``stop`` + + Stops playing. + """ + self.backend.playback.stop() + + @handle_pattern(r'^commands$') + def _reflection_commands(self): + """ + *musicpd.org, reflection section:* + + ``commands`` + + Shows which commands the current user has access to. + """ + pass # TODO + + @handle_pattern(r'^decoders$') + def _reflection_decoders(self): + """ + *musicpd.org, reflection section:* + + ``decoders`` + + Print a list of decoder plugins, followed by their supported + suffixes and MIME types. Example response:: + + plugin: mad + suffix: mp3 + suffix: mp2 + mime_type: audio/mpeg + plugin: mpcdec + suffix: mpc + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^notcommands$') + def _reflection_notcommands(self): + """ + *musicpd.org, reflection section:* + + ``notcommands`` + + Shows which commands the current user does not have access to. + """ + pass # TODO + + @handle_pattern(r'^tagtypes$') + def _reflection_tagtypes(self): + """ + *musicpd.org, reflection section:* + + ``tagtypes`` + + Shows a list of available song metadata. + """ + pass # TODO + + @handle_pattern(r'^urlhandlers$') + def _reflection_urlhandlers(self): + """ + *musicpd.org, reflection section:* + + ``urlhandlers`` + + Gets a list of available URL handlers. + """ + return self.backend.uri_handlers + + @handle_pattern(r'^clearerror$') + def _status_clearerror(self): + """ + *musicpd.org, status section:* + + ``clearerror`` + + Clears the current error message in status (this is also + accomplished by any command that starts playback). + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^currentsong$') + def _status_currentsong(self): + """ + *musicpd.org, status section:* + + ``currentsong`` + + Displays the song info of the current song (same song that is + identified in status). + """ + if self.backend.playback.current_track is not None: + return self.backend.playback.current_track.mpd_format( + position=self.backend.playback.playlist_position) + + @handle_pattern(r'^idle$') + @handle_pattern(r'^idle (?P.+)$') + def _status_idle(self, subsystems=None): + """ + *musicpd.org, status section:* + + ``idle [SUBSYSTEMS...]`` + + Waits until there is a noteworthy change in one or more of MPD's + subsystems. As soon as there is one, it lists all changed systems + in a line in the format ``changed: SUBSYSTEM``, where ``SUBSYSTEM`` + is one of the following: + + - ``database``: the song database has been modified after update. + - ``update``: a database update has started or finished. If the + database was modified during the update, the database event is + also emitted. + - ``stored_playlist``: a stored playlist has been modified, + renamed, created or deleted + - ``playlist``: the current playlist has been modified + - ``player``: the player has been started, stopped or seeked + - ``mixer``: the volume has been changed + - ``output``: an audio output has been enabled or disabled + - ``options``: options like repeat, random, crossfade, replay gain + + While a client is waiting for idle results, the server disables + timeouts, allowing a client to wait for events as long as MPD runs. + The idle command can be canceled by sending the command ``noidle`` + (no other commands are allowed). MPD will then leave idle mode and + print results immediately; might be empty at this time. + + If the optional ``SUBSYSTEMS`` argument is used, MPD will only send + notifications when something changed in one of the specified + subsystems. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^noidle$') + def _status_noidle(self): + """See :meth:`_idle`.""" + raise MpdNotImplemented # TODO + @handle_pattern(r'^stats$') def _status_stats(self): """ @@ -1078,18 +988,7 @@ class MpdHandler(object): 'db_playtime': 0, # TODO 'db_update': 0, # TODO 'playtime': 0, # TODO - } - - @handle_pattern(r'^stop$') - def _playback_stop(self): - """ - *musicpd.org, playback section:* - - ``stop`` - - Stops playing. - """ - self.backend.playback.stop() + } @handle_pattern(r'^status$') def _status_status(self): @@ -1279,65 +1178,164 @@ class MpdHandler(object): """ raise MpdNotImplemented # TODO - @handle_pattern(r'^swap "(?P\d+)" "(?P\d+)"$') - def _swap(self, songpos1, songpos2): + @handle_pattern(r'^listplaylist "(?P[^"]+)"$') + def _stored_playlists_listplaylist(self, name): """ - *musicpd.org, current playlist section:* + *musicpd.org, stored playlists section:* - ``swap {SONG1} {SONG2}`` + ``listplaylist {NAME}`` - Swaps the positions of ``SONG1`` and ``SONG2``. + Lists the files in the playlist ``NAME.m3u``. + + Output format:: + + file: relative/path/to/file1.flac + file: relative/path/to/file2.ogg + file: relative/path/to/file3.mp3 + """ + try: + return ['file: %s' % t.uri + for t in self.backend.stored_playlists.get_by_name(name).tracks] + except KeyError, e: + raise MpdAckError(e) + + @handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') + def _stored_playlists_listplaylistinfo(self, name): + """ + *musicpd.org, stored playlists section:* + + ``listplaylistinfo {NAME}`` + + Lists songs in the playlist ``NAME.m3u``. + + Output format: + + Standard track listing, with fields: file, Time, Title, Date, + Album, Artist, Track + """ + try: + return self.backend.stored_playlists.get_by_name(name).mpd_format( + search_result=True) + except KeyError, e: + raise MpdAckError(e) + + @handle_pattern(r'^listplaylists$') + def _stored_playlists_listplaylists(self): + """ + *musicpd.org, stored playlists section:* + + ``listplaylists`` + + Prints a list of the playlist directory. + + After each playlist name the server sends its last modification + time as attribute ``Last-Modified`` in ISO 8601 format. To avoid + problems due to clock differences between clients and the server, + clients should not compare this value with their local clock. + + Output format:: + + playlist: a + Last-Modified: 2010-02-06T02:10:25Z + playlist: b + Last-Modified: 2010-02-06T02:11:08Z + """ + # TODO Add Last-Modified attribute to output + return [u'playlist: %s' % p.name + for p in self.backend.stored_playlists.playlists] + + @handle_pattern(r'^load "(?P[^"]+)"$') + def _stored_playlists_load(self, name): + """ + *musicpd.org, stored playlists section:* + + ``load {NAME}`` + + Loads the playlist ``NAME.m3u`` from the playlist directory. + """ + matches = self.backend.stored_playlists.search(name) + if matches: + self.backend.current_playlist.load(matches[0]) + self.backend.playback.new_playlist_loaded_callback() + + @handle_pattern(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') + def _stored_playlist_playlistadd(self, name, uri): + """ + *musicpd.org, stored playlists section:* + + ``playlistadd {NAME} {URI}`` + + Adds ``URI`` to the playlist ``NAME.m3u``. + + ``NAME.m3u`` will be created if it does not exist. """ raise MpdNotImplemented # TODO - @handle_pattern(r'^swapid "(?P\d+)" "(?P\d+)"$') - def _current_playlist_swapid(self, songid1, songid2): + @handle_pattern(r'^playlistclear "(?P[^"]+)"$') + def _stored_playlist_playlistclear(self, name): """ - *musicpd.org, current playlist section:* + *musicpd.org, stored playlists section:* - ``swapid {SONG1} {SONG2}`` + ``playlistclear {NAME}`` - Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). + Clears the playlist ``NAME.m3u``. """ raise MpdNotImplemented # TODO - @handle_pattern(r'^tagtypes$') - def _reflection_tagtypes(self): + @handle_pattern(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') + def _stored_playlist_playlistdelete(self, name, songpos): """ - *musicpd.org, reflection section:* + *musicpd.org, stored playlists section:* - ``tagtypes`` + ``playlistdelete {NAME} {SONGPOS}`` - Shows a list of available song metadata. + Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. """ - pass # TODO + raise MpdNotImplemented # TODO - @handle_pattern(r'^update( "(?P[^"]+)")*$') - def _music_db_update(self, uri=None, rescan_unmodified_files=False): + @handle_pattern(r'^playlistmove "(?P[^"]+)" ' + r'"(?P\d+)" "(?P\d+)"$') + def _stored_playlist_playlistmove(self, name, songid, songpos): """ - *musicpd.org, music database section:* + *musicpd.org, stored playlists section:* - ``update [URI]`` + ``playlistmove {NAME} {SONGID} {SONGPOS}`` - Updates the music database: find new files, remove deleted files, - update modified files. - - ``URI`` is a particular directory or song/file to update. If you do - not specify it, everything is updated. - - Prints ``updating_db: JOBID`` where ``JOBID`` is a positive number - identifying the update job. You can read the current job id in the - ``status`` response. + Moves ``SONGID`` in the playlist ``NAME.m3u`` to the position + ``SONGPOS``. """ - return {'updating_db': 0} # TODO + raise MpdNotImplemented # TODO - @handle_pattern(r'^urlhandlers$') - def _reflection_urlhandlers(self): + @handle_pattern(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') + def _stored_playlists_rename(self, old_name, new_name): """ - *musicpd.org, reflection section:* + *musicpd.org, stored playlists section:* - ``urlhandlers`` + ``rename {NAME} {NEW_NAME}`` - Gets a list of available URL handlers. + Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. """ - return self.backend.uri_handlers + raise MpdNotImplemented # TODO + + @handle_pattern(r'^rm "(?P[^"]+)"$') + def _stored_playlists_rm(self, name): + """ + *musicpd.org, stored playlists section:* + + ``rm {NAME}`` + + Removes the playlist ``NAME.m3u`` from the playlist directory. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^save "(?P[^"]+)"$') + def _stored_playlists_save(self, name): + """ + *musicpd.org, stored playlists section:* + + ``save {NAME}`` + + Saves the current playlist to ``NAME.m3u`` in the playlist + directory. + """ + raise MpdNotImplemented # TODO \ No newline at end of file From d62608db3e29c028444987e5b411c26b9312eb45 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 16:04:09 +0100 Subject: [PATCH 115/341] Fix typo --- mopidy/mpd/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 220c35a7..67a6d95f 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -113,7 +113,7 @@ class MpdHandler(object): raise MpdNotImplemented # TODO @handle_pattern(r'^outputs$') - def _audio_ouput_outputs(self): + def _audio_output_outputs(self): """ *musicpd.org, audio output section:* From 56b8a6647b2ffa66c280ba52d3384bdaa9d11f6a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 16:17:22 +0100 Subject: [PATCH 116/341] Fix error in docstring formatting --- mopidy/mpd/handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 67a6d95f..403ee026 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -303,7 +303,7 @@ class MpdHandler(object): ``moveid {FROM} {TO}`` - Moves the song with ``FROM`` (songid) to ``TO` (playlist index) in + Moves the song with ``FROM`` (songid) to ``TO`` (playlist index) in the playlist. If ``TO`` is negative, it is relative to the current song in the playlist (if there is one). """ @@ -1338,4 +1338,4 @@ class MpdHandler(object): Saves the current playlist to ``NAME.m3u`` in the playlist directory. """ - raise MpdNotImplemented # TODO \ No newline at end of file + raise MpdNotImplemented # TODO From 93e5a417e503a7d816bb7a01aeb8ebbb0e30082b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 16:27:18 +0100 Subject: [PATCH 117/341] Add 'elapsed' to 'status' output and increase MPD protocol version to 0.16.0 --- mopidy/__init__.py | 2 +- mopidy/mpd/handler.py | 8 ++++---- tests/mpd/handlertest.py | 7 +++++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 7316ab6f..eaea5fd7 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -5,7 +5,7 @@ def get_version(): return u'0.1.dev' def get_mpd_protocol_version(): - return u'0.15.0' + return u'0.16.0' class Settings(object): def __getattr__(self, attr): diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 403ee026..4afe77d6 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -1039,7 +1039,7 @@ class MpdHandler(object): if self.backend.playback.state in ( self.backend.playback.PLAYING, self.backend.playback.PAUSED): result.append(('time', self.__status_status_time())) - # TODO Add 'elapsed' here when moving to MPD 0.16.0 + result.append(('elapsed', self.__status_status_time_elapsed())) result.append(('bitrate', self.__status_status_bitrate())) return result @@ -1092,8 +1092,8 @@ class MpdHandler(object): return u'pause' def __status_status_time(self): - return u'%s:%s' % (self.__status_status_time_elapsed(), - self.__status_status_time_total()) + return u'%s:%s' % (self.__status_status_time_elapsed() // 1000, + self.__status_status_time_total() // 1000) def __status_status_time_elapsed(self): return self.backend.playback.time_position @@ -1104,7 +1104,7 @@ class MpdHandler(object): elif self.backend.playback.current_track.length is None: return 0 else: - return self.backend.playback.current_track.length // 1000 + return self.backend.playback.current_track.length def __status_status_volume(self): if self.backend.playback.volume is not None: diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index a6891c93..1830151d 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -284,6 +284,13 @@ class StatusHandlerTest(unittest.TestCase): total = int(total) self.assert_(position <= total) + def test_status_method_when_playing_contains_elapsed(self): + self.b.playback.state = self.b.playback.PAUSED + self.b.playback._play_time_accumulated = 59123 + result = dict(self.h._status_status()) + self.assert_('elapsed' in result) + self.assertEquals(int(result['elapsed']), 59123) + def test_status_method_when_playing_contains_bitrate(self): self.b.playback.state = self.b.playback.PLAYING self.b.playback.current_track = Track(bitrate=320) From b3826c86d6735424c0d6efb0df8874fc18579674 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 16:33:08 +0100 Subject: [PATCH 118/341] Add empty line between methods --- mopidy/mpd/handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 4afe77d6..d3656f42 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -373,6 +373,7 @@ class MpdHandler(object): if end is not None: end = int(end) return self.backend.current_playlist.playlist.mpd_format(start, end) + @handle_pattern(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') def _current_playlist_playlistsearch(self, tag, needle): """ From 3dedb8ced3311df789378ef48b73c5e706fe4c21 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 16:46:39 +0100 Subject: [PATCH 119/341] Implement _current_playlist_delete --- mopidy/mpd/handler.py | 19 ++++++++++++++++++- tests/mpd/handlertest.py | 32 ++++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index d3656f42..dbf22de1 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -253,7 +253,24 @@ class MpdHandler(object): Deletes a song from the playlist. """ - raise MpdNotImplemented # TODO + try: + tracks = [] + if songpos is not None: + songpos = int(songpos) + tracks = [ + self.backend.current_playlist.playlist.tracks[songpos]] + elif start is not None: + start = int(start) + if end is not None: + end = int(end) + else: + end = self.backend.current_playlist.playlist.length + tracks = self.backend.current_playlist.playlist.tracks[ + start:end] + for track in tracks: + self.backend.current_playlist.remove(track) + except IndexError, e: + raise MpdAckError(u'Position out of bounds') @handle_pattern(r'^deleteid "(?P\d+)"$') def _current_playlist_deleteid(self, songid): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 1830151d..0d7c2946 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -510,16 +510,36 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'ACK Not implemented' in result) def test_delete_songpos(self): - result = self.h.handle_request(u'delete "5"') - self.assert_(u'ACK Not implemented' in result) + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'delete "2"') + self.assertEquals(self.b.current_playlist.playlist.length, 4) + self.assert_(u'OK' in result) def test_delete_open_range(self): - result = self.h.handle_request(u'delete "10:"') - self.assert_(u'ACK Not implemented' in result) + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'delete "1:"') + self.assertEquals(self.b.current_playlist.playlist.length, 1) + self.assert_(u'OK' in result) def test_delete_closed_range(self): - result = self.h.handle_request(u'delete "10:20"') - self.assert_(u'ACK Not implemented' in result) + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'delete "1:3"') + self.assertEquals(self.b.current_playlist.playlist.length, 3) + self.assert_(u'OK' in result) + + def test_delete_out_of_range(self): + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'delete "5"') + self.assertEquals(self.b.current_playlist.playlist.length, 5) + self.assert_(u'ACK Position out of bounds' in result) def test_deleteid(self): self.b.current_playlist.load(Playlist(tracks=[Track(id=0)])) From 71fc316709560e94efa836dc4b23fa37cf49fb21 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 16:57:16 +0100 Subject: [PATCH 120/341] Implement _current_playlist_add --- mopidy/backends/__init__.py | 7 ++++++- mopidy/backends/dummy.py | 7 +++++++ mopidy/mpd/handler.py | 4 +++- tests/mpd/handlertest.py | 9 +++++++-- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 97042468..9a591894 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -63,7 +63,12 @@ class BaseCurrentPlaylistController(object): :param at_position: position in current playlist to add track :type at_position: int or :class:`None` """ - raise NotImplementedError + tracks = self.playlist.tracks + if at_position: + tracks.insert(at_position, track) + else: + tracks.append(track) + self.playlist = self.playlist.with_(tracks=tracks) def clear(self): """Clear the current playlist.""" diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 4f7c1c9c..c2e828f9 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -15,6 +15,13 @@ class DummyCurrentPlaylistController(BaseCurrentPlaylistController): pass class DummyLibraryController(BaseLibraryController): + _library = [] + + def lookup(self, uri): + matches = filter(lambda t: uri == t.uri, self._library) + if matches: + return matches[0] + def search(self, type, query): return Playlist() diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index dbf22de1..275ca8e1 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -224,7 +224,9 @@ class MpdHandler(object): Adds the file ``URI`` to the playlist (directories add recursively). ``URI`` can also be a single file. """ - raise MpdNotImplemented # TODO + track = self.backend.library.lookup(uri) + if track is not None: + self.backend.current_playlist.add(track) @handle_pattern(r'^addid "(?P[^"]*)"( (?P\d+))*$') def _current_playlist_addid(self, uri, songpos=None): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 0d7c2946..62fd4d44 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -494,8 +494,13 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.h = handler.MpdHandler(backend=self.b) def test_add(self): - result = self.h.handle_request(u'add "file:///dev/urandom"') - self.assert_(u'ACK Not implemented' in result) + needle = Track(uri='dummy://foo') + self.b.library._library = [Track(), Track(), needle, Track()] + self.assertEquals(self.b.current_playlist.playlist.length, 0) + result = self.h.handle_request(u'add "dummy://foo"') + self.assertEquals(self.b.current_playlist.playlist.length, 1) + self.assert_(needle in self.b.current_playlist.playlist.tracks) + self.assert_(u'OK' in result) def test_addid_without_songpos(self): result = self.h.handle_request(u'addid "file:///dev/urandom"') From 54f538555c61960a5cd68e83a6681a680fce6aa9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 17:05:43 +0100 Subject: [PATCH 121/341] Implement _current_playlist_addid --- mopidy/mpd/handler.py | 13 ++++++++----- tests/mpd/handlertest.py | 32 +++++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 275ca8e1..0c282b65 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -224,11 +224,9 @@ class MpdHandler(object): Adds the file ``URI`` to the playlist (directories add recursively). ``URI`` can also be a single file. """ - track = self.backend.library.lookup(uri) - if track is not None: - self.backend.current_playlist.add(track) + self._current_playlist_addid(uri) - @handle_pattern(r'^addid "(?P[^"]*)"( (?P\d+))*$') + @handle_pattern(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') def _current_playlist_addid(self, uri, songpos=None): """ *musicpd.org, current playlist section:* @@ -243,7 +241,12 @@ class MpdHandler(object): Id: 999 OK """ - raise MpdNotImplemented # TODO + if songpos is not None: + songpos = int(songpos) + track = self.backend.library.lookup(uri) + if track is not None: + self.backend.current_playlist.add(track, at_position=songpos) + return ('Id', track.id) @handle_pattern(r'^delete "(?P\d+)"$') @handle_pattern(r'^delete "(?P\d+):(?P\d+)*"$') diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 62fd4d44..170b90f1 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -496,19 +496,37 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def test_add(self): needle = Track(uri='dummy://foo') self.b.library._library = [Track(), Track(), needle, Track()] - self.assertEquals(self.b.current_playlist.playlist.length, 0) + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) result = self.h.handle_request(u'add "dummy://foo"') - self.assertEquals(self.b.current_playlist.playlist.length, 1) - self.assert_(needle in self.b.current_playlist.playlist.tracks) + self.assertEquals(self.b.current_playlist.playlist.length, 6) + self.assertEquals(self.b.current_playlist.playlist.tracks[5], needle) self.assert_(u'OK' in result) def test_addid_without_songpos(self): - result = self.h.handle_request(u'addid "file:///dev/urandom"') - self.assert_(u'ACK Not implemented' in result) + needle = Track(uri='dummy://foo', id=137) + self.b.library._library = [Track(), Track(), needle, Track()] + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'addid "dummy://foo"') + self.assertEquals(self.b.current_playlist.playlist.length, 6) + self.assertEquals(self.b.current_playlist.playlist.tracks[5], needle) + self.assert_(u'Id: 137' in result) + self.assert_(u'OK' in result) def test_addid_with_songpos(self): - result = self.h.handle_request(u'addid "file:///dev/urandom" 0') - self.assert_(u'ACK Not implemented' in result) + needle = Track(uri='dummy://foo', id=137) + self.b.library._library = [Track(), Track(), needle, Track()] + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'addid "dummy://foo" "3"') + self.assertEquals(self.b.current_playlist.playlist.length, 6) + self.assertEquals(self.b.current_playlist.playlist.tracks[3], needle) + self.assert_(u'Id: 137' in result) + self.assert_(u'OK' in result) def test_clear(self): result = self.h.handle_request(u'clear') From 7e94f69e6e04fff5b37db0e03cf3a53aa25598b6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 17:09:28 +0100 Subject: [PATCH 122/341] Implement _current_playlist_clear --- mopidy/mpd/handler.py | 2 +- tests/mpd/handlertest.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 0c282b65..6141d651 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -302,7 +302,7 @@ class MpdHandler(object): Clears the current playlist. """ - raise MpdNotImplemented # TODO + self.backend.current_playlist.clear() @handle_pattern(r'^move "(?P\d+)" "(?P\d+)"$') @handle_pattern(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 170b90f1..c919dd5b 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -529,8 +529,12 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_clear(self): + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) result = self.h.handle_request(u'clear') - self.assert_(u'ACK Not implemented' in result) + self.assertEquals(self.b.current_playlist.playlist.length, 0) + self.assert_(u'OK' in result) def test_delete_songpos(self): self.b.current_playlist.playlist = Playlist( From 6c538ba947e60ef67d34eeed82ca267150fd0aea Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 17:14:29 +0100 Subject: [PATCH 123/341] Add extra asserts to _current_playlist_deleteid tests --- tests/mpd/handlertest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index c919dd5b..7a453216 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -569,12 +569,17 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'ACK Position out of bounds' in result) def test_deleteid(self): - self.b.current_playlist.load(Playlist(tracks=[Track(id=0)])) + self.b.current_playlist.load(Playlist(tracks=[Track(id=0), Track()])) + self.assertEquals(self.b.current_playlist.playlist.length, 2) result = self.h.handle_request(u'deleteid "0"') + self.assertEquals(self.b.current_playlist.playlist.length, 1) self.assert_(u'OK' in result) def test_deleteid_does_not_exist(self): + self.b.current_playlist.load(Playlist(tracks=[Track(id=1), Track()])) + self.assertEquals(self.b.current_playlist.playlist.length, 2) result = self.h.handle_request(u'deleteid "0"') + self.assertEquals(self.b.current_playlist.playlist.length, 2) self.assert_(u'ACK Track with ID "0" not found' in result) def test_move_songpos(self): From 9846f7a7381a7154d7a11c66eef20d68917dc755 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 17:41:10 +0100 Subject: [PATCH 124/341] Split _current_playlist_delete into a method for songpos and a method for ranges --- mopidy/mpd/handler.py | 35 ++++++++++++++++++----------------- tests/mpd/handlertest.py | 12 ++++++++++-- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 6141d651..c3f2b220 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -248,9 +248,8 @@ class MpdHandler(object): self.backend.current_playlist.add(track, at_position=songpos) return ('Id', track.id) - @handle_pattern(r'^delete "(?P\d+)"$') @handle_pattern(r'^delete "(?P\d+):(?P\d+)*"$') - def _current_playlist_delete(self, songpos=None, start=None, end=None): + def _current_playlist_delete_range(self, start, end=None): """ *musicpd.org, current playlist section:* @@ -258,22 +257,24 @@ class MpdHandler(object): Deletes a song from the playlist. """ + start = int(start) + if end is not None: + end = int(end) + else: + end = self.backend.current_playlist.playlist.length + tracks = self.backend.current_playlist.playlist.tracks[start:end] + if not tracks: + raise MpdAckError(u'Position out of bounds') + for track in tracks: + self.backend.current_playlist.remove(track) + + @handle_pattern(r'^delete "(?P\d+)"$') + def _current_playlist_delete_songpos(self, songpos): + """See :meth:`_current_playlist_delete_range`""" try: - tracks = [] - if songpos is not None: - songpos = int(songpos) - tracks = [ - self.backend.current_playlist.playlist.tracks[songpos]] - elif start is not None: - start = int(start) - if end is not None: - end = int(end) - else: - end = self.backend.current_playlist.playlist.length - tracks = self.backend.current_playlist.playlist.tracks[ - start:end] - for track in tracks: - self.backend.current_playlist.remove(track) + songpos = int(songpos) + track = self.backend.current_playlist.playlist.tracks[songpos] + self.backend.current_playlist.remove(track) except IndexError, e: raise MpdAckError(u'Position out of bounds') diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 7a453216..42d2b1e0 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -544,6 +544,14 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEquals(self.b.current_playlist.playlist.length, 4) self.assert_(u'OK' in result) + def test_delete_songpos_out_of_bounds(self): + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'delete "5"') + self.assertEquals(self.b.current_playlist.playlist.length, 5) + self.assert_(u'ACK Position out of bounds' in result) + def test_delete_open_range(self): self.b.current_playlist.playlist = Playlist( tracks=[Track(), Track(), Track(), Track(), Track()]) @@ -560,11 +568,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEquals(self.b.current_playlist.playlist.length, 3) self.assert_(u'OK' in result) - def test_delete_out_of_range(self): + def test_delete_range_out_of_bounds(self): self.b.current_playlist.playlist = Playlist( tracks=[Track(), Track(), Track(), Track(), Track()]) self.assertEquals(self.b.current_playlist.playlist.length, 5) - result = self.h.handle_request(u'delete "5"') + result = self.h.handle_request(u'delete "5:7"') self.assertEquals(self.b.current_playlist.playlist.length, 5) self.assert_(u'ACK Position out of bounds' in result) From 6dfb658e6bb862a4f05b0a97ac0522f16e838625 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 18:58:33 +0100 Subject: [PATCH 125/341] Add last_modified field to Playlist model --- mopidy/models.py | 13 +++++++++++-- tests/modelstest.py | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 934ca8ac..c79eeab6 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -17,6 +17,7 @@ class ImmutableObject(object): return super(ImmutableObject, self).__setattr__(name, value) raise AttributeError('Object is immutable.') + class Artist(ImmutableObject): """ :param uri: artist URI @@ -169,6 +170,11 @@ class Playlist(ImmutableObject): #: The playlist name. Read-only. name = None + #: The playlist modification time. Read-only. + #: + #: :class:`datetime.datetime`, or :class:`None` if unknown. + last_modified = None + def __init__(self, *args, **kwargs): self._tracks = kwargs.pop('tracks', []) super(Playlist, self).__init__(*args, **kwargs) @@ -202,7 +208,7 @@ class Playlist(ImmutableObject): tracks.append(track.mpd_format(position, search_result)) return tracks - def with_(self, uri=None, name=None, tracks=None): + def with_(self, uri=None, name=None, tracks=None, last_modified=None): """ Create a new playlist object with the given values. The values that are not given are taken from the object the method is called on. @@ -223,4 +229,7 @@ class Playlist(ImmutableObject): name = self.name if tracks is None: tracks = self.tracks - return Playlist(uri=uri, name=name, tracks=tracks) + if last_modified is None: + last_modified = self.last_modified + return Playlist(uri=uri, name=name, tracks=tracks, + last_modified=last_modified) diff --git a/tests/modelstest.py b/tests/modelstest.py index 29911dc7..1fe4bcbe 100644 --- a/tests/modelstest.py +++ b/tests/modelstest.py @@ -137,6 +137,7 @@ class TrackTest(unittest.TestCase): track = Track(artists=[Artist(name=u'ABBA'), Artist(name=u'Beatles')]) self.assertEqual(track.mpd_format_artists(), u'ABBA, Beatles') + class PlaylistTest(unittest.TestCase): def test_uri(self): uri = u'an_uri' @@ -161,6 +162,13 @@ class PlaylistTest(unittest.TestCase): playlist = Playlist(tracks=tracks) self.assertEqual(playlist.length, 3) + def test_last_modified(self): + last_modified = dt.datetime.now() + playlist = Playlist(last_modified=last_modified) + self.assertEqual(playlist.last_modified, last_modified) + self.assertRaises(AttributeError, setattr, playlist, 'last_modified', + None) + def test_mpd_format(self): playlist = Playlist(tracks=[ Track(track_no=1), Track(track_no=2), Track(track_no=3)]) @@ -176,25 +184,46 @@ class PlaylistTest(unittest.TestCase): def test_with_new_uri(self): tracks = [Track()] - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks) + last_modified = dt.datetime.now() + playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + last_modified=last_modified) new_playlist = playlist.with_(uri=u'another uri') self.assertEqual(new_playlist.uri, u'another uri') self.assertEqual(new_playlist.name, u'a name') self.assertEqual(new_playlist.tracks, tracks) + self.assertEqual(new_playlist.last_modified, last_modified) def test_with_new_name(self): tracks = [Track()] - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks) + last_modified = dt.datetime.now() + playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + last_modified=last_modified) new_playlist = playlist.with_(name=u'another name') self.assertEqual(new_playlist.uri, u'an uri') self.assertEqual(new_playlist.name, u'another name') self.assertEqual(new_playlist.tracks, tracks) + self.assertEqual(new_playlist.last_modified, last_modified) def test_with_new_tracks(self): tracks = [Track()] - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks) + last_modified = dt.datetime.now() + playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + last_modified=last_modified) new_tracks = [Track(), Track()] new_playlist = playlist.with_(tracks=new_tracks) self.assertEqual(new_playlist.uri, u'an uri') self.assertEqual(new_playlist.name, u'a name') self.assertEqual(new_playlist.tracks, new_tracks) + self.assertEqual(new_playlist.last_modified, last_modified) + + def test_with_new_last_modified(self): + tracks = [Track()] + last_modified = dt.datetime.now() + new_last_modified = last_modified + dt.timedelta(1) + playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + last_modified=last_modified) + new_playlist = playlist.with_(last_modified=new_last_modified) + self.assertEqual(new_playlist.uri, u'an uri') + self.assertEqual(new_playlist.name, u'a name') + self.assertEqual(new_playlist.tracks, tracks) + self.assertEqual(new_playlist.last_modified, new_last_modified) From 434fbb88530bcb4f111080b920a1face3bc1eaeb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 20:07:11 +0100 Subject: [PATCH 126/341] Add 'Last-Modified' field to _stored_playlists_listplaylists response --- mopidy/mpd/handler.py | 11 ++++++++--- tests/mpd/handlertest.py | 6 ++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index c3f2b220..fb0f5aa5 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -10,6 +10,7 @@ implement our own MPD server which is compatible with the numerous existing `MPD clients `_. """ +import datetime as dt import logging import re import sys @@ -1264,9 +1265,13 @@ class MpdHandler(object): playlist: b Last-Modified: 2010-02-06T02:11:08Z """ - # TODO Add Last-Modified attribute to output - return [u'playlist: %s' % p.name - for p in self.backend.stored_playlists.playlists] + result = [] + for playlist in self.backend.stored_playlists.playlists: + result.append((u'playlist', playlist.name)) + # TODO Remove microseconds and add time zone information + result.append((u'Last-Modified', + (playlist.last_modified or dt.datetime.now()).isoformat())) + return result @handle_pattern(r'^load "(?P[^"]+)"$') def _stored_playlists_load(self, name): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 42d2b1e0..7201be0c 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -1,3 +1,4 @@ +import datetime as dt import unittest from mopidy.backends.dummy import DummyBackend @@ -702,7 +703,12 @@ class StoredPlaylistsHandlerTest(unittest.TestCase): self.assert_(u'ACK Name "name" not found' in result) def test_listplaylists(self): + last_modified = dt.datetime(2001, 3, 17, 13, 41, 17) + self.b.stored_playlists.playlists = [Playlist(name='a', + last_modified=last_modified)] result = self.h.handle_request(u'listplaylists') + self.assert_(u'playlist: a' in result) + self.assert_(u'Last-Modified: 2001-03-17T13:41:17' in result) self.assert_(u'OK' in result) def test_load(self): From 00472ca1bc1ad4eaa8e6747b48873f1d42e00123 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 21:10:53 +0100 Subject: [PATCH 127/341] Implement _current_playlist_move_{range,songpos} --- mopidy/mpd/handler.py | 17 +++++++++++++---- tests/mpd/handlertest.py | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index fb0f5aa5..3e3752d4 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -306,10 +306,8 @@ class MpdHandler(object): """ self.backend.current_playlist.clear() - @handle_pattern(r'^move "(?P\d+)" "(?P\d+)"$') @handle_pattern(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') - def _current_playlist_move(self, songpos=None, - start=None, end=None, to=None): + def _current_playlist_move_range(self, start, to, end=None): """ *musicpd.org, current playlist section:* @@ -318,7 +316,18 @@ class MpdHandler(object): Moves the song at ``FROM`` or range of songs at ``START:END`` to ``TO`` in the playlist. """ - raise MpdNotImplemented # TODO + if end is None: + end = self.backend.current_playlist.playlist.length + start = int(start) + end = int(end) + to = int(to) + self.backend.current_playlist.move(start, end, to) + + @handle_pattern(r'^move "(?P\d+)" "(?P\d+)"$') + def _current_playlist_move_songpos(self, songpos, to): + """See :meth:`_current_playlist_move_range`.""" + songpos = int(songpos) + self._current_playlist_move_range(start=songpos, end=songpos + 1, to=to) @handle_pattern(r'^moveid "(?P\d+)" "(?P\d+)"$') def _current_playlist_moveid(self, songid, to): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 7201be0c..c598a1d4 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -592,16 +592,43 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'ACK Track with ID "0" not found' in result) def test_move_songpos(self): - result = self.h.handle_request(u'move "5" "0"') - self.assert_(u'ACK Not implemented' in result) + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + result = self.h.handle_request(u'move "1" "0"') + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'b') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'f') + self.assert_(u'OK' in result) def test_move_open_range(self): - result = self.h.handle_request(u'move "10:" "0"') - self.assert_(u'ACK Not implemented' in result) + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + result = self.h.handle_request(u'move "2:" "0"') + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'f') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'b') + self.assert_(u'OK' in result) def test_move_closed_range(self): - result = self.h.handle_request(u'move "10:20" "0"') - self.assert_(u'ACK Not implemented' in result) + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + result = self.h.handle_request(u'move "1:3" "0"') + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'b') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'f') + self.assert_(u'OK' in result) def test_moveid(self): result = self.h.handle_request(u'moveid "0" "10"') From 3180d7faf2092a00bd64572f90f2c1d0053b2f58 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 21:15:01 +0100 Subject: [PATCH 128/341] Implement _current_playlist_moveid --- mopidy/mpd/handler.py | 9 +++++++-- tests/mpd/handlertest.py | 13 +++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 3e3752d4..6a79c490 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -327,7 +327,8 @@ class MpdHandler(object): def _current_playlist_move_songpos(self, songpos, to): """See :meth:`_current_playlist_move_range`.""" songpos = int(songpos) - self._current_playlist_move_range(start=songpos, end=songpos + 1, to=to) + to = int(to) + self.backend.current_playlist.move(songpos, songpos + 1, to) @handle_pattern(r'^moveid "(?P\d+)" "(?P\d+)"$') def _current_playlist_moveid(self, songid, to): @@ -340,7 +341,11 @@ class MpdHandler(object): the playlist. If ``TO`` is negative, it is relative to the current song in the playlist (if there is one). """ - raise MpdNotImplemented # TODO + songid = int(songid) + to = int(to) + track = self.backend.current_playlist.get_by_id(songid) + position = self.backend.current_playlist.playlist.tracks.index(track) + self.backend.current_playlist.move(position, position + 1, to) @handle_pattern(r'^playlist$') def _current_playlist_playlist(self): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index c598a1d4..04466190 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -631,8 +631,17 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_moveid(self): - result = self.h.handle_request(u'moveid "0" "10"') - self.assert_(u'ACK Not implemented' in result) + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e', id=137), Track(name='f')])) + result = self.h.handle_request(u'moveid "137" "2"') + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'b') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'f') + self.assert_(u'OK' in result) def test_playlist_returns_same_as_playlistinfo(self): playlist_result = self.h.handle_request(u'playlist') From 2dea0820b2f2b4c2a0424e0689e76981445af402 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 22:00:19 +0100 Subject: [PATCH 129/341] Rename Track.title to Track.name --- mopidy/backends/despotify.py | 2 +- mopidy/backends/libspotify.py | 4 ++-- mopidy/models.py | 10 +++++----- tests/modelstest.py | 14 +++++++------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index ef8f85a6..43a8512d 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -103,7 +103,7 @@ class DespotifyTranslator(object): date = None return Track( uri=spotify_track.get_uri(), - title=spotify_track.title.decode(ENCODING), + name=spotify_track.title.decode(ENCODING), artists=[self.to_mopidy_artist(a) for a in spotify_track.artists], album=self.to_mopidy_album(spotify_track.album), track_no=spotify_track.tracknumber, diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 0d0350cf..bfe2e4db 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -126,11 +126,11 @@ class LibspotifyTranslator(object): def to_mopidy_track(self, spotify_track): if not spotify_track.is_loaded(): - return Track(title=u'[loading...]') + return Track(name=u'[loading...]') uri = str(Link.from_track(spotify_track, 0)) return Track( uri=uri, - title=spotify_track.name().decode(ENCODING), + name=spotify_track.name().decode(ENCODING), artists=[self.to_mopidy_artist(a) for a in spotify_track.artists()], album=self.to_mopidy_album(spotify_track.album()), track_no=spotify_track.index(), diff --git a/mopidy/models.py b/mopidy/models.py index c79eeab6..951ada6d 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -68,8 +68,8 @@ class Track(ImmutableObject): """ :param uri: track URI :type uri: string - :param title: track title - :type title: string + :param name: track name + :type name: string :param artists: track artists :type artists: list of :class:`Artist` :param album: track album @@ -89,8 +89,8 @@ class Track(ImmutableObject): #: The track URI. Read-only. uri = None - #: The track title. Read-only. - title = None + #: The track name. Read-only. + name = None #: The track :class:`Album`. Read-only. album = None @@ -131,7 +131,7 @@ class Track(ImmutableObject): ('file', self.uri or ''), ('Time', self.length and (self.length // 1000) or 0), ('Artist', self.mpd_format_artists()), - ('Title', self.title or ''), + ('Title', self.name or ''), ('Album', self.album and self.album.name or ''), ('Date', self.date or ''), ] diff --git a/tests/modelstest.py b/tests/modelstest.py index 1fe4bcbe..6e4717bf 100644 --- a/tests/modelstest.py +++ b/tests/modelstest.py @@ -50,11 +50,11 @@ class TrackTest(unittest.TestCase): self.assertEqual(track.uri, uri) self.assertRaises(AttributeError, setattr, track, 'uri', None) - def test_title(self): - title = u'a title' - track = Track(title=title) - self.assertEqual(track.title, title) - self.assertRaises(AttributeError, setattr, track, 'title', None) + def test_name(self): + name = u'a name' + track = Track(name=name) + self.assertEqual(track.name, name) + self.assertRaises(AttributeError, setattr, track, 'name', None) def test_artists(self): artists = [Artist(), Artist()] @@ -115,7 +115,7 @@ class TrackTest(unittest.TestCase): track = Track( uri=u'a uri', artists=[Artist(name=u'an artist')], - title=u'a title', + name=u'a name', album=Album(name=u'an album', num_tracks=13), track_no=7, date=dt.date(1977, 1, 1), @@ -126,7 +126,7 @@ class TrackTest(unittest.TestCase): self.assert_(('file', 'a uri') in result) self.assert_(('Time', 137) in result) self.assert_(('Artist', 'an artist') in result) - self.assert_(('Title', 'a title') in result) + self.assert_(('Title', 'a name') in result) self.assert_(('Album', 'an album') in result) self.assert_(('Track', '7/13') in result) self.assert_(('Date', dt.date(1977, 1, 1)) in result) From e3b407c7092e81c6c0d142b9792a38664f12bbfa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 22:15:15 +0100 Subject: [PATCH 130/341] Implement _current_playlist_playlistid when songid argument is given --- mopidy/mpd/handler.py | 13 ++++++++++--- tests/mpd/handlertest.py | 20 +++++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 6a79c490..5c5dec67 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -373,7 +373,7 @@ class MpdHandler(object): """ raise MpdNotImplemented # TODO - @handle_pattern(r'^playlistid( "(?P\S+)")*$') + @handle_pattern(r'^playlistid( "(?P\d+)")*$') def _current_playlist_playlistid(self, songid=None): """ *musicpd.org, current playlist section:* @@ -383,8 +383,15 @@ class MpdHandler(object): Displays a list of songs in the playlist. ``SONGID`` is optional and specifies a single song to display info for. """ - # TODO Limit selection to songid - return self.backend.current_playlist.playlist.mpd_format() + if songid is not None: + try: + songid = int(songid) + track = self.backend.current_playlist.get_by_id(songid) + return track.mpd_format() + except KeyError, e: + raise MpdAckError(e) + else: + return self.backend.current_playlist.playlist.mpd_format() @handle_pattern(r'^playlistinfo$') @handle_pattern(r'^playlistinfo "(?P\d+)"$') diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 04466190..f87e73f1 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -653,13 +653,31 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'ACK Not implemented' in result) def test_playlistid_without_songid(self): + self.b.current_playlist.load(Playlist( + tracks=[Track(name='a', id=33), Track(name='b', id=38)])) result = self.h.handle_request(u'playlistid') + self.assert_(u'Title: a' in result) + self.assert_(u'Id: 33' in result) + self.assert_(u'Title: b' in result) + self.assert_(u'Id: 38' in result) self.assert_(u'OK' in result) def test_playlistid_with_songid(self): - result = self.h.handle_request(u'playlistid "10"') + self.b.current_playlist.load(Playlist( + tracks=[Track(name='a', id=33), Track(name='b', id=38)])) + result = self.h.handle_request(u'playlistid "38"') + self.assert_(u'Title: a' not in result) + self.assert_(u'Id: 33' not in result) + self.assert_(u'Title: b' in result) + self.assert_(u'Id: 38' in result) self.assert_(u'OK' in result) + def test_playlistid_with_not_existing_songid_fails(self): + self.b.current_playlist.load(Playlist( + tracks=[Track(name='a', id=33), Track(name='b', id=38)])) + result = self.h.handle_request(u'playlistid "25"') + self.assert_(u'ACK Track with ID "25" not found' in result) + def test_playlistinfo_without_songpos_or_range(self): result = self.h.handle_request(u'playlistinfo') self.assert_(u'OK' in result) From 1a7fbe671bc7ee2210d2f21efb644b9d37c1ea5e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 22:26:18 +0100 Subject: [PATCH 131/341] Implement _current_playlist_plchangesposid --- mopidy/mpd/handler.py | 10 +++++++++- tests/mpd/handlertest.py | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 5c5dec67..128696ab 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -443,6 +443,7 @@ class MpdHandler(object): To detect songs that were deleted at the end of the playlist, use ``playlistlength`` returned by status command. """ + # XXX Naive implementation that returns all tracks as changed if int(version) < self.backend.current_playlist.version: return self.backend.current_playlist.playlist.mpd_format() @@ -460,7 +461,14 @@ class MpdHandler(object): To detect songs that were deleted at the end of the playlist, use ``playlistlength`` returned by status command. """ - raise MpdNotImplemented # TODO + # XXX Naive implementation that returns all tracks as changed + if int(version) != self.backend.current_playlist.version: + result = [] + for position, track in enumerate( + self.backend.current_playlist.playlist.tracks): + result.append((u'cpos', position)) + result.append((u'Id', track.id)) + return result @handle_pattern(r'^shuffle$') @handle_pattern(r'^shuffle "(?P\d+):(?P\d+)*"$') diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index f87e73f1..05911eb6 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -699,12 +699,25 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'ACK Not implemented' in result) def test_plchanges(self): + self.b.current_playlist.load(Playlist( + tracks=[Track(name='a'), Track(name='b'), Track(name='c')])) result = self.h.handle_request(u'plchanges "0"') + self.assert_(u'Title: a' in result) + self.assert_(u'Title: b' in result) + self.assert_(u'Title: c' in result) self.assert_(u'OK' in result) def test_plchangesposid(self): + self.b.current_playlist.load(Playlist( + tracks=[Track(id=11), Track(id=12), Track(id=13)])) result = self.h.handle_request(u'plchangesposid "0"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'cpos: 0' in result) + self.assert_(u'Id: 11' in result) + self.assert_(u'cpos: 2' in result) + self.assert_(u'Id: 12' in result) + self.assert_(u'cpos: 2' in result) + self.assert_(u'Id: 13' in result) + self.assert_(u'OK' in result) def test_shuffle_without_range(self): result = self.h.handle_request(u'shuffle') From c8ee771ebc5f2793b92452abc4c4dac63406b4a7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 22:56:42 +0100 Subject: [PATCH 132/341] Implement _current_playlist_shuffle --- mopidy/mpd/handler.py | 6 +++++- tests/mpd/handlertest.py | 33 ++++++++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 128696ab..154ce373 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -481,7 +481,11 @@ class MpdHandler(object): Shuffles the current playlist. ``START:END`` is optional and specifies a range of songs. """ - raise MpdNotImplemented # TODO + if start is not None: + start = int(start) + if end is not None: + end = int(end) + self.backend.current_playlist.shuffle(start, end) @handle_pattern(r'^swap "(?P\d+)" "(?P\d+)"$') def _current_playlist_swap(self, songpos1, songpos2): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 05911eb6..108341f5 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -720,16 +720,39 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_shuffle_without_range(self): + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + self.assertEquals(self.b.current_playlist.version, 2) result = self.h.handle_request(u'shuffle') - self.assert_(u'ACK Not implemented' in result) + self.assertEquals(self.b.current_playlist.version, 3) + self.assert_(u'OK' in result) def test_shuffle_with_open_range(self): - result = self.h.handle_request(u'shuffle "10:"') - self.assert_(u'ACK Not implemented' in result) + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + self.assertEquals(self.b.current_playlist.version, 2) + result = self.h.handle_request(u'shuffle "4:"') + self.assertEquals(self.b.current_playlist.version, 3) + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'b') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'd') + self.assert_(u'OK' in result) def test_shuffle_with_closed_range(self): - result = self.h.handle_request(u'shuffle "10:20"') - self.assert_(u'ACK Not implemented' in result) + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + self.assertEquals(self.b.current_playlist.version, 2) + result = self.h.handle_request(u'shuffle "1:3"') + self.assertEquals(self.b.current_playlist.version, 3) + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'f') + self.assert_(u'OK' in result) def test_swap(self): result = self.h.handle_request(u'swap "10" "20"') From b89a6fc46d824bd3f8dc79d7d0e1b4984e80983d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 23:07:44 +0100 Subject: [PATCH 133/341] Implement _current_playlist_swap --- mopidy/mpd/handler.py | 12 +++++++++++- tests/mpd/handlertest.py | 13 +++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 154ce373..fbae54c1 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -496,7 +496,17 @@ class MpdHandler(object): Swaps the positions of ``SONG1`` and ``SONG2``. """ - raise MpdNotImplemented # TODO + songpos1 = int(songpos1) + songpos2 = int(songpos2) + playlist = self.backend.current_playlist.playlist + tracks = playlist.tracks + song1 = tracks[songpos1] + song2 = tracks[songpos2] + del tracks[songpos1] + tracks.insert(songpos1, song2) + del tracks[songpos2] + tracks.insert(songpos2, song1) + self.backend.current_playlist.load(playlist.with_(tracks=tracks)) @handle_pattern(r'^swapid "(?P\d+)" "(?P\d+)"$') def _current_playlist_swapid(self, songid1, songid2): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 108341f5..d590becc 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -755,8 +755,17 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_swap(self): - result = self.h.handle_request(u'swap "10" "20"') - self.assert_(u'ACK Not implemented' in result) + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + result = self.h.handle_request(u'swap "1" "4"') + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'b') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'f') + self.assert_(u'OK' in result) def test_swapid(self): result = self.h.handle_request(u'swapid "10" "20"') From d0916334d4db1ef1c546af81d85ecb0b76578321 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 23:10:54 +0100 Subject: [PATCH 134/341] Implement _current_playlist_swapid --- mopidy/mpd/handler.py | 8 +++++++- tests/mpd/handlertest.py | 13 +++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index fbae54c1..1331babf 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -517,7 +517,13 @@ class MpdHandler(object): Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). """ - raise MpdNotImplemented # TODO + songid1 = int(songid1) + songid2 = int(songid2) + song1 = self.backend.current_playlist.get_by_id(songid1) + song2 = self.backend.current_playlist.get_by_id(songid2) + songpos1 = self.backend.current_playlist.playlist.tracks.index(song1) + songpos2 = self.backend.current_playlist.playlist.tracks.index(song2) + self._current_playlist_swap(songpos1, songpos2) @handle_pattern(r'^$') def _empty(self): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index d590becc..5e294abc 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -768,8 +768,17 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_swapid(self): - result = self.h.handle_request(u'swapid "10" "20"') - self.assert_(u'ACK Not implemented' in result) + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b', id=13), Track(name='c'), + Track(name='d'), Track(name='e', id=29), Track(name='f')])) + result = self.h.handle_request(u'swapid "13" "29"') + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'b') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'f') + self.assert_(u'OK' in result) class StoredPlaylistsHandlerTest(unittest.TestCase): From 68810d01b71c42fdf15630a370e161c141230b54 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 23:16:06 +0100 Subject: [PATCH 135/341] Implement _playback_consume --- mopidy/mpd/handler.py | 7 +++---- tests/mpd/handlertest.py | 6 ++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 1331babf..c8be7ac4 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -689,11 +689,10 @@ class MpdHandler(object): 1. When consume is activated, each song played is removed from playlist. """ - state = int(state) - if state: - raise MpdNotImplemented # TODO + if int(state): + self.backend.playback.consume = True else: - raise MpdNotImplemented # TODO + self.backend.playback.consume = False @handle_pattern(r'^crossfade "(?P\d+)"$') def _playback_crossfade(self, seconds): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 5e294abc..64064d7d 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -307,11 +307,13 @@ class PlaybackOptionsHandlerTest(unittest.TestCase): def test_consume_off(self): result = self.h.handle_request(u'consume "0"') - self.assert_(u'ACK Not implemented' in result) + self.assertFalse(self.b.playback.consume) + self.assert_(u'OK' in result) def test_consume_on(self): result = self.h.handle_request(u'consume "1"') - self.assert_(u'ACK Not implemented' in result) + self.assertTrue(self.b.playback.consume) + self.assert_(u'OK' in result) def test_crossfade(self): result = self.h.handle_request(u'crossfade "10"') From a4f640e085911577d1ece7f972d340d2270a0f1f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 23:16:27 +0100 Subject: [PATCH 136/341] Add empty line between methods --- mopidy/mpd/handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index c8be7ac4..bd3cb312 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -738,6 +738,7 @@ class MpdHandler(object): without arguments. """ return self.backend.playback.play() + @handle_pattern(r'^playid "(?P\d+)"$') def _playback_playid(self, songid): """ From ede1b7d5ab6a6387de5d90784171ffb4dccd5fd7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 23:17:45 +0100 Subject: [PATCH 137/341] Implement _playback_random --- mopidy/mpd/handler.py | 7 +++---- tests/mpd/handlertest.py | 6 ++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index bd3cb312..493bf42c 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -791,11 +791,10 @@ class MpdHandler(object): Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ - state = int(state) - if state: - raise MpdNotImplemented # TODO + if int(state): + self.backend.playback.random = True else: - raise MpdNotImplemented # TODO + self.backend.playback.random = False @handle_pattern(r'^repeat "(?P[01])"$') def _playback_repeat(self, state): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 64064d7d..1326f826 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -321,11 +321,13 @@ class PlaybackOptionsHandlerTest(unittest.TestCase): def test_random_off(self): result = self.h.handle_request(u'random "0"') - self.assert_(u'ACK Not implemented' in result) + self.assertFalse(self.b.playback.random) + self.assert_(u'OK' in result) def test_random_on(self): result = self.h.handle_request(u'random "1"') - self.assert_(u'ACK Not implemented' in result) + self.assertTrue(self.b.playback.random) + self.assert_(u'OK' in result) def test_repeat_off(self): result = self.h.handle_request(u'repeat "0"') From 2d2e7c24882458f927eeaf13eccc705746e17cba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 23:18:35 +0100 Subject: [PATCH 138/341] Implement _playback_repeat --- mopidy/mpd/handler.py | 7 +++---- tests/mpd/handlertest.py | 6 ++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 493bf42c..ef5e55d4 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -805,11 +805,10 @@ class MpdHandler(object): Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ - state = int(state) - if state: - raise MpdNotImplemented # TODO + if int(state): + self.backend.playback.repeat = True else: - raise MpdNotImplemented # TODO + self.backend.playback.repeat = False @handle_pattern(r'^replay_gain_mode "(?P(off|track|album))"$') def _playback_replay_gain_mode(self, mode): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 1326f826..6f3c7bf2 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -331,11 +331,13 @@ class PlaybackOptionsHandlerTest(unittest.TestCase): def test_repeat_off(self): result = self.h.handle_request(u'repeat "0"') - self.assert_(u'ACK Not implemented' in result) + self.assertFalse(self.b.playback.repeat) + self.assert_(u'OK' in result) def test_repeat_on(self): result = self.h.handle_request(u'repeat "1"') - self.assert_(u'ACK Not implemented' in result) + self.assertTrue(self.b.playback.repeat) + self.assert_(u'OK' in result) def test_setvol_below_min(self): result = self.h.handle_request(u'setvol "-10"') From 04ceb83c9445cb9dd1cfd2674135a9e54c82de73 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 23:22:12 +0100 Subject: [PATCH 139/341] Implement _playback_single and add 'single' field to BasePlaybackController --- mopidy/backends/__init__.py | 6 ++++++ mopidy/mpd/handler.py | 7 +++---- tests/mpd/handlertest.py | 6 ++++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index e800f6b2..f8eb8845 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -249,6 +249,12 @@ class BasePlaybackController(object): #: The current track is played once. repeat = False + #: :class:`True` + #: Playback is stopped after current song, unless in repeat mode. + #: :class:`False` + #: Playback continues after current song. + single = False + def __init__(self, backend, mixer=alsaaudio.Mixer): self.backend = backend self._state = self.STOPPED diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index ef5e55d4..885aeb18 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -888,11 +888,10 @@ class MpdHandler(object): single is activated, playback is stopped after current song, or song is repeated if the ``repeat`` mode is enabled. """ - state = int(state) - if state: - raise MpdNotImplemented # TODO + if int(state): + self.backend.playback.single = True else: - raise MpdNotImplemented # TODO + self.backend.playback.single = False @handle_pattern(r'^stop$') def _playback_stop(self): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 6f3c7bf2..27a57c90 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -371,11 +371,13 @@ class PlaybackOptionsHandlerTest(unittest.TestCase): def test_single_off(self): result = self.h.handle_request(u'single "0"') - self.assert_(u'ACK Not implemented' in result) + self.assertFalse(self.b.playback.single) + self.assert_(u'OK' in result) def test_single_on(self): result = self.h.handle_request(u'single "1"') - self.assert_(u'ACK Not implemented' in result) + self.assertTrue(self.b.playback.single) + self.assert_(u'OK' in result) def test_replay_gain_mode_off(self): result = self.h.handle_request(u'replay_gain_mode "off"') From 5852672ae1ef7a85299e3b4ce3ac76719066595f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 23:23:29 +0100 Subject: [PATCH 140/341] Remove trailing whitespace --- mopidy/mpd/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 885aeb18..922a251d 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -1059,7 +1059,7 @@ class MpdHandler(object): 'db_playtime': 0, # TODO 'db_update': 0, # TODO 'playtime': 0, # TODO - } + } @handle_pattern(r'^status$') def _status_status(self): From 50e3c837c5279434b7d7862e21186616d73f1000 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Feb 2010 23:24:48 +0100 Subject: [PATCH 141/341] Implement __status_status_single --- mopidy/mpd/handler.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 922a251d..7e50359f 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -1131,19 +1131,13 @@ class MpdHandler(object): return self.backend.current_playlist.version def __status_status_random(self): - if self.backend.playback.random: - return 1 - else: - return 0 + return int(self.backend.playback.random) def __status_status_repeat(self): - if self.backend.playback.repeat: - return 1 - else: - return 0 + return int(self.backend.playback.repeat) def __status_status_single(self): - return 0 # TODO + return int(self.backend.playback.single) def __status_status_songid(self): if self.backend.playback.current_track.id is not None: From c9475b34a2a9bf8e391e1b6800b70ac1ee87f59a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 4 Mar 2010 19:08:35 +0100 Subject: [PATCH 142/341] Python 2.6 is needed for __main__.py and @x.setter support --- docs/installation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation.rst b/docs/installation.rst index 4f15f101..dc495582 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -13,7 +13,7 @@ guides here. Dependencies ============ -* Python >= 2.5 +* Python >= 2.6 * pyalsaaudio >= 0.2 (Debian/Ubuntu: python-alsaaudio) * Dependencies for at least one Mopidy backend: From b641a47c6615ff98070ec523149de14640fc9a7e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 18:22:46 +0100 Subject: [PATCH 143/341] Fix 'time' and 'elapsed' in status output --- mopidy/backends/__init__.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index f8eb8845..692185ca 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -340,7 +340,8 @@ class BasePlaybackController(object): def time_position(self): """Time position in milliseconds.""" if self.state == self.PLAYING: - time_since_started = int(time.time()) - self._play_time_started + time_since_started = (self._current_wall_time - + self._play_time_started) return self._play_time_accumulated + time_since_started elif self.state == self.PAUSED: return self._play_time_accumulated @@ -349,14 +350,18 @@ class BasePlaybackController(object): def _play_time_start(self): self._play_time_accumulated = 0 - self._play_time_started = int(time.time()) + self._play_time_started = self._current_wall_time def _play_time_pause(self): - time_since_started = int(time.time()) - self._play_time_started + time_since_started = self._current_wall_time - self._play_time_started self._play_time_accumulated += time_since_started def _play_time_resume(self): - self._play_time_started = int(time.time()) + self._play_time_started = self._current_wall_time + + @property + def _current_wall_time(self): + return int(time.time() * 1000) @property def volume(self): From ed39840c679cc29ace5cfceb40b1ee74e8f095cc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 18:23:16 +0100 Subject: [PATCH 144/341] despotify: Add library track lookup Adding track from library search result to playlist now works. --- mopidy/backends/despotify.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 43a8512d..06159db0 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -36,6 +36,10 @@ class DespotifyCurrentPlaylistController(BaseCurrentPlaylistController): class DespotifyLibraryController(BaseLibraryController): + def lookup(self, uri): + track = self.backend.spotify.lookup(uri.encode(ENCODING)) + return self.backend.translate.to_mopidy_track(track) + def search(self, type, what): query = u'%s:%s' % (type, what) result = self.backend.spotify.search(query.encode(ENCODING)) From bc1167ba063e6bb12aa39964ad1be048b428a15c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 19:27:13 +0100 Subject: [PATCH 145/341] Return 'positive' but empty results on 'count' and 'list' --- mopidy/mpd/handler.py | 4 ++-- tests/mpd/handlertest.py | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 7e50359f..d0638889 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -540,7 +540,7 @@ class MpdHandler(object): Counts the number of songs and their total playtime in the db matching ``TAG`` exactly. """ - raise MpdNotImplemented # TODO + return [('songs', 0), ('playtime', 0)] # TODO @handle_pattern(r'^find "(?P(album|artist|title))" ' r'"(?P[^"]+)"$') @@ -588,7 +588,7 @@ class MpdHandler(object): ``ARTIST`` is an optional parameter when type is ``album``, this specifies to list albums by an artist. """ - raise MpdNotImplemented # TODO + pass # TODO @handle_pattern(r'^listall "(?P[^"]+)"') def _music_db_listall(self, uri): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 27a57c90..7eee480f 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -868,7 +868,9 @@ class MusicDatabaseHandlerTest(unittest.TestCase): def test_count(self): result = self.h.handle_request(u'count "tag" "needle"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'songs: 0' in result) + self.assert_(u'playtime: 0' in result) + self.assert_(u'OK' in result) def test_find_album(self): result = self.h.handle_request(u'find "album" "what"') @@ -895,7 +897,7 @@ class MusicDatabaseHandlerTest(unittest.TestCase): def test_list_artist(self): result = self.h.handle_request(u'list "artist"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) def test_list_artist_with_artist_should_fail(self): try: @@ -906,11 +908,11 @@ class MusicDatabaseHandlerTest(unittest.TestCase): def test_list_album_without_artist(self): result = self.h.handle_request(u'list "album"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) def test_list_album_with_artist(self): result = self.h.handle_request(u'list "album" "anartist"') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) def test_listall(self): result = self.h.handle_request(u'listall "file:///dev/urandom"') From 6aacf8f6ce2c9a929ec9cfb410948b5061da1a04 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 19:28:01 +0100 Subject: [PATCH 146/341] 'needle' argument to 'count' may be empty --- mopidy/mpd/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index d0638889..15a7258e 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -530,7 +530,7 @@ class MpdHandler(object): """The original MPD server returns ``OK`` on an empty request.``""" pass - @handle_pattern(r'^count "(?P[^"]+)" "(?P[^"]+)"$') + @handle_pattern(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def _music_db_count(self, tag, needle): """ *musicpd.org, music database section:* From 86ddffc8b09dfb6aa66ead97d4b3c5a90cc7a3e2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 19:29:17 +0100 Subject: [PATCH 147/341] GMPC does not add " around the type argument, and uses the type any too. --- mopidy/mpd/handler.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 15a7258e..ccfd4f5a 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -542,6 +542,8 @@ class MpdHandler(object): """ return [('songs', 0), ('playtime', 0)] # TODO + @handle_pattern(r'^find (?P(album|artist|title)) ' + r'"(?P[^"]+)"$') @handle_pattern(r'^find "(?P(album|artist|title))" ' r'"(?P[^"]+)"$') def _music_db_find(self, type, what): @@ -552,6 +554,8 @@ class MpdHandler(object): Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be ``album``, ``artist``, or ``title``. ``WHAT`` is what to find. + + GMPC does not add ``"`` around the type argument. """ if type == u'title': type = u'track' @@ -642,7 +646,9 @@ class MpdHandler(object): """ return self._music_db_update(uri, rescan_unmodified_files=True) - @handle_pattern(r'^search "(?P(album|artist|filename|title))" ' + @handle_pattern(r'^search (?P(album|artist|filename|title|any)) ' + r'"(?P[^"]+)"$') + @handle_pattern(r'^search "(?P(album|artist|filename|title|any))" ' r'"(?P[^"]+)"$') def _music_db_search(self, type, what): """ @@ -653,6 +659,9 @@ class MpdHandler(object): Searches for any song that contains ``WHAT``. ``TYPE`` can be ``title``, ``artist``, ``album`` or ``filename``. Search is not case sensitive. + + GMPC does not add ``"`` around the type argument, and uses the type + ``any`` too. """ if type == u'title': type = u'track' From b6a130990404507dc42f82558ee9491673125697 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 19:30:15 +0100 Subject: [PATCH 148/341] Doc syntax fix --- mopidy/mpd/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index ccfd4f5a..1fc543bf 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -587,7 +587,7 @@ class MpdHandler(object): ``list {TYPE} [ARTIST]`` Lists all tags of the specified type. ``TYPE`` should be ``album`` - or artist. + or ``artist``. ``ARTIST`` is an optional parameter when type is ``album``, this specifies to list albums by an artist. From 290f3adc147a6e43c6b854446727234af7e19053 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 19:52:47 +0100 Subject: [PATCH 149/341] Add tests for 'find' and 'search' without quotes --- tests/mpd/handlertest.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 7eee480f..b5b93998 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -876,14 +876,26 @@ class MusicDatabaseHandlerTest(unittest.TestCase): result = self.h.handle_request(u'find "album" "what"') self.assert_(u'OK' in result) + def test_find_album_without_quotes(self): + result = self.h.handle_request(u'find album "what"') + self.assert_(u'OK' in result) + def test_find_artist(self): result = self.h.handle_request(u'find "artist" "what"') self.assert_(u'OK' in result) + def test_find_artist_without_quotes(self): + result = self.h.handle_request(u'find artist "what"') + self.assert_(u'OK' in result) + def test_find_title(self): result = self.h.handle_request(u'find "title" "what"') self.assert_(u'OK' in result) + def test_find_title_without_quotes(self): + result = self.h.handle_request(u'find title "what"') + self.assert_(u'OK' in result) + def test_find_else_should_fail(self): try: result = self.h.handle_request(u'find "somethingelse" "what"') @@ -940,18 +952,42 @@ class MusicDatabaseHandlerTest(unittest.TestCase): result = self.h.handle_request(u'search "album" "analbum"') self.assert_(u'OK' in result) + def test_search_album_without_quotes(self): + result = self.h.handle_request(u'search album "analbum"') + self.assert_(u'OK' in result) + def test_search_artist(self): result = self.h.handle_request(u'search "artist" "anartist"') self.assert_(u'OK' in result) + def test_search_artist_without_quotes(self): + result = self.h.handle_request(u'search artist "anartist"') + self.assert_(u'OK' in result) + def test_search_filename(self): result = self.h.handle_request(u'search "filename" "afilename"') self.assert_(u'OK' in result) + def test_search_filename_without_quotes(self): + result = self.h.handle_request(u'search filename "afilename"') + self.assert_(u'OK' in result) + def test_search_title(self): result = self.h.handle_request(u'search "title" "atitle"') self.assert_(u'OK' in result) + def test_search_title_without_quotes(self): + result = self.h.handle_request(u'search title "atitle"') + self.assert_(u'OK' in result) + + def test_search_any(self): + result = self.h.handle_request(u'search "any" "anything"') + self.assert_(u'OK' in result) + + def test_search_any_without_quotes(self): + result = self.h.handle_request(u'search any "anything"') + self.assert_(u'OK' in result) + def test_search_else_should_fail(self): try: result = self.h.handle_request(u'search "sometype" "something"') From e0b65b96033890670b54a7bc9cf6a1b8600f700b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 19:53:49 +0100 Subject: [PATCH 150/341] Add support for 'any' type to search in all backends. Search works in GMPC :-D --- mopidy/backends/__init__.py | 2 +- mopidy/backends/despotify.py | 10 ++++++++-- mopidy/backends/libspotify.py | 5 ++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 692185ca..55da11c2 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -204,7 +204,7 @@ class BaseLibraryController(object): """ Search the library for tracks where ``type`` contains ``query``. - :param type: 'track', 'artist', 'album', or 'uri' + :param type: 'track', 'artist', 'album', 'uri', and 'any' :type type: string :param query: the search query :type query: string diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 06159db0..e8a78752 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -41,9 +41,15 @@ class DespotifyLibraryController(BaseLibraryController): return self.backend.translate.to_mopidy_track(track) def search(self, type, what): - query = u'%s:%s' % (type, what) + if type == u'track': + type = u'title' + if type == u'any': + query = what + else: + query = u'%s:%s' % (type, what) result = self.backend.spotify.search(query.encode(ENCODING)) - if result is None: + if (result is None or result.playlist.tracks[0].get_uri() == + 'spotify:track:0000000000000000000000'): return Playlist() return self.backend.translate.to_mopidy_playlist(result.playlist) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index bfe2e4db..8a2218b8 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -48,7 +48,10 @@ class LibspotifyLibraryController(BaseLibraryController): # FIXME When searching while playing music, this is really slow, like # 12-14s between querying and getting results. self._search_results_received.clear() - query = u'%s:%s' % (type, what) + if type is u'any': + query = what + else: + query = u'%s:%s' % (type, what) def callback(results, userdata): logger.debug(u'Search results received') self._search_results = results From 7838d7ceb5ee0b7de1505abbf0d02c533c3e595e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 21:11:34 +0100 Subject: [PATCH 151/341] Fix 'urlhandlers' output --- mopidy/mpd/handler.py | 2 +- tests/mpd/handlertest.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 1fc543bf..f1f429af 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -974,7 +974,7 @@ class MpdHandler(object): Gets a list of available URL handlers. """ - return self.backend.uri_handlers + return [(u'handler', uri) for uri in self.backend.uri_handlers] @handle_pattern(r'^clearerror$') def _status_clearerror(self): diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index b5b93998..fa3a2143 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -1120,5 +1120,4 @@ class ReflectionHandlerTest(unittest.TestCase): def test_urlhandlers(self): result = self.h.handle_request(u'urlhandlers') self.assert_(u'OK' in result) - result = result[0] - self.assert_('dummy:' in result) + self.assert_(u'handler: dummy:' in result) From 0af7c52544443c05b6e7e2d71fcda2c15562a08b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 22:37:59 +0100 Subject: [PATCH 152/341] Document how GMPC does multi-word search --- mopidy/mpd/handler.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index f1f429af..669fe399 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -555,7 +555,9 @@ class MpdHandler(object): Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be ``album``, ``artist``, or ``title``. ``WHAT`` is what to find. - GMPC does not add ``"`` around the type argument. + *GMPC:* + + - does not add quotes around the type argument. """ if type == u'title': type = u'track' @@ -660,9 +662,15 @@ class MpdHandler(object): ``title``, ``artist``, ``album`` or ``filename``. Search is not case sensitive. - GMPC does not add ``"`` around the type argument, and uses the type - ``any`` too. + *GMPC:* + + - does not add quotes around the type argument. + - uses the undocumented type ``any``. + - searches for multiple words like this:: + + search any "foo" any "bar" any "baz" """ + # TODO Support GMPC multi-word search if type == u'title': type = u'track' return self.backend.library.search(type, what).mpd_format( From 523216d0fd9a04c19f396e09d4b77ec20df9c932 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 23:48:50 +0100 Subject: [PATCH 153/341] Add mixer API and rewrite ALSA mixer hack --- docs/api/mixers.rst | 8 ++++++ docs/installation.rst | 5 ++++ mopidy/__main__.py | 8 +++--- mopidy/backends/__init__.py | 15 +++++++----- mopidy/backends/despotify.py | 3 ++- mopidy/backends/dummy.py | 14 +++-------- mopidy/backends/libspotify.py | 3 ++- mopidy/mixers/__init__.py | 35 ++++++++++++++++++++++++++ mopidy/mixers/alsa.py | 13 ++++++++++ mopidy/mixers/dummy.py | 11 +++++++++ mopidy/settings/default.py | 6 ++++- tests/__main__.py | 1 + tests/mixers/dummytest.py | 26 ++++++++++++++++++++ tests/mpd/handlertest.py | 46 ++++++++++++++++++++++++----------- 14 files changed, 156 insertions(+), 38 deletions(-) create mode 100644 docs/api/mixers.rst create mode 100644 mopidy/mixers/__init__.py create mode 100644 mopidy/mixers/alsa.py create mode 100644 mopidy/mixers/dummy.py create mode 100644 tests/mixers/dummytest.py diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst new file mode 100644 index 00000000..0bf94237 --- /dev/null +++ b/docs/api/mixers.rst @@ -0,0 +1,8 @@ +******************************** +:mod:`mopidy.mixer` -- Mixer API +******************************** + +.. automodule:: mopidy.mixers + :synopsis: Sound mixer interface. + :members: + :undoc-members: diff --git a/docs/installation.rst b/docs/installation.rst index dc495582..f0aca702 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -160,6 +160,11 @@ libspotify backend, copy the Spotify application key to BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',) +*OS X:* The default mixer does not work on OS X, so you must change to a dummy +mixer:: + + MIXER = u'mopidy.mixers.dummy.DummyMixer' + For a full list of available settings, see :mod:`mopidy.settings.default`. diff --git a/mopidy/__main__.py b/mopidy/__main__.py index edc5c808..2fb51f4e 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -14,7 +14,8 @@ logger = logging.getLogger('mopidy') def main(): _setup_logging(2) - backend = _get_backend(settings.BACKENDS[0]) + mixer = _get_class(settings.MIXER)() + backend = _get_class(settings.BACKENDS[0])(mixer=mixer) MpdServer(backend=backend) asyncore.loop() @@ -30,14 +31,13 @@ def _setup_logging(verbosity_level): level=level, ) -def _get_backend(name): +def _get_class(name): module_name = name[:name.rindex('.')] class_name = name[name.rindex('.') + 1:] logger.info('Loading: %s from %s', class_name, module_name) module = __import__(module_name, globals(), locals(), [class_name], -1) class_object = getattr(module, class_name) - instance = class_object() - return instance + return class_object if __name__ == '__main__': try: diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 55da11c2..93b74498 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -3,13 +3,14 @@ import logging import random import time -import alsaaudio - from mopidy.models import Playlist logger = logging.getLogger('backends.base') class BaseBackend(object): + def __init__(self, mixer=None): + self.mixer = mixer + #: The current playlist controller. An instance of #: :class:`BaseCurrentPlaylistController`. current_playlist = None @@ -17,6 +18,9 @@ class BaseBackend(object): #: The library controller. An instance of :class:`BaseLibraryController`. library = None + #: The sound mixer. An instance of :class:`mopidy.mixers.BaseMixer`. + mixer = None + #: The playback controller. An instance of :class:`BasePlaybackController`. playback = None @@ -255,10 +259,9 @@ class BasePlaybackController(object): #: Playback continues after current song. single = False - def __init__(self, backend, mixer=alsaaudio.Mixer): + def __init__(self, backend): self.backend = backend self._state = self.STOPPED - self._mixer = mixer() @property def next_track(self): @@ -370,11 +373,11 @@ class BasePlaybackController(object): :class:`None` if unknown. """ - return self._mixer.getvolume()[0] + return self.backend.mixer.volume @volume.setter def volume(self, volume): - self._mixer.setvolume(volume) + self.backend.mixer.volume = volume def new_playlist_loaded_callback(self): """Tell the playback controller that a new playlist has been loaded.""" diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index e8a78752..349f3a9a 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -14,7 +14,8 @@ logger = logging.getLogger(u'backends.despotify') ENCODING = 'utf-8' class DespotifyBackend(BaseBackend): - def __init__(self): + def __init__(self, *args, **kwargs): + super(DespotifyBackend, self).__init__(*args, **kwargs) self.current_playlist = DespotifyCurrentPlaylistController(backend=self) self.library = DespotifyLibraryController(backend=self) self.playback = DespotifyPlaybackController(backend=self) diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index c2e828f9..96a87e7d 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -4,10 +4,11 @@ from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, from mopidy.models import Playlist class DummyBackend(BaseBackend): - def __init__(self): + def __init__(self, *args, **kwargs): + super(DummyBackend, self).__init__(*args, **kwargs) self.current_playlist = DummyCurrentPlaylistController(backend=self) self.library = DummyLibraryController(backend=self) - self.playback = DummyPlaybackController(backend=self, mixer=DummyMixer) + self.playback = DummyPlaybackController(backend=self) self.stored_playlists = DummyStoredPlaylistsController(backend=self) self.uri_handlers = [u'dummy:'] @@ -46,12 +47,3 @@ class DummyPlaybackController(BasePlaybackController): class DummyStoredPlaylistsController(BaseStoredPlaylistsController): def search(self, query): return [Playlist(name=query)] - -class DummyMixer(object): - volume = 0 - - def getvolume(self): - return [self.volume, self.volume] - - def setvolume(self, volume): - self.volume = volume diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 8a2218b8..d2fabaf1 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -17,7 +17,8 @@ logger = logging.getLogger(u'backends.libspotify') ENCODING = 'utf-8' class LibspotifyBackend(BaseBackend): - def __init__(self): + def __init__(self, *args, **kwargs): + super(LibspotifyBackend, self).__init__(*args, **kwargs) self.current_playlist = LibspotifyCurrentPlaylistController( backend=self) self.library = LibspotifyLibraryController(backend=self) diff --git a/mopidy/mixers/__init__.py b/mopidy/mixers/__init__.py new file mode 100644 index 00000000..786a32d0 --- /dev/null +++ b/mopidy/mixers/__init__.py @@ -0,0 +1,35 @@ +class BaseMixer(object): + @property + def volume(self): + """ + The audio volume + + Integer in range [0, 100]. :class:`None` if unknown. Values below 0 is + equal to 0. Values above 100 is equal to 100. + """ + return self._get_volume() + + @volume.setter + def volume(self, volume): + volume = int(volume) + if volume < 0: + volume = 0 + elif volume > 100: + volume = 100 + self._set_volume(volume) + + def _get_volume(self): + """ + Return volume as integer in range [0, 100]. :class:`None` if unknown. + + *Must be implemented by subclass.* + """ + raise NotImplementedError + + def _set_volume(self, volume): + """ + Set volume as integer in range [0, 100]. + + *Must be implemented by subclass.* + """ + raise NotImplementedError diff --git a/mopidy/mixers/alsa.py b/mopidy/mixers/alsa.py new file mode 100644 index 00000000..54c10a09 --- /dev/null +++ b/mopidy/mixers/alsa.py @@ -0,0 +1,13 @@ +import alsaaudio + +from mopidy.mixers import BaseMixer + +class AlsaMixer(BaseMixer): + def __init__(self): + self._mixer = alsaaudio.Mixer() + + def _get_volume(self): + return self._mixer.getvolume()[0] + + def _set_volume(self, volume): + self._mixer.setvolume(volume) diff --git a/mopidy/mixers/dummy.py b/mopidy/mixers/dummy.py new file mode 100644 index 00000000..a08e8398 --- /dev/null +++ b/mopidy/mixers/dummy.py @@ -0,0 +1,11 @@ +from mopidy.mixers import BaseMixer + +class DummyMixer(BaseMixer): + def __init__(self): + self._volume = None + + def _get_volume(self): + return self._volume + + def _set_volume(self, volume): + self._volume = volume diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index c136bc2e..44f63dea 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -13,7 +13,6 @@ Available settings and their default values. #: #: .. note:: #: Currently only the first backend in the list is used. -#: BACKENDS = ( u'mopidy.backends.despotify.DespotifyBackend', #u'mopidy.backends.libspotify.LibspotifyBackend', @@ -24,6 +23,11 @@ BACKENDS = ( #: the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n %(message)s' +#: Sound mixer to use. Default:: +#: +#: MIXER = u'mopidy.mixers.alsa.AlsaMixer' +MIXER = u'mopidy.mixers.alsa.AlsaMixer' + #: Which address Mopidy should bind to. Examples: #: #: ``localhost`` diff --git a/tests/__main__.py b/tests/__main__.py index e203582a..11677b0e 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -9,6 +9,7 @@ def main(): sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) r = CoverageTestRunner() + r.add_pair('mopidy/mixers/dummy.py', 'tests/mixers/dummytest.py') r.add_pair('mopidy/models.py', 'tests/modelstest.py') r.add_pair('mopidy/mpd/handler.py', 'tests/mpd/handlertest.py') r.run() diff --git a/tests/mixers/dummytest.py b/tests/mixers/dummytest.py new file mode 100644 index 00000000..00d748fe --- /dev/null +++ b/tests/mixers/dummytest.py @@ -0,0 +1,26 @@ +import unittest + +from mopidy.mixers.dummy import DummyMixer + +class BaseMixerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + + def test_volume_is_None_initially(self): + self.assertEqual(self.m.volume, None) + + def test_volume_set_to_min(self): + self.m.volume = 0 + self.assertEqual(self.m.volume, 0) + + def test_volume_set_to_max(self): + self.m.volume = 100 + self.assertEqual(self.m.volume, 100) + + def test_volume_set_to_below_min_results_in_min(self): + self.m.volume = -10 + self.assertEqual(self.m.volume, 0) + + def test_volume_set_to_above_max_results_in_max(self): + self.m.volume = 110 + self.assertEqual(self.m.volume, 100) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index fa3a2143..2274851a 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -3,6 +3,7 @@ import unittest from mopidy.backends.dummy import DummyBackend from mopidy.exceptions import MpdAckError +from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track, Playlist from mopidy.mpd import handler @@ -19,7 +20,9 @@ class DummySession(object): class RequestHandlerTest(unittest.TestCase): def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = handler.MpdHandler(backend=self.b) def test_register_same_pattern_twice_fails(self): func = lambda: None @@ -46,7 +49,9 @@ class RequestHandlerTest(unittest.TestCase): class CommandListsTest(unittest.TestCase): def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = handler.MpdHandler(backend=self.b) def test_command_list_begin(self): result = self.h.handle_request(u'command_list_begin') @@ -92,7 +97,8 @@ class CommandListsTest(unittest.TestCase): class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend() + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) self.s = DummySession() self.h = handler.MpdHandler(backend=self.b, session=self.s) @@ -158,7 +164,6 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_status_method_contains_volume_which_defaults_to_0(self): - self.b.playback.volume = None result = dict(self.h._status_status()) self.assert_('volume' in result) self.assertEquals(int(result['volume']), 0) @@ -302,7 +307,8 @@ class StatusHandlerTest(unittest.TestCase): class PlaybackOptionsHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend() + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) self.h = handler.MpdHandler(backend=self.b) def test_consume_off(self): @@ -421,7 +427,8 @@ class PlaybackOptionsHandlerTest(unittest.TestCase): class PlaybackControlHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend() + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) self.h = handler.MpdHandler(backend=self.b) def test_next(self): @@ -499,7 +506,8 @@ class PlaybackControlHandlerTest(unittest.TestCase): class CurrentPlaylistHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend() + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) self.h = handler.MpdHandler(backend=self.b) def test_add(self): @@ -791,7 +799,8 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): class StoredPlaylistsHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend() + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) self.h = handler.MpdHandler(backend=self.b) def test_listplaylist(self): @@ -863,7 +872,8 @@ class StoredPlaylistsHandlerTest(unittest.TestCase): class MusicDatabaseHandlerTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend() + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) self.h = handler.MpdHandler(backend=self.b) def test_count(self): @@ -1018,7 +1028,9 @@ class MusicDatabaseHandlerTest(unittest.TestCase): class StickersHandlerTest(unittest.TestCase): def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = handler.MpdHandler(backend=self.b) def test_sticker_get(self): result = self.h.handle_request( @@ -1053,8 +1065,10 @@ class StickersHandlerTest(unittest.TestCase): class ConnectionHandlerTest(unittest.TestCase): def setUp(self): - self.h = handler.MpdHandler(session=DummySession(), - backend=DummyBackend()) + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.s = DummySession() + self.h = handler.MpdHandler(backend=self.b, session=self.s) def test_close(self): result = self.h.handle_request(u'close') @@ -1079,7 +1093,9 @@ class ConnectionHandlerTest(unittest.TestCase): class AudioOutputHandlerTest(unittest.TestCase): def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = handler.MpdHandler(backend=self.b) def test_enableoutput(self): result = self.h.handle_request(u'enableoutput "0"') @@ -1099,7 +1115,9 @@ class AudioOutputHandlerTest(unittest.TestCase): class ReflectionHandlerTest(unittest.TestCase): def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = handler.MpdHandler(backend=self.b) def test_commands(self): result = self.h.handle_request(u'commands') From 3a07c6b27dcfdac3b8466e8aafa63fb7c4aaefae Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 23:49:23 +0100 Subject: [PATCH 154/341] Fix typo --- docs/api/mixers.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst index 0bf94237..256291f2 100644 --- a/docs/api/mixers.rst +++ b/docs/api/mixers.rst @@ -1,6 +1,6 @@ -******************************** -:mod:`mopidy.mixer` -- Mixer API -******************************** +********************************* +:mod:`mopidy.mixers` -- Mixer API +********************************* .. automodule:: mopidy.mixers :synopsis: Sound mixer interface. From e8dbefceb575297fa979ea6139500ebbead92bc9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Mar 2010 23:50:41 +0100 Subject: [PATCH 155/341] Update backend API synopsis --- docs/api/backends.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 56d6f059..687eef31 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -3,6 +3,6 @@ ************************************* .. automodule:: mopidy.backends - :synopsis: Interface between Mopidy and its various backends. + :synopsis: Backend interface. :members: :undoc-members: From dbcdd0cb042b4a66b0362c55bde5365b36dbb980 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Mar 2010 17:11:19 +0100 Subject: [PATCH 156/341] docs: Move changes to bottom of contents --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 72da0107..3aa0bc4b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,11 +6,11 @@ Contents .. toctree:: :maxdepth: 3 - changes installation settings development api/index + changes Indices and tables ================== From 31529fb50fa87ce87c65191f50867b152ff78ff9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Mar 2010 17:12:17 +0100 Subject: [PATCH 157/341] docs: Move settings doc into API docs --- docs/{ => api}/settings.rst | 6 +++--- docs/index.rst | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) rename docs/{ => api}/settings.rst (51%) diff --git a/docs/settings.rst b/docs/api/settings.rst similarity index 51% rename from docs/settings.rst rename to docs/api/settings.rst index 7e67de50..cba8b0ae 100644 --- a/docs/settings.rst +++ b/docs/api/settings.rst @@ -1,6 +1,6 @@ -******** -Settings -******** +****************************************** +:mod:`mopidy.settings.default` -- Settings +****************************************** .. automodule:: mopidy.settings.default :synopsis: Available settings and their default values. diff --git a/docs/index.rst b/docs/index.rst index 3aa0bc4b..feb636d7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,7 +7,6 @@ Contents :maxdepth: 3 installation - settings development api/index changes From c95fef4343d97abd38f9f169b352c73306e3cf75 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Mar 2010 17:26:23 +0100 Subject: [PATCH 158/341] docs: Update libspotify test example --- docs/installation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation.rst b/docs/installation.rst index f0aca702..eecd5ab4 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -135,7 +135,7 @@ binary version, and place the file at ``pyspotify/spotify_appkey.key``. Test your libspotify setup:: - ./example1.py -u USERNAME -p PASSWORD + examples/example1.py -u USERNAME -p PASSWORD .. note:: From 9c09c9b1df8ba39b8f51b0bdbb4dc54a895ebf4a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Mar 2010 17:33:46 +0100 Subject: [PATCH 159/341] Automatically choose AlsaMixer on Linux and DummyMixer elsewhere --- docs/installation.rst | 5 ----- mopidy/settings/default.py | 14 ++++++++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index eecd5ab4..c256de51 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -160,11 +160,6 @@ libspotify backend, copy the Spotify application key to BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',) -*OS X:* The default mixer does not work on OS X, so you must change to a dummy -mixer:: - - MIXER = u'mopidy.mixers.dummy.DummyMixer' - For a full list of available settings, see :mod:`mopidy.settings.default`. diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index 44f63dea..65db36a3 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -7,6 +7,8 @@ Available settings and their default values. ``mopidy/settings/local.py`` and redefine settings there. """ +import sys + #: List of playback backends to use. Default:: #: #: BACKENDS = (u'mopidy.backends.despotify.DespotifyBackend',) @@ -23,10 +25,18 @@ BACKENDS = ( #: the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n %(message)s' -#: Sound mixer to use. Default:: +#: Sound mixer to use. +#: +#: Default on Linux:: #: #: MIXER = u'mopidy.mixers.alsa.AlsaMixer' -MIXER = u'mopidy.mixers.alsa.AlsaMixer' +#: +#: Default on other operating systems:: +#: +#: MIXER = u'mopidy.mixers.dummy.DummyMixer' +MIXER = u'mopidy.mixers.dummy.DummyMixer' +if sys.platform == 'linux2': + MIXER = u'mopidy.mixers.alsa.AlsaMixer' #: Which address Mopidy should bind to. Examples: #: From 1619ecbd56d634c648fb4372e416ec5b4e5849b4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Mar 2010 20:22:17 +0100 Subject: [PATCH 160/341] Add mixer for OS X which uses osascript --- mopidy/mixers/osa.py | 15 +++++++++++++++ mopidy/settings/default.py | 6 ++++++ 2 files changed, 21 insertions(+) create mode 100644 mopidy/mixers/osa.py diff --git a/mopidy/mixers/osa.py b/mopidy/mixers/osa.py new file mode 100644 index 00000000..a10dfe42 --- /dev/null +++ b/mopidy/mixers/osa.py @@ -0,0 +1,15 @@ +from subprocess import Popen, PIPE + +from mopidy.mixers import BaseMixer + +class OsaMixer(BaseMixer): + def _get_volume(self): + try: + return int(Popen( + ['osascript', '-e', 'output volume of (get volume settings)'], + stdout=PIPE).communicate()[0]) + except ValueError: + return None + + def _set_volume(self, volume): + Popen(['osascript', '-e', 'set volume output volume %d' % volume]) diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index 65db36a3..96d5cd23 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -31,12 +31,18 @@ CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n #: #: MIXER = u'mopidy.mixers.alsa.AlsaMixer' #: +#: Default on OS X:: +#: +#: MIXER = u'mopidy.mixers.osa.OsaMixer' +#: #: Default on other operating systems:: #: #: MIXER = u'mopidy.mixers.dummy.DummyMixer' MIXER = u'mopidy.mixers.dummy.DummyMixer' if sys.platform == 'linux2': MIXER = u'mopidy.mixers.alsa.AlsaMixer' +elif sys.platform == 'darwin': + MIXER = u'mopidy.mixers.osa.OsaMixer' #: Which address Mopidy should bind to. Examples: #: From b1ece22032a0947a09bf24092997acb94ca4c711 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Mar 2010 20:34:57 +0100 Subject: [PATCH 161/341] Move mopidy.exceptions.SettingError to mopidy.SettingsError --- mopidy/__init__.py | 14 ++++++++------ mopidy/__main__.py | 5 ++--- mopidy/exceptions.py | 3 --- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index eaea5fd7..176bef6a 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,5 +1,4 @@ -from mopidy.exceptions import SettingError -from mopidy import settings as original_settings +from mopidy import settings as raw_settings def get_version(): return u'0.1.dev' @@ -7,13 +6,16 @@ def get_version(): def get_mpd_protocol_version(): return u'0.16.0' +class SettingsError(Exception): + pass + class Settings(object): def __getattr__(self, attr): - if not hasattr(original_settings, attr): - raise SettingError(u'Setting "%s" is not set.' % attr) - value = getattr(original_settings, attr) + if not hasattr(raw_settings, attr): + raise SettingsError(u'Setting "%s" is not set.' % attr) + value = getattr(raw_settings, attr) if type(value) != bool and not value: - raise SettingError(u'Setting "%s" is empty.' % attr) + raise SettingsError(u'Setting "%s" is empty.' % attr) return value settings = Settings() diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 2fb51f4e..d2cf09a7 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -6,8 +6,7 @@ import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import settings -from mopidy.exceptions import SettingError +from mopidy import settings, SettingsError from mopidy.mpd.server import MpdServer logger = logging.getLogger('mopidy') @@ -44,5 +43,5 @@ if __name__ == '__main__': main() except KeyboardInterrupt: sys.exit('\nInterrupted by user') - except SettingError, e: + except SettingsError, e: sys.exit('%s' % e) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index ac9b9dfe..8f352807 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -1,6 +1,3 @@ -class SettingError(Exception): - pass - class MpdAckError(Exception): pass From 38992bcf9c0cc8c5c91b8cc79bd4f926ed64aa16 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Mar 2010 20:37:03 +0100 Subject: [PATCH 162/341] Move MPD exceptions into mopidy.mpd --- mopidy/exceptions.py | 6 ------ mopidy/mpd/__init__.py | 6 ++++++ mopidy/mpd/handler.py | 2 +- mopidy/mpd/session.py | 2 +- tests/mpd/handlertest.py | 3 +-- 5 files changed, 9 insertions(+), 10 deletions(-) delete mode 100644 mopidy/exceptions.py diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py deleted file mode 100644 index 8f352807..00000000 --- a/mopidy/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -class MpdAckError(Exception): - pass - -class MpdNotImplemented(MpdAckError): - def __init__(self, *args): - super(MpdNotImplemented, self).__init__(u'Not implemented', *args) diff --git a/mopidy/mpd/__init__.py b/mopidy/mpd/__init__.py index e69de29b..8f352807 100644 --- a/mopidy/mpd/__init__.py +++ b/mopidy/mpd/__init__.py @@ -0,0 +1,6 @@ +class MpdAckError(Exception): + pass + +class MpdNotImplemented(MpdAckError): + def __init__(self, *args): + super(MpdNotImplemented, self).__init__(u'Not implemented', *args) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 669fe399..981307d5 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -15,7 +15,7 @@ import logging import re import sys -from mopidy.exceptions import MpdAckError, MpdNotImplemented +from mopidy.mpd import MpdAckError, MpdNotImplemented logger = logging.getLogger('mpd.handler') diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index d7ecfeaa..19d8c1c5 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -2,7 +2,7 @@ import asynchat import logging from mopidy import get_mpd_protocol_version -from mopidy.exceptions import MpdAckError +from mopidy.mpd import MpdAckError from mopidy.mpd.handler import MpdHandler logger = logging.getLogger(u'mpd.session') diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 2274851a..0d8e97f1 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -2,10 +2,9 @@ import datetime as dt import unittest from mopidy.backends.dummy import DummyBackend -from mopidy.exceptions import MpdAckError from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track, Playlist -from mopidy.mpd import handler +from mopidy.mpd import handler, MpdAckError class DummySession(object): def do_close(self): From d774ad90fb7a2e6b59da978ad339e1562dd981fe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Mar 2010 20:49:55 +0100 Subject: [PATCH 163/341] docs: Include mixers in the class instantiation and usage diagram --- docs/development.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 60004533..4cb043f6 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -134,17 +134,22 @@ features we wished was there: Class instantiation and usage ============================= -The following diagram shows how Mopidy with the despotify backend is wired -together. The gray nodes are part of external dependencies, and not Mopidy. +The following diagram shows how Mopidy with the despotify backend and ALSA +mixer is wired together. The gray nodes are part of external dependencies, and +not Mopidy. .. digraph:: class_instantiation_and_usage "spytify" [ color="gray" ] "despotify" [ color="gray" ] + "alsaaudio" [ color="gray" ] "__main__" -> "MpdServer" [ label="create 1" ] + "__main__" -> "AlsaMixer" [ label="create 1" ] "__main__" -> "DespotifyBackend" [ label="create 1" ] "MpdServer" -> "MpdSession" [ label="create 1 per client" ] "MpdSession" -> "MpdHandler" [ label="pass MPD requests to" ] "MpdHandler" -> "DespotifyBackend" [ label="use backend API" ] "DespotifyBackend" -> "spytify" [ label="use Python wrapper" ] "spytify" -> "despotify" [ label="use C library" ] + "DespotifyBackend" -> "AlsaMixer" [ label="use mixer API" ] + "AlsaMixer" -> "alsaaudio" [ label="use Python library" ] From f06f5c596aceeaec1e1f642a8afde53e3197bf21 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Mar 2010 23:25:44 +0100 Subject: [PATCH 164/341] docs: despotify setup using Homebrew --- docs/installation.rst | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index c256de51..9503607b 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -42,18 +42,24 @@ Installing despotify libtool libncursesw5-dev libao-dev *OS X:* In OS X you need to have `XCode -`_ and `MacPorts -`_ installed. Then, to install despotify's -dependencies:: +`_ installed, and either `MacPorts +`_ or `Homebrew `_. - sudo port install openssl zlib libvorbis libtool ncursesw libao +*OS X, Homebrew:* Install dependencies:: + + brew install libvorbis ncursesw libao pkg-config + +*OS X, MacPorts:* Install dependencies:: + + sudo port install libvorbis libtool ncursesw libao *All OS:* Check out revision 503 of the despotify source code:: - svn co https://despotify.svn.sourceforge.net/svnroot/despotify@503 + svn checkout https://despotify.svn.sourceforge.net/svnroot/despotify@503 -*OS X:* Edit ``despotify/src/Makefile.local.mk`` and uncomment the last two -lines so that it reads:: +*OS X, MacPorts:* Copy ``despotify/src/Makefile.local.mk.dist`` to +``despotify/src/Makefile.local.mk`` and uncomment the last two lines of the new +file so that it reads:: ## If you're on Mac OS X and have installed libvorbisfile ## via 'port install ..', try uncommenting these lines From b5483424835616f66101c4f99b11e8fe4e5bed37 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Mar 2010 23:36:13 +0100 Subject: [PATCH 165/341] docs: Update dependency list, as pyalsaaudio is not required for OS X --- docs/installation.rst | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 9503607b..9282a58c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -13,12 +13,19 @@ guides here. Dependencies ============ -* Python >= 2.6 -* pyalsaaudio >= 0.2 (Debian/Ubuntu: python-alsaaudio) -* Dependencies for at least one Mopidy backend: +- Python >= 2.6 +- Dependencies for at least one Mopidy mixer: + + - *Linux, AlsaMixer:* pyalsaaudio >= 0.2 (Debian/Ubuntu package: + python-alsaaudio) + - *OS X, OsaMixer:* Nothing needed. + +- Dependencies for at least one Mopidy backend: + + - :ref:`despotify` + - :ref:`libspotify` + - * :ref:`despotify` - * :ref:`libspotify` .. _despotify: From 6551b8309e4505ed54973f083702dda643139fe2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Mar 2010 23:36:32 +0100 Subject: [PATCH 166/341] docs: Add note on MPD clients --- docs/installation.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/installation.rst b/docs/installation.rst index 9282a58c..57a401e5 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -183,4 +183,10 @@ To start Mopidy, go to the root of the Mopidy project, then simply run:: python mopidy +When Mopidy says ``Please connect to localhost port 6600 using an MPD client.`` +it's ready to accept connections by any MPD client. You can find a list of tons +of MPD clients at http://mpd.wikia.com/wiki/Clients. We use Sonata, GMPC, +ncmpc, and ncmpcpp during development. The first two are GUI clients, while the +last two are terminal clients. + To stop Mopidy, press ``CTRL+C``. From a8f11174fe0ffaea97b26c4433fcdc45e34d3602 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 00:01:44 +0100 Subject: [PATCH 167/341] OK instead of ACK on 'idle' --- mopidy/mpd/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 981307d5..cd6be9be 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -1045,7 +1045,7 @@ class MpdHandler(object): notifications when something changed in one of the specified subsystems. """ - raise MpdNotImplemented # TODO + pass # TODO @handle_pattern(r'^noidle$') def _status_noidle(self): From 229d0d391dc7d5b0167dde842010a544b1b31e51 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 00:03:42 +0100 Subject: [PATCH 168/341] ncmpc uses capitalized type arguments for search --- mopidy/mpd/handler.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index cd6be9be..0dc03bf0 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -544,6 +544,8 @@ class MpdHandler(object): @handle_pattern(r'^find (?P(album|artist|title)) ' r'"(?P[^"]+)"$') + @handle_pattern(r'^find (?P(Album|Artist|Title)) ' + r'"(?P[^"]+)"$') @handle_pattern(r'^find "(?P(album|artist|title))" ' r'"(?P[^"]+)"$') def _music_db_find(self, type, what): @@ -558,7 +560,13 @@ class MpdHandler(object): *GMPC:* - does not add quotes around the type argument. + + *ncmpc:* + + - does not add quotes around the type argument. + - capitalizes the type argument. """ + type = type.lower() if type == u'title': type = u'track' return self.backend.library.find_exact(type, what).mpd_format( From 68ae8e25d809b8a7a4e4278031ca09ec78e5c0dc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 00:05:49 +0100 Subject: [PATCH 169/341] ncmpc uses capitalized type arguments for search (search too, not just find) --- mopidy/mpd/handler.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 0dc03bf0..47f70c3d 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -658,6 +658,8 @@ class MpdHandler(object): @handle_pattern(r'^search (?P(album|artist|filename|title|any)) ' r'"(?P[^"]+)"$') + @handle_pattern(r'^search (?P(Album|Artist|Filename|Title|Any)) ' + r'"(?P[^"]+)"$') @handle_pattern(r'^search "(?P(album|artist|filename|title|any))" ' r'"(?P[^"]+)"$') def _music_db_search(self, type, what): @@ -677,6 +679,11 @@ class MpdHandler(object): - searches for multiple words like this:: search any "foo" any "bar" any "baz" + + *ncmpc:* + + - does not add quotes around the type argument. + - capitalizes the type argument. """ # TODO Support GMPC multi-word search if type == u'title': From 71d9bb83e53e62304b88193f295e3631216a6b7c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 00:06:49 +0100 Subject: [PATCH 170/341] Lowercase type argument for search too, not just find --- mopidy/mpd/handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 47f70c3d..557cccf8 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -686,6 +686,7 @@ class MpdHandler(object): - capitalizes the type argument. """ # TODO Support GMPC multi-word search + type = type.lower() if type == u'title': type = u'track' return self.backend.library.search(type, what).mpd_format( From 481d3a670ae700b2b47dcd3e573f73800f5193e9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 00:10:14 +0100 Subject: [PATCH 171/341] ncmpc doesn't add quotes and capitalizes type argument for 'list' --- mopidy/mpd/handler.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 557cccf8..d60e16e7 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -589,6 +589,7 @@ class MpdHandler(object): #return result @handle_pattern(r'^list "(?Partist)"$') + @handle_pattern(r'^list (?PArtist)$') @handle_pattern(r'^list "(?Palbum)"( "(?P[^"]+)")*$') def _music_db_list(self, type, artist=None): """ @@ -601,7 +602,13 @@ class MpdHandler(object): ``ARTIST`` is an optional parameter when type is ``album``, this specifies to list albums by an artist. + + *ncmpc:* + + - does not add quotes around the type argument. + - capitalizes the type argument. """ + type = type.lower() pass # TODO @handle_pattern(r'^listall "(?P[^"]+)"') From 38399c844bcd365fb0948e37b965e1016da89546 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 00:16:20 +0100 Subject: [PATCH 172/341] Update tests to check for 'OK' from 'idle' calls --- tests/mpd/handlertest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 0d8e97f1..f996b33f 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -127,11 +127,11 @@ class StatusHandlerTest(unittest.TestCase): def test_idle_without_subsystems(self): result = self.h.handle_request(u'idle') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) def test_idle_with_subsystems(self): result = self.h.handle_request(u'idle database playlist') - self.assert_(u'ACK Not implemented' in result) + self.assert_(u'OK' in result) def test_noidle(self): result = self.h.handle_request(u'noidle') From 96365d3467e1b0a9520eaff8086224d2d181b03b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 11:45:42 +0100 Subject: [PATCH 173/341] Add caching of OsaMixer volume If volume is just managed through Mopidy it is always correct. If another application changes the volume, Mopidy will be correct within 30 seconds. --- mopidy/mixers/osa.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/mopidy/mixers/osa.py b/mopidy/mixers/osa.py index a10dfe42..6ec12e17 100644 --- a/mopidy/mixers/osa.py +++ b/mopidy/mixers/osa.py @@ -1,15 +1,32 @@ from subprocess import Popen, PIPE +import time from mopidy.mixers import BaseMixer +CACHE_TTL = 30 + class OsaMixer(BaseMixer): + _cache = None + _last_update = None + + def _valid_cache(self): + return (self._cache is not None + and self._last_update is not None + and (int(time.time() - self._last_update) < CACHE_TTL)) + def _get_volume(self): - try: - return int(Popen( - ['osascript', '-e', 'output volume of (get volume settings)'], - stdout=PIPE).communicate()[0]) - except ValueError: - return None + if not self._valid_cache(): + try: + self._cache = int(Popen( + ['osascript', '-e', + 'output volume of (get volume settings)'], + stdout=PIPE).communicate()[0]) + except ValueError: + self._cache = None + self._last_update = int(time.time()) + return self._cache def _set_volume(self, volume): Popen(['osascript', '-e', 'set volume output volume %d' % volume]) + self._cache = volume + self._last_update = int(time.time()) From 8a07155d2084c0b32ff906b2aaa69338540f6af1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 18:50:05 +0100 Subject: [PATCH 174/341] docs: Move despotify and libspotify install instructions to their own pages --- docs/index.rst | 2 +- docs/installation.rst | 192 ------------------------------- docs/installation/despotify.rst | 80 +++++++++++++ docs/installation/index.rst | 80 +++++++++++++ docs/installation/libspotify.rst | 58 ++++++++++ 5 files changed, 219 insertions(+), 193 deletions(-) delete mode 100644 docs/installation.rst create mode 100644 docs/installation/despotify.rst create mode 100644 docs/installation/index.rst create mode 100644 docs/installation/libspotify.rst diff --git a/docs/index.rst b/docs/index.rst index feb636d7..29b9b928 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ Contents .. toctree:: :maxdepth: 3 - installation + installation/index development api/index changes diff --git a/docs/installation.rst b/docs/installation.rst deleted file mode 100644 index 57a401e5..00000000 --- a/docs/installation.rst +++ /dev/null @@ -1,192 +0,0 @@ -************ -Installation -************ - -Mopidy itself is a breeze to install, as it just requires a standard Python -installation. The libraries we depend on to connect to the Spotify service is -far more tricky to get working for the time being. Until installation of these -libraries are either well documented by their developers, or the libraries are -packaged for various Linux distributions, we will supply our own installation -guides here. - - -Dependencies -============ - -- Python >= 2.6 -- Dependencies for at least one Mopidy mixer: - - - *Linux, AlsaMixer:* pyalsaaudio >= 0.2 (Debian/Ubuntu package: - python-alsaaudio) - - *OS X, OsaMixer:* Nothing needed. - -- Dependencies for at least one Mopidy backend: - - - :ref:`despotify` - - :ref:`libspotify` - - - - -.. _despotify: - -despotify backend ------------------ - -To use the despotify backend, you first need to install despotify and spytify. - -.. note:: - - This backend requires a Spotify premium account. - - -Installing despotify -^^^^^^^^^^^^^^^^^^^^ - -*Linux:* Install despotify's dependencies. At Debian/Ubuntu systems:: - - sudo aptitude install libssl-dev zlib1g-dev libvorbis-dev \ - libtool libncursesw5-dev libao-dev - -*OS X:* In OS X you need to have `XCode -`_ installed, and either `MacPorts -`_ or `Homebrew `_. - -*OS X, Homebrew:* Install dependencies:: - - brew install libvorbis ncursesw libao pkg-config - -*OS X, MacPorts:* Install dependencies:: - - sudo port install libvorbis libtool ncursesw libao - -*All OS:* Check out revision 503 of the despotify source code:: - - svn checkout https://despotify.svn.sourceforge.net/svnroot/despotify@503 - -*OS X, MacPorts:* Copy ``despotify/src/Makefile.local.mk.dist`` to -``despotify/src/Makefile.local.mk`` and uncomment the last two lines of the new -file so that it reads:: - - ## If you're on Mac OS X and have installed libvorbisfile - ## via 'port install ..', try uncommenting these lines - CFLAGS += -I/opt/local/include - LDFLAGS += -L/opt/local/lib - -*All OS:* Build and install despotify:: - - cd despotify/src/ - make - sudo make install - - -Installing spytify -^^^^^^^^^^^^^^^^^^ - -spytify's source comes bundled with despotify. - -Build and install spytify:: - - cd despotify/src/bindings/python/ - export PKG_CONFIG_PATH=../../lib # Needed on OS X - make - sudo make install - -To validate that everything is working, run the ``test.py`` script which is -distributed with spytify:: - - python test.py - -The test script should ask for your username and password (which must be for a -Spotify Premium account), ask for a search query, list all your playlists with -tracks, play 10s from a random song from the search result, pause for two -seconds, play for five more seconds, and quit. - - -.. _libspotify: - -libspotify backend ------------------- - -As an alternative to the despotify backend, we are working on a libspotify -backend. To use the libspotify backend you must install libspotify and -pyspotify. - -.. note:: - - This backend requires a Spotify premium account, and it requires you to get - an application key from Spotify before use. - - -Installing libspotify -^^^^^^^^^^^^^^^^^^^^^ - -As libspotify's installation script at the moment is somewhat broken (see this -`GetSatisfaction thread `_ -for details), it is easiest to use the libspotify files bundled with pyspotify. -The files bundled with pyspotify are for 64-bit, so if you run a 32-bit OS, you -must get libspotify from https://developer.spotify.com/en/libspotify/. - - -Installing pyspotify -^^^^^^^^^^^^^^^^^^^^ - -Install pyspotify's dependencies. At Debian/Ubuntu systems:: - - sudo aptitude install python-alsaaudio - -Check out the pyspotify code, and install it:: - - git clone git://github.com/winjer/pyspotify.git - cd pyspotify - export LD_LIBRARY_PATH=$PWD/lib - sudo python setup.py develop - -Apply for an application key at -https://developer.spotify.com/en/libspotify/application-key, download the -binary version, and place the file at ``pyspotify/spotify_appkey.key``. - -Test your libspotify setup:: - - examples/example1.py -u USERNAME -p PASSWORD - -.. note:: - - Until Spotify fixes their installation script, you'll have to set - ``LD_LIBRARY_PATH`` every time you are going to use libspotify (in other - words before starting Mopidy). - - -Spotify settings -================ - -Create a file named ``local.py`` in the directory ``mopidy/settings/``. Enter -your Spotify Premium account's username and password into the file, like this:: - - SPOTIFY_USERNAME = u'myusername' - SPOTIFY_PASSWORD = u'mysecret' - -Currently the despotify backend is the default. If you want to use the -libspotify backend, copy the Spotify application key to -``mopidy/spotify_appkey.key``, and add the following to -``mopidy/mopidy/settings/local.py``:: - - BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',) - -For a full list of available settings, see :mod:`mopidy.settings.default`. - - -Running Mopidy -============== - -To start Mopidy, go to the root of the Mopidy project, then simply run:: - - python mopidy - -When Mopidy says ``Please connect to localhost port 6600 using an MPD client.`` -it's ready to accept connections by any MPD client. You can find a list of tons -of MPD clients at http://mpd.wikia.com/wiki/Clients. We use Sonata, GMPC, -ncmpc, and ncmpcpp during development. The first two are GUI clients, while the -last two are terminal clients. - -To stop Mopidy, press ``CTRL+C``. diff --git a/docs/installation/despotify.rst b/docs/installation/despotify.rst new file mode 100644 index 00000000..e50d3cba --- /dev/null +++ b/docs/installation/despotify.rst @@ -0,0 +1,80 @@ +.. _despotify: + +********************** +despotify installation +********************** + +To use the despotify backend, you first need to install despotify and spytify. + +.. note:: + + This backend requires a Spotify premium account. + + +Installing despotify +==================== + +*Linux:* Install despotify's dependencies. At Debian/Ubuntu systems:: + + sudo aptitude install libssl-dev zlib1g-dev libvorbis-dev \ + libtool libncursesw5-dev libao-dev + +*OS X:* In OS X you need to have `XCode +`_ installed, and either `MacPorts +`_ or `Homebrew `_. + +*OS X, Homebrew:* Install dependencies:: + + brew install libvorbis ncursesw libao pkg-config + +*OS X, MacPorts:* Install dependencies:: + + sudo port install libvorbis libtool ncursesw libao + +*All OS:* Check out revision 503 of the despotify source code:: + + svn checkout https://despotify.svn.sourceforge.net/svnroot/despotify@503 + +*OS X, MacPorts:* Copy ``despotify/src/Makefile.local.mk.dist`` to +``despotify/src/Makefile.local.mk`` and uncomment the last two lines of the new +file so that it reads:: + + ## If you're on Mac OS X and have installed libvorbisfile + ## via 'port install ..', try uncommenting these lines + CFLAGS += -I/opt/local/include + LDFLAGS += -L/opt/local/lib + +*All OS:* Build and install despotify:: + + cd despotify/src/ + make + sudo make install + + +Installing spytify +================== + +spytify's source comes bundled with despotify. + +Build and install spytify:: + + cd despotify/src/bindings/python/ + export PKG_CONFIG_PATH=../../lib # Needed on OS X + make + sudo make install + + +Testing the installation +======================== + +To validate that everything is working, run the ``test.py`` script which is +distributed with spytify:: + + python test.py + +The test script should ask for your username and password (which must be for a +Spotify Premium account), ask for a search query, list all your playlists with +tracks, play 10s from a random song from the search result, pause for two +seconds, play for five more seconds, and quit. +o stop Mopidy, press ``CTRL+C``. + diff --git a/docs/installation/index.rst b/docs/installation/index.rst new file mode 100644 index 00000000..98829e02 --- /dev/null +++ b/docs/installation/index.rst @@ -0,0 +1,80 @@ +************ +Installation +************ + +Mopidy itself is a breeze to install, as it just requires a standard Python +installation. The libraries we depend on to connect to the Spotify service is +far more tricky to get working for the time being. Until installation of these +libraries are either well documented by their developers, or the libraries are +packaged for various Linux distributions, we will supply our own installation +guides here. + +.. toctree:: + :maxdepth: 1 + + despotify + libspotify + + +Dependencies +============ + +- Python >= 2.6 +- Dependencies for at least one Mopidy mixer: + + - AlsaMixer (Linux only) + + - pyalsaaudio >= 0.2 (Debian/Ubuntu package: python-alsaaudio) + + - OsaMixer (OS X only) + + - Nothing needed. + +- Dependencies for at least one Mopidy backend: + + - DespotifyBackend (Linux and OS X) + + - see :ref:`despotify` + + - LibspotifyBackend (Linux only) + + - see :ref:`libspotify` + + +Spotify settings +================ + +Create a file named ``local.py`` in the directory ``mopidy/settings/``. Enter +your Spotify Premium account's username and password into the file, like this:: + + SPOTIFY_USERNAME = u'myusername' + SPOTIFY_PASSWORD = u'mysecret' + + +Switching backend +================= + +Currently the despotify backend is the default. If you want to use the +libspotify backend instead, copy the Spotify application key to +``mopidy/spotify_appkey.key``, and add the following to +``mopidy/mopidy/settings/local.py``:: + + BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',) + +For a full list of available settings, see :mod:`mopidy.settings.default`. + + +Running Mopidy +============== + +To start Mopidy, go to the root of the Mopidy project, then simply run:: + + python mopidy + +When Mopidy says ``Please connect to localhost port 6600 using an MPD client.`` +it's ready to accept connections by any MPD client. You can find a list of tons +of MPD clients at http://mpd.wikia.com/wiki/Clients. We use Sonata, GMPC, +ncmpc, and ncmpcpp during development. The first two are GUI clients, while the +last two are terminal clients. + +To stop Mopidy, press ``CTRL+C``. diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst new file mode 100644 index 00000000..042a7f2e --- /dev/null +++ b/docs/installation/libspotify.rst @@ -0,0 +1,58 @@ +.. _libspotify: + +*********************** +libspotify installation +*********************** + +As an alternative to the despotify backend, we are working on a libspotify +backend. To use the libspotify backend you must install libspotify and +pyspotify. + +.. note:: + + This backend requires a Spotify premium account, and it requires you to get + an application key from Spotify before use. + + +Installing libspotify +===================== + +As libspotify's installation script at the moment is somewhat broken (see this +`GetSatisfaction thread `_ +for details), it is easiest to use the libspotify files bundled with pyspotify. +The files bundled with pyspotify are for 64-bit, so if you run a 32-bit OS, you +must get libspotify from https://developer.spotify.com/en/libspotify/. + + +Installing pyspotify +==================== + +Install pyspotify's dependencies. At Debian/Ubuntu systems:: + + sudo aptitude install python-alsaaudio + +Check out the pyspotify code, and install it:: + + git clone git://github.com/winjer/pyspotify.git + cd pyspotify + export LD_LIBRARY_PATH=$PWD/lib + sudo python setup.py develop + +Apply for an application key at +https://developer.spotify.com/en/libspotify/application-key, download the +binary version, and place the file at ``pyspotify/spotify_appkey.key``. + + +Testing the installation +======================== + +Test your libspotify setup:: + + examples/example1.py -u USERNAME -p PASSWORD + +.. note:: + + Until Spotify fixes their installation script, you'll have to set + ``LD_LIBRARY_PATH`` every time you are going to use libspotify (in other + words before starting Mopidy). + From 13796b1e429430124daa04fe4ab3c10f544e6fc4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 19:46:48 +0100 Subject: [PATCH 175/341] docs: Split development docs intro three pages. Extend roadmap part quite a bit. --- .../contributing.rst} | 74 +------------------ docs/development/index.rst | 10 +++ docs/development/internals.rst | 68 +++++++++++++++++ docs/development/roadmap.rst | 53 +++++++++++++ docs/index.rst | 2 +- 5 files changed, 135 insertions(+), 72 deletions(-) rename docs/{development.rst => development/contributing.rst} (50%) create mode 100644 docs/development/index.rst create mode 100644 docs/development/internals.rst create mode 100644 docs/development/roadmap.rst diff --git a/docs/development.rst b/docs/development/contributing.rst similarity index 50% rename from docs/development.rst rename to docs/development/contributing.rst index 4cb043f6..2889c210 100644 --- a/docs/development.rst +++ b/docs/development/contributing.rst @@ -1,20 +1,11 @@ -*********** -Development -*********** +***************** +How to contribute +***************** Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at ``irc.freenode.net`` and through `GitHub `_. -Scope for the first release -=========================== - -To limit scope, we will start by implementing an MPD server which only -supports Spotify, and not playback of files from disk. We will make Mopidy -modular, so we can extend it with other backends in the future, like file -playback and other online music services such as Last.fm. - - Code style ========== @@ -94,62 +85,3 @@ The documentation at http://www.mopidy.com/docs/ is automatically updated within 10 minutes after a documentation update is pushed to ``jodal/mopidy/master`` at GitHub. - -Notes on despotify/spytify -========================== - -`spytify `_ -is the Python bindings for the open source `despotify `_ -library. It got no documentation to speak of, but a couple of examples are -available. - -A list of the issues we currently experience with spytify, both bugs and -features we wished was there: - -* r483: Sometimes segfaults when traversing stored playlists, their tracks, - artists, and albums. As it is not predictable, it may be a concurrency issue. - -* r503: Segfaults when looking up playlists, both your own lists and other - peoples shared lists. To reproduce:: - - >>> import spytify - >>> s = spytify.Spytify('alice', 'secret') - >>> s.lookup('spotify:user:klette:playlist:5rOGYPwwKqbAcVX8bW4k5V') - Segmentation fault - - -Notes on libspotify/libopenspotify/pyspotify -============================================ - -`pyspotify `_ is the Python bindings for -the official Spotify library, libspotify. It got no documentation to speak of, -but multiple examples are available. - -A list of the issues we currently experience with pyspotify, both bugs and -features we wished was there: - -* None at the moment. - - -Class instantiation and usage -============================= - -The following diagram shows how Mopidy with the despotify backend and ALSA -mixer is wired together. The gray nodes are part of external dependencies, and -not Mopidy. - -.. digraph:: class_instantiation_and_usage - - "spytify" [ color="gray" ] - "despotify" [ color="gray" ] - "alsaaudio" [ color="gray" ] - "__main__" -> "MpdServer" [ label="create 1" ] - "__main__" -> "AlsaMixer" [ label="create 1" ] - "__main__" -> "DespotifyBackend" [ label="create 1" ] - "MpdServer" -> "MpdSession" [ label="create 1 per client" ] - "MpdSession" -> "MpdHandler" [ label="pass MPD requests to" ] - "MpdHandler" -> "DespotifyBackend" [ label="use backend API" ] - "DespotifyBackend" -> "spytify" [ label="use Python wrapper" ] - "spytify" -> "despotify" [ label="use C library" ] - "DespotifyBackend" -> "AlsaMixer" [ label="use mixer API" ] - "AlsaMixer" -> "alsaaudio" [ label="use Python library" ] diff --git a/docs/development/index.rst b/docs/development/index.rst new file mode 100644 index 00000000..14c49dbd --- /dev/null +++ b/docs/development/index.rst @@ -0,0 +1,10 @@ +*********** +Development +*********** + +.. toctree:: + :maxdepth: 3 + + roadmap + contributing + internals diff --git a/docs/development/internals.rst b/docs/development/internals.rst new file mode 100644 index 00000000..8764d74e --- /dev/null +++ b/docs/development/internals.rst @@ -0,0 +1,68 @@ +********* +Internals +********* + +Some of the following notes and details will hopefully be useful when you start +developing on Mopidy, while some may only be useful when you get deeper into +specific parts of Mopidy. + + +Class instantiation and usage +============================= + +The following diagram shows how Mopidy with the despotify backend and ALSA +mixer is wired together. The gray nodes are part of external dependencies, and +not Mopidy. + +.. digraph:: class_instantiation_and_usage + + "spytify" [ color="gray" ] + "despotify" [ color="gray" ] + "alsaaudio" [ color="gray" ] + "__main__" -> "MpdServer" [ label="create 1" ] + "__main__" -> "AlsaMixer" [ label="create 1" ] + "__main__" -> "DespotifyBackend" [ label="create 1" ] + "MpdServer" -> "MpdSession" [ label="create 1 per client" ] + "MpdSession" -> "MpdHandler" [ label="pass MPD requests to" ] + "MpdHandler" -> "DespotifyBackend" [ label="use backend API" ] + "DespotifyBackend" -> "spytify" [ label="use Python wrapper" ] + "spytify" -> "despotify" [ label="use C library" ] + "DespotifyBackend" -> "AlsaMixer" [ label="use mixer API" ] + "AlsaMixer" -> "alsaaudio" [ label="use Python library" ] + + +Notes on despotify/spytify +========================== + +`spytify `_ +is the Python bindings for the open source `despotify `_ +library. It got no documentation to speak of, but a couple of examples are +available. + +A list of the issues we currently experience with spytify, both bugs and +features we wished was there: + +- r503: Sometimes segfaults when traversing stored playlists, their tracks, + artists, and albums. As it is not predictable, it may be a concurrency issue. + +- r503: Segfaults when looking up playlists, both your own lists and other + peoples shared lists. To reproduce:: + + >>> import spytify + >>> s = spytify.Spytify('alice', 'secret') + >>> s.lookup('spotify:user:klette:playlist:5rOGYPwwKqbAcVX8bW4k5V') + Segmentation fault + + +Notes on libspotify/pyspotify +============================================ + +`pyspotify `_ is the Python bindings for +the official Spotify library, libspotify. It got no documentation to speak of, +but multiple examples are available. Like libspotify, pyspotify's calls are +mostly asynchronous. + +A list of the issues we currently experience with pyspotify, both bugs and +features we wished was there: + +- None at the moment. diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst new file mode 100644 index 00000000..26861b06 --- /dev/null +++ b/docs/development/roadmap.rst @@ -0,0 +1,53 @@ +******* +Roadmap +******* + +This is the current roadmap and collection of wild ideas for future Mopidy +development. + + +Scope for the first release +=========================== + +This was was the plan written down when we started developing Mopidy, and we +still keep quite close to it: + + To limit scope, we will start by implementing an MPD server which only + supports Spotify, and not playback of files from disk. We will make Mopidy + modular, so we can extend it with other backends in the future, like file + playback and other online music services such as Last.fm. + + +Stuff we really want to do, but just not right now +================================================== + +- Replace libspotify with `openspotify + `_ for the + ``LibspotifyBackend``. +- A backend for playback from local disk. Quite a bit of work on a `gstreamer + `_ backend has already been done by Thomas + Adamcik. +- Support multiple backends at the same time. It would be really nice to have + tracks from local disk and Spotify tracks in the same playlist. +- Package Mopidy as a `Python package `_. +- Get a build server, i.e. `Hudson `_, up and running + which runs our test suite on all relevant platforms (Ubuntu, OS X, etc.) and + creates nightly packages (see next items). +- Create `Debian packages `_ of all our + dependencies and Mopidy itself (hosted in our own Debian repo until we get + stuff into the various distros) to make Debian/Ubuntu installation a breeze. +- Create `Homebrew `_ recipies for all our + dependencies and Mopidy itself to make OS X installation a breeze. + + +Crazy stuff we had to write down somewhere +========================================== + +- Add or create a new frontend protocol other than MPD. The MPD protocol got + quite a bit of legacy and it is badly documented. The amount of available + client implementations is MPD's big win. +- Add support for storing (Spotify) music to disk. +- Add support for serving the music as an `Icecast `_ + stream instead of playing it locally. +- Integrate with `Squeezebox `_ in some + way. diff --git a/docs/index.rst b/docs/index.rst index 29b9b928..644b8d57 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,7 +7,7 @@ Contents :maxdepth: 3 installation/index - development + development/index api/index changes From fa7499b26e141eef2e9f438c9eb8ea09ade45171 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 19:49:13 +0100 Subject: [PATCH 176/341] docs: Add link from internals to API docs --- docs/development/internals.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/development/internals.rst b/docs/development/internals.rst index 8764d74e..9c38f654 100644 --- a/docs/development/internals.rst +++ b/docs/development/internals.rst @@ -6,6 +6,8 @@ Some of the following notes and details will hopefully be useful when you start developing on Mopidy, while some may only be useful when you get deeper into specific parts of Mopidy. +In addition to what you'll find here, don't forget the :doc:`/api/index`. + Class instantiation and usage ============================= From 0736bf876ea7a13fb602ef18bbb59fb34a773f33 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 19:50:07 +0100 Subject: [PATCH 177/341] docs: Refer to docs using :doc: instead of :ref: --- docs/installation/despotify.rst | 2 -- docs/installation/index.rst | 4 ++-- docs/installation/libspotify.rst | 2 -- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/installation/despotify.rst b/docs/installation/despotify.rst index e50d3cba..fa7af6f5 100644 --- a/docs/installation/despotify.rst +++ b/docs/installation/despotify.rst @@ -1,5 +1,3 @@ -.. _despotify: - ********************** despotify installation ********************** diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 98829e02..e01fd994 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -34,11 +34,11 @@ Dependencies - DespotifyBackend (Linux and OS X) - - see :ref:`despotify` + - see :doc:`despotify` - LibspotifyBackend (Linux only) - - see :ref:`libspotify` + - see :doc:`libspotify` Spotify settings diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst index 042a7f2e..5ee6c333 100644 --- a/docs/installation/libspotify.rst +++ b/docs/installation/libspotify.rst @@ -1,5 +1,3 @@ -.. _libspotify: - *********************** libspotify installation *********************** From 2b4964ba7635b1dd9ca82143965000dba1e8529a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 19:59:42 +0100 Subject: [PATCH 178/341] docs: Add copy-pasteable commands for installing Mopidy without backend dependencies --- docs/installation/index.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index e01fd994..934c9181 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -40,6 +40,17 @@ Dependencies - see :doc:`libspotify` +To install Mopidy itself (i.e. no backend dependencies), on Debian/Ubuntu:: + + sudo aptitude install python-alsaaudio git-core + git clone git://github.com/jodal/mopidy.git + cd mopidy/ + +And on OS X, assuming you allready got git installed, e.g. from Homebrew:: + + git clone git://github.com/jodal/mopidy.git + cd mopidy/ + Spotify settings ================ From 59aad3e1f76695af2a48e4d01cc4e2ca758f1316 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 20:08:19 +0100 Subject: [PATCH 179/341] docs: More future stuff on the crazy-list --- docs/development/roadmap.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index 26861b06..55ca03cb 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -51,3 +51,7 @@ Crazy stuff we had to write down somewhere stream instead of playing it locally. - Integrate with `Squeezebox `_ in some way. +- AirPort Express support, like in + `PulseAudio `_. +- NAD amplifier mixer through their RS-232 connection. (This I'm actually going + to sooner rather than later. --jodal) From a480046b16f615f9bd728c2e5fa1839a30862cc8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 20:19:28 +0100 Subject: [PATCH 180/341] docs: Denon got almost the same RS-232 interface (says klette) --- docs/development/roadmap.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index 55ca03cb..01fac609 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -53,5 +53,5 @@ Crazy stuff we had to write down somewhere way. - AirPort Express support, like in `PulseAudio `_. -- NAD amplifier mixer through their RS-232 connection. (This I'm actually going - to sooner rather than later. --jodal) +- NAD/Denon amplifier mixer through their RS-232 connection. (This I'm actually + going to sooner rather than later. --jodal) From 434c052cb9888fe67c21c52cf68482028c8af667 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 20:44:09 +0100 Subject: [PATCH 181/341] docs: Add links to despotify/libspotify/pyspotify --- docs/installation/despotify.rst | 3 ++- docs/installation/libspotify.rst | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/installation/despotify.rst b/docs/installation/despotify.rst index fa7af6f5..51742708 100644 --- a/docs/installation/despotify.rst +++ b/docs/installation/despotify.rst @@ -2,7 +2,8 @@ despotify installation ********************** -To use the despotify backend, you first need to install despotify and spytify. +To use the `despotify `_ backend, you first need to +install despotify and spytify. .. note:: diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst index 5ee6c333..b12c4f21 100644 --- a/docs/installation/libspotify.rst +++ b/docs/installation/libspotify.rst @@ -2,9 +2,10 @@ libspotify installation *********************** -As an alternative to the despotify backend, we are working on a libspotify -backend. To use the libspotify backend you must install libspotify and -pyspotify. +As an alternative to the despotify backend, we are working on a +`libspotify `_ backend. +To use the libspotify backend you must install libspotify and +`pyspotify `_. .. note:: From 5b031a12e297d698b0e4af6ab66cf1318bee3890 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Mar 2010 20:44:34 +0100 Subject: [PATCH 182/341] docs: Change Spotify Premium requirement notices from note to warning --- docs/installation/despotify.rst | 2 +- docs/installation/libspotify.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/installation/despotify.rst b/docs/installation/despotify.rst index 51742708..f00a4dee 100644 --- a/docs/installation/despotify.rst +++ b/docs/installation/despotify.rst @@ -5,7 +5,7 @@ despotify installation To use the `despotify `_ backend, you first need to install despotify and spytify. -.. note:: +.. warning:: This backend requires a Spotify premium account. diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst index b12c4f21..c5a0a5d8 100644 --- a/docs/installation/libspotify.rst +++ b/docs/installation/libspotify.rst @@ -7,7 +7,7 @@ As an alternative to the despotify backend, we are working on a To use the libspotify backend you must install libspotify and `pyspotify `_. -.. note:: +.. warning:: This backend requires a Spotify premium account, and it requires you to get an application key from Spotify before use. From baa10f7c91c6527b3ee008086a3a5787cdc0ddc4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Mar 2010 10:18:51 +0100 Subject: [PATCH 183/341] Add link to Mopidy presentation --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 9bca444e..feacb595 100644 --- a/README.rst +++ b/README.rst @@ -13,4 +13,5 @@ Mopidy. * `Source code `_ * `Documentation `_ +* `Presentation of Mopidy `_ * IRC: ``#mopidy`` at `irc.freenode.net `_ From e96751aa4688464be2d63e97323b6d57dfbedfc0 Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 12:43:11 +0100 Subject: [PATCH 184/341] Added Denon mixer support --- mopidy/mixers/denon.py | 27 +++++++++++++++++++++++++++ mopidy/settings/default.py | 16 ++++++++++++++++ tests/__main__.py | 1 + tests/mixers/denontest.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 mopidy/mixers/denon.py create mode 100644 tests/mixers/denontest.py diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py new file mode 100644 index 00000000..2acfa619 --- /dev/null +++ b/mopidy/mixers/denon.py @@ -0,0 +1,27 @@ +from serial import Serial + +from mopidy.mixers import BaseMixer +from mopidy.settings import MIXER_PORT + + +class DenonMixer(BaseMixer): + def __init__(self): + self._device = Serial(port=MIXER_PORT) + self._levels = ['99']+["%(#)02d"% {'#': v} for v in range(0,99)] + self._volume = None + + def _get_volume(self): + # The Denon spec doesnt seem to document + # how to query the volume, so we keep the + # state internally + return self._volume + + def _set_volume(self, volume): + # Clamp according to Denon-spec + if not volume: + volume = 0 + elif volume > 99: + volume = 99 + + self._volume = volume + self._device.write('MV%s\r'% self._levels[volume]) diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index 96d5cd23..d6e18503 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -38,12 +38,28 @@ CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n #: Default on other operating systems:: #: #: MIXER = u'mopidy.mixers.dummy.DummyMixer' +#: +#: *Using external mixers* +#: +#: Using external mixers depends on the pyserial-library, +#: so make sure you have it installed. It also adds one +#: more setting, MIXER_PORT. This must point to the device +#: port like /dev/tty1 or similar. +#: +#: Available external Mixers:: +#: +#: +#: MIXER = u'mopidy.mixers.denon.DenonMixer' +#: MIXER_PORT = u'/dev/tty0' # Verify this manually +#: MIXER = u'mopidy.mixers.dummy.DummyMixer' if sys.platform == 'linux2': MIXER = u'mopidy.mixers.alsa.AlsaMixer' elif sys.platform == 'darwin': MIXER = u'mopidy.mixers.osa.OsaMixer' +MIXER_PORT=None + #: Which address Mopidy should bind to. Examples: #: #: ``localhost`` diff --git a/tests/__main__.py b/tests/__main__.py index 11677b0e..d3adfca0 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -10,6 +10,7 @@ def main(): os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) r = CoverageTestRunner() r.add_pair('mopidy/mixers/dummy.py', 'tests/mixers/dummytest.py') + r.add_pair('mopidy/mixers/denon.py', 'tests/mixers/denontest.py') r.add_pair('mopidy/models.py', 'tests/modelstest.py') r.add_pair('mopidy/mpd/handler.py', 'tests/mpd/handlertest.py') r.run() diff --git a/tests/mixers/denontest.py b/tests/mixers/denontest.py new file mode 100644 index 00000000..a862e402 --- /dev/null +++ b/tests/mixers/denontest.py @@ -0,0 +1,31 @@ +import unittest +import os + +from mopidy.mixers.denon import DenonMixer + +class DenonMixerTest(unittest.TestCase): + def setUp(self): + self.m = DenonMixer() + self.m._device = os.tmpfile() # "Mock" :-) + + def tearDown(self): + self.m._device.close() + + def test_volume_is_None_initially(self): + self.assertEqual(self.m.volume, None) + + def test_volume_set_to_min(self): + self.m.volume = 0 + self.assertEqual(self.m.volume, 0) + + def test_volume_set_to_max(self): + self.m.volume = 100 + self.assertEqual(self.m.volume, 99) + + def test_volume_set_to_below_min_results_in_min(self): + self.m.volume = -10 + self.assertEqual(self.m.volume, 0) + + def test_volume_set_to_above_max_results_in_max(self): + self.m.volume = 110 + self.assertEqual(self.m.volume, 99) From 867c8fbc507f53e2921a5217cdec48d38104fe3a Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 13:16:56 +0100 Subject: [PATCH 185/341] Added reading of volume from Denon devices that support it, and reopen socket if needed --- mopidy/mixers/denon.py | 17 ++++++++++++----- tests/mixers/denontest.py | 24 ++++++++++++++++++++---- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 2acfa619..78bb27a0 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -6,14 +6,19 @@ from mopidy.settings import MIXER_PORT class DenonMixer(BaseMixer): def __init__(self): - self._device = Serial(port=MIXER_PORT) + self._device = Serial(port=MIXER_PORT, timeout=0.2) self._levels = ['99']+["%(#)02d"% {'#': v} for v in range(0,99)] self._volume = None def _get_volume(self): - # The Denon spec doesnt seem to document - # how to query the volume, so we keep the - # state internally + try: + self._device.write('MV?\r') + vol = self._device.read(2) + if vol: + return self._levels.index(int(vol)) + except: + pass # No support for volume query on device + return self._volume def _set_volume(self, volume): @@ -23,5 +28,7 @@ class DenonMixer(BaseMixer): elif volume > 99: volume = 99 - self._volume = volume + if not self._device.isOpen(): + self._device.open() self._device.write('MV%s\r'% self._levels[volume]) + self._volume = volume diff --git a/tests/mixers/denontest.py b/tests/mixers/denontest.py index a862e402..6256ac38 100644 --- a/tests/mixers/denontest.py +++ b/tests/mixers/denontest.py @@ -3,13 +3,24 @@ import os from mopidy.mixers.denon import DenonMixer +class DenonMixerDeviceMock(object): + def __init__(self): + self._open = True + self.ret_val = bytes('00') + + def write(self, x): + pass + def read(self, x): + return self.ret_val + def isOpen(self): + return self._open + def open(self): + self._open = True + class DenonMixerTest(unittest.TestCase): def setUp(self): self.m = DenonMixer() - self.m._device = os.tmpfile() # "Mock" :-) - - def tearDown(self): - self.m._device.close() + self.m._device = DenonMixerDeviceMock() def test_volume_is_None_initially(self): self.assertEqual(self.m.volume, None) @@ -29,3 +40,8 @@ class DenonMixerTest(unittest.TestCase): def test_volume_set_to_above_max_results_in_max(self): self.m.volume = 110 self.assertEqual(self.m.volume, 99) + + def test_reopen_device(self): + self.m._device._open = False + self.m.volume = 10 + self.assertTrue(self.m._device._open) From 9e411e4de32b4e76e9987e952385f677566bed85 Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 19:27:57 +0100 Subject: [PATCH 186/341] External mixers dont have undefined volume --- tests/mixers/denontest.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/mixers/denontest.py b/tests/mixers/denontest.py index 6256ac38..a26dcff0 100644 --- a/tests/mixers/denontest.py +++ b/tests/mixers/denontest.py @@ -22,9 +22,6 @@ class DenonMixerTest(unittest.TestCase): self.m = DenonMixer() self.m._device = DenonMixerDeviceMock() - def test_volume_is_None_initially(self): - self.assertEqual(self.m.volume, None) - def test_volume_set_to_min(self): self.m.volume = 0 self.assertEqual(self.m.volume, 0) From eee09a54d2efbbdccf4868f52b915fed76a9c24f Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 19:28:31 +0100 Subject: [PATCH 187/341] Ask device for volume. Remove internal state. --- mopidy/mixers/denon.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 78bb27a0..ba6efd86 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -11,15 +11,9 @@ class DenonMixer(BaseMixer): self._volume = None def _get_volume(self): - try: - self._device.write('MV?\r') - vol = self._device.read(2) - if vol: - return self._levels.index(int(vol)) - except: - pass # No support for volume query on device - - return self._volume + self._device.write('MV?\r') + vol = self._device.read(20)[2:4] + return self._levels.index(vol) def _set_volume(self, volume): # Clamp according to Denon-spec From 49171eec5b318788343d04c9abaadac6c943126d Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 19:29:14 +0100 Subject: [PATCH 188/341] Denon devices return the current volume on set. --- mopidy/mixers/denon.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index ba6efd86..8b9b7ef1 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -25,4 +25,5 @@ class DenonMixer(BaseMixer): if not self._device.isOpen(): self._device.open() self._device.write('MV%s\r'% self._levels[volume]) - self._volume = volume + vol = self._device.read(20)[2:4] + self._volume = self._levels.index(vol) From 631983a14f941fa745b6e7f4b32fe1ef697d5703 Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 19:29:54 +0100 Subject: [PATCH 189/341] Update denon device mock to reflect mixer changes --- tests/mixers/denontest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/mixers/denontest.py b/tests/mixers/denontest.py index a26dcff0..ad7743ce 100644 --- a/tests/mixers/denontest.py +++ b/tests/mixers/denontest.py @@ -6,10 +6,11 @@ from mopidy.mixers.denon import DenonMixer class DenonMixerDeviceMock(object): def __init__(self): self._open = True - self.ret_val = bytes('00') + self.ret_val = bytes('MV00\r') def write(self, x): - pass + if x[2] != '?': + self.ret_val = bytes(x) def read(self, x): return self.ret_val def isOpen(self): From 6f13a49e199dc25e377ba9bb5b31e0e99ef2b906 Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 21:19:31 +0100 Subject: [PATCH 190/341] Log volume on _get_volume --- mopidy/mixers/denon.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 8b9b7ef1..236a33a9 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -1,8 +1,11 @@ +import logging + from serial import Serial from mopidy.mixers import BaseMixer from mopidy.settings import MIXER_PORT +logger = logging.get_logger(u'mopidy.mixers.denon') class DenonMixer(BaseMixer): def __init__(self): @@ -13,6 +16,7 @@ class DenonMixer(BaseMixer): def _get_volume(self): self._device.write('MV?\r') vol = self._device.read(20)[2:4] + logger.debug(u'Volume: %s' % self._levels.index(vol)) return self._levels.index(vol) def _set_volume(self, volume): From 5e7b61449fad0221342f32ba0a650a1a6ba01e58 Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 21:20:36 +0100 Subject: [PATCH 191/341] Volume filtered by contract --- mopidy/mixers/denon.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 236a33a9..b3b23771 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -21,9 +21,7 @@ class DenonMixer(BaseMixer): def _set_volume(self, volume): # Clamp according to Denon-spec - if not volume: - volume = 0 - elif volume > 99: + if volume > 99: volume = 99 if not self._device.isOpen(): From 85c47977808cfa29472c7fcd0063703e77aa0c80 Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 21:21:12 +0100 Subject: [PATCH 192/341] Ensure device connection is open on write and read --- mopidy/mixers/denon.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index b3b23771..64d3db4d 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -14,6 +14,7 @@ class DenonMixer(BaseMixer): self._volume = None def _get_volume(self): + self.ensure_open_device() self._device.write('MV?\r') vol = self._device.read(20)[2:4] logger.debug(u'Volume: %s' % self._levels.index(vol)) @@ -24,8 +25,13 @@ class DenonMixer(BaseMixer): if volume > 99: volume = 99 - if not self._device.isOpen(): - self._device.open() + self.ensure_open_device() self._device.write('MV%s\r'% self._levels[volume]) vol = self._device.read(20)[2:4] self._volume = self._levels.index(vol) + logger.debug(u'Volume: %s' % self._volume) + + def ensure_open_device(self): + if not self._device.isOpen(): + logger.debug(u'(re)connecting to Denon device') + self._device.open() From b9809411724c20d3198bd4c35e08593186e91e63 Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 21:21:57 +0100 Subject: [PATCH 193/341] Volume starts as 0 --- mopidy/mixers/denon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 64d3db4d..895ae36b 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -11,7 +11,7 @@ class DenonMixer(BaseMixer): def __init__(self): self._device = Serial(port=MIXER_PORT, timeout=0.2) self._levels = ['99']+["%(#)02d"% {'#': v} for v in range(0,99)] - self._volume = None + self._volume = 0 def _get_volume(self): self.ensure_open_device() From cd5c2ed8837fbd05998664e2f0fe31008b6a2fcf Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 22:38:10 +0100 Subject: [PATCH 194/341] Ensure thread-safety on serialport IO. And fixed logging --- mopidy/mixers/denon.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 895ae36b..0537609a 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -1,35 +1,39 @@ import logging +from threading import Lock from serial import Serial from mopidy.mixers import BaseMixer from mopidy.settings import MIXER_PORT -logger = logging.get_logger(u'mopidy.mixers.denon') +logger = logging.getLogger(u'mopidy.mixers.denon') class DenonMixer(BaseMixer): def __init__(self): self._device = Serial(port=MIXER_PORT, timeout=0.2) self._levels = ['99']+["%(#)02d"% {'#': v} for v in range(0,99)] self._volume = 0 + self._lock = Lock() def _get_volume(self): + self._lock.acquire(); self.ensure_open_device() self._device.write('MV?\r') - vol = self._device.read(20)[2:4] - logger.debug(u'Volume: %s' % self._levels.index(vol)) + vol = self._device.readline()[2:4] + self._lock.release() + logger.debug(u'_get_volume() = %s' % vol) return self._levels.index(vol) def _set_volume(self, volume): # Clamp according to Denon-spec if volume > 99: volume = 99 - + self._lock.acquire() self.ensure_open_device() self._device.write('MV%s\r'% self._levels[volume]) - vol = self._device.read(20)[2:4] + vol = self._device.readline()[2:4] + self._lock.release() self._volume = self._levels.index(vol) - logger.debug(u'Volume: %s' % self._volume) def ensure_open_device(self): if not self._device.isOpen(): From ed2b64448aaf3608ea6ed826b255e048964ffd81 Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 22:39:20 +0100 Subject: [PATCH 195/341] Ensure we have proper variable types for lookups. --- mopidy/mixers/denon.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 0537609a..45647b72 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -19,12 +19,13 @@ class DenonMixer(BaseMixer): self._lock.acquire(); self.ensure_open_device() self._device.write('MV?\r') - vol = self._device.readline()[2:4] + vol = str(self._device.readline()[2:4]) self._lock.release() logger.debug(u'_get_volume() = %s' % vol) return self._levels.index(vol) def _set_volume(self, volume): + volume = int(volume) # Clamp according to Denon-spec if volume > 99: volume = 99 From 2e1e595bbc55162cb9c39e969744c300fd5e32ad Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 22:50:17 +0100 Subject: [PATCH 196/341] Add Kristian Klette to AUTHORS --- AUTHORS.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index 65e7d950..357cb311 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -6,3 +6,4 @@ Contributors to Mopidy in the order of appearance: * Stein Magnus Jodal * Johannes Knutsen * Thomas Adamcik +* Kristian Klette From 39cfb9a07f271ff7bbc05a3872538d281d3ebd9b Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 22:50:48 +0100 Subject: [PATCH 197/341] Fixed doc styling on external mixers part --- mopidy/settings/default.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index d6e18503..a89bc3a6 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -44,13 +44,15 @@ CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n #: Using external mixers depends on the pyserial-library, #: so make sure you have it installed. It also adds one #: more setting, MIXER_PORT. This must point to the device -#: port like /dev/tty1 or similar. +#: port like /dev/tty1 or similar. Example:: #: -#: Available external Mixers:: +#: MIXER_PORT = u'/dev/ttyUSB0' # Verify this manually #: +#: **Available external Mixers** +#: +#: Denon AVR/AVC via RS-232:: #: #: MIXER = u'mopidy.mixers.denon.DenonMixer' -#: MIXER_PORT = u'/dev/tty0' # Verify this manually #: MIXER = u'mopidy.mixers.dummy.DummyMixer' if sys.platform == 'linux2': @@ -58,7 +60,7 @@ if sys.platform == 'linux2': elif sys.platform == 'darwin': MIXER = u'mopidy.mixers.osa.OsaMixer' -MIXER_PORT=None +MIXER_PORT = None #: Which address Mopidy should bind to. Examples: #: From 5f0511aeaea20c7ce99016a0b8baf7c378942147 Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 23:07:35 +0100 Subject: [PATCH 198/341] Add space before % --- mopidy/mixers/denon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 45647b72..78ed0983 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -11,7 +11,7 @@ logger = logging.getLogger(u'mopidy.mixers.denon') class DenonMixer(BaseMixer): def __init__(self): self._device = Serial(port=MIXER_PORT, timeout=0.2) - self._levels = ['99']+["%(#)02d"% {'#': v} for v in range(0,99)] + self._levels = ['99']+["%(#)02d" % {'#': v} for v in range(0,99)] self._volume = 0 self._lock = Lock() From 2818e9de59f824ce041d026130f805166773473e Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 23:18:02 +0100 Subject: [PATCH 199/341] Update external mixer documentation --- mopidy/settings/default.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index a89bc3a6..94c5ffdb 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -39,16 +39,10 @@ CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n #: #: MIXER = u'mopidy.mixers.dummy.DummyMixer' #: -#: *Using external mixers* +#: **Available external mixers** #: -#: Using external mixers depends on the pyserial-library, -#: so make sure you have it installed. It also adds one -#: more setting, MIXER_PORT. This must point to the device -#: port like /dev/tty1 or similar. Example:: -#: -#: MIXER_PORT = u'/dev/ttyUSB0' # Verify this manually -#: -#: **Available external Mixers** +#: .. note:: +#: Using external mixers depends on the pyserial library. #: #: Denon AVR/AVC via RS-232:: #: @@ -60,6 +54,8 @@ if sys.platform == 'linux2': elif sys.platform == 'darwin': MIXER = u'mopidy.mixers.osa.OsaMixer' +#: Which port a mixer is connected to if using an external mixer. +#: This must point to the device port like ``/dev/ttyUSB0`` or similar. MIXER_PORT = None #: Which address Mopidy should bind to. Examples: From ea45bb899332965a39621670393df3f643f7b6e3 Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 23:19:13 +0100 Subject: [PATCH 200/341] Fix typo in external mixer documentation --- mopidy/settings/default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index 94c5ffdb..7f6d1d88 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -54,7 +54,7 @@ if sys.platform == 'linux2': elif sys.platform == 'darwin': MIXER = u'mopidy.mixers.osa.OsaMixer' -#: Which port a mixer is connected to if using an external mixer. +#: Which port the mixer is connected to if using an external mixer. #: This must point to the device port like ``/dev/ttyUSB0`` or similar. MIXER_PORT = None From 850277afd190e69a4a8feb533a9eec312da8f37f Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 23:21:14 +0100 Subject: [PATCH 201/341] Style fix. Add some spacing --- mopidy/mixers/denon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 78ed0983..118abf28 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -11,7 +11,7 @@ logger = logging.getLogger(u'mopidy.mixers.denon') class DenonMixer(BaseMixer): def __init__(self): self._device = Serial(port=MIXER_PORT, timeout=0.2) - self._levels = ['99']+["%(#)02d" % {'#': v} for v in range(0,99)] + self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)] self._volume = 0 self._lock = Lock() From 11ad608c75a7175f24e72e5f9d2337511527235d Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 23:21:40 +0100 Subject: [PATCH 202/341] Remove redundant type-conversion --- mopidy/mixers/denon.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 118abf28..de0968a9 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -25,7 +25,6 @@ class DenonMixer(BaseMixer): return self._levels.index(vol) def _set_volume(self, volume): - volume = int(volume) # Clamp according to Denon-spec if volume > 99: volume = 99 From 55fe11046fe043648f69534410b91832ab598a88 Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 23:29:25 +0100 Subject: [PATCH 203/341] Added some module documentation to DenonMixer --- mopidy/mixers/denon.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index de0968a9..374875b9 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -8,6 +8,23 @@ from mopidy.settings import MIXER_PORT logger = logging.getLogger(u'mopidy.mixers.denon') +#: Mixer for controlling Denon recivers and amplifiers using the RS-232 protocol. +#: +#: Connects using the serial specifications from +#: Denon's RS-232 Protocol specification. +#: +#: Communication speed : 9600bps +#: Character length : 8 bits +#: Parity control : None +#: Start bit : 1 bit +#: Stop bit : 1 bit +#: Communication procedure : Non procedural +#: Communication data length : 135 bytes (maximum) +#: +#: The external mixer is the authoritative source for the current volume. +#: This allows the user to use his remote control the volume without +#: mopidy cancelling the volume setting. + class DenonMixer(BaseMixer): def __init__(self): self._device = Serial(port=MIXER_PORT, timeout=0.2) From 87ac261b5efff2bc220ee08fa799cf376a5254b7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Mar 2010 23:31:46 +0100 Subject: [PATCH 204/341] Add NadMixer for controlling volume directly at NAD amplifiers --- mopidy/mixers/nad.py | 129 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 mopidy/mixers/nad.py diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py new file mode 100644 index 00000000..e1cbc1ec --- /dev/null +++ b/mopidy/mixers/nad.py @@ -0,0 +1,129 @@ +import logging +from serial import Serial +from threading import Lock + +from mopidy.mixers import BaseMixer +from mopidy.settings import MIXER_PORT + +logger = logging.getLogger('mopidy.mixers.nad') + +class NadMixer(BaseMixer): + """ + The NAD mixer was created using a NAD C 355BEE amplifier, but should also + work with other NAD amplifiers supporting the same RS-232 protocol. The C + 355BEE does not give you access to the current volume. It only supports + increasing or decreasing the volume one step at the time. Other NAD + amplifiers may support more advanced volume adjustment than what is + currently used by this mixer. + """ + + #: Number of volume levels the device supports + NUM_STEPS = 40 + + def __init__(self): + #: Volume in range [0..100]. :class:`None` before calibration. + self._volume = None + #: Volume in range [0..NUM_STEPS]. :class:`None` before calibration. + self._nad_volume = None + #: Acquire this lock before you touch the device. + self._lock = Lock() + #: The serial device through which we talk to the amplifier. + #: + #: If you set the timeout too low, the reads will never get complete + #: confirmations and calibration will decrease volume forever. + self._device = Serial(port=MIXER_PORT, baudrate=115200, timeout=0.2) + self._clear() + self._device_model = self._get_device_model() + self._calibrate() + + def _get_device_model(self): + self._write('Main.Model?') + result = '' + while len(result) < 2: + result = self._readline() + result = result.replace('Main.Model=', '') + logger.info(u'Connected to device of model "%s"' % result) + return result + + def _calibrate(self): + """ + The NAD C 355BEE amplifier has 40 different volume levels. We have no + way of asking on which level we are. Thus, we must calibrate the mixer + by decreasing the volume 39 times. + """ + logger.info(u'Calibrating NAD amplifier') + steps_left = self.NUM_STEPS - 1 + while steps_left: + if self._decrease_volume(): + steps_left -= 1 + self._volume = 0 + self._nad_volume = 0 + + def _get_volume(self): + """ + Return volume as set by client, and not a translation from + the internal volume with the same discrete steps as the device. + + If we used a translation from the internal volume, _get_volume would + not match what the client selected and _set_volume received, which will + make the volume controller "skip". This is particularily irritating in + console clients where you use +/- to adjust volume. E.g. "get: 50, + press -, set: 49, (wait 1 sec), repeat". You will never get to 48 + without pressing minus faster. + """ + return self._volume + + def _set_volume(self, volume): + self._volume = volume + self._set_nad_volume(int(round(volume * self.NUM_STEPS / 100.0))) + + def _set_nad_volume(self, target_volume): + """ + Increase or decrease the amplifier volume until it matches the given + target volume. Only calls to increase and decrease that returns + :class:`True` are counted against the internal volume. + """ + if self._nad_volume is None: + raise Exception(u'Calibration needed') + while target_volume > self._nad_volume: + if self._increase_volume(): + self._nad_volume += 1 + while target_volume < self._nad_volume: + if self._decrease_volume(): + self._nad_volume -= 1 + + def _increase_volume(self): + self._write('Main.Volume+') + return self._readline() == 'Main.Volume+' + + def _decrease_volume(self): + self._write('Main.Volume-') + return self._readline() == 'Main.Volume-' + + def _clear(self): + """Clear input and output buffers while keeping the lock.""" + self._lock.acquire() + self._device.flushInput() + self._device.flushOutput() + self._lock.release() + + def _write(self, data): + """Write and flush data to device while keeping the lock.""" + self._lock.acquire() + if not self._device.isOpen(): + self._device.open() + self._device.write('\r%s\r' % data) + self._device.flush() + self._lock.release() + + def _readline(self): + """ + Read line from device while keeping the lock. The result is stripped + for leading and trailing whitespace. + """ + self._lock.acquire() + if not self._device.isOpen(): + self._device.open() + result = self._device.readline(eol='\r') + self._lock.release() + return result.strip() From cb6a2b18495c9731b47d14a983b4f42ddbd07bad Mon Sep 17 00:00:00 2001 From: Kristian Date: Wed, 10 Mar 2010 23:32:13 +0100 Subject: [PATCH 205/341] Convert DenonMixer module doc to docstring --- mopidy/mixers/denon.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 374875b9..426b2db3 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -8,22 +8,24 @@ from mopidy.settings import MIXER_PORT logger = logging.getLogger(u'mopidy.mixers.denon') -#: Mixer for controlling Denon recivers and amplifiers using the RS-232 protocol. -#: -#: Connects using the serial specifications from -#: Denon's RS-232 Protocol specification. -#: -#: Communication speed : 9600bps -#: Character length : 8 bits -#: Parity control : None -#: Start bit : 1 bit -#: Stop bit : 1 bit -#: Communication procedure : Non procedural -#: Communication data length : 135 bytes (maximum) -#: -#: The external mixer is the authoritative source for the current volume. -#: This allows the user to use his remote control the volume without -#: mopidy cancelling the volume setting. +""" + Mixer for controlling Denon recivers and amplifiers using the RS-232 protocol. + + Connects using the serial specifications from + Denon's RS-232 Protocol specification. + + Communication speed : 9600bps + Character length : 8 bits + Parity control : None + Start bit : 1 bit + Stop bit : 1 bit + Communication procedure : Non procedural + Communication data length : 135 bytes (maximum) + + The external mixer is the authoritative source for the current volume. + This allows the user to use his remote control the volume without + mopidy cancelling the volume setting. +""" class DenonMixer(BaseMixer): def __init__(self): From fcbadcf971491ee2978599a3b1da3e79c7fe37c7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Mar 2010 23:45:32 +0100 Subject: [PATCH 206/341] NadMixer: Recalibrate if volume is set to 0 --- mopidy/mixers/nad.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index e1cbc1ec..c117b4e4 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -15,6 +15,11 @@ class NadMixer(BaseMixer): increasing or decreasing the volume one step at the time. Other NAD amplifiers may support more advanced volume adjustment than what is currently used by this mixer. + + Sadly, this means that if you use the remote control to change the volume + on the amplifier, Mopidy will no longer report the correct volume. To + recalibrate the mixer, set the volume to 0, and then back again to the + level you want. """ #: Number of volume levels the device supports @@ -75,6 +80,8 @@ class NadMixer(BaseMixer): def _set_volume(self, volume): self._volume = volume + if volume == 0: + self._calibrate() # Recalibrate internal volume self._set_nad_volume(int(round(volume * self.NUM_STEPS / 100.0))) def _set_nad_volume(self, target_volume): From 560dc9245f66536d7c2c4fc181014b8b1dc99ccd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Mar 2010 00:51:15 +0100 Subject: [PATCH 207/341] NadMixer: Move everything thouching the serial device into its own Process --- mopidy/mixers/nad.py | 79 ++++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index c117b4e4..77439312 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -1,6 +1,6 @@ import logging from serial import Serial -from threading import Lock +from multiprocessing import Process, Pipe from mopidy.mixers import BaseMixer from mopidy.settings import MIXER_PORT @@ -22,24 +22,45 @@ class NadMixer(BaseMixer): level you want. """ + def __init__(self): + self._volume = None + self._pipe, other_end = Pipe() + NadTalker(pipe=other_end).start() + + def _get_volume(self): + return self._volume + + def _set_volume(self, volume): + self._volume = volume + if volume == 0: + self._pipe.send({'command': 'calibrate'}) + self._pipe.send({'command': 'set_volume', 'volume': volume}) + + +class NadTalker(Process): #: Number of volume levels the device supports NUM_STEPS = 40 - def __init__(self): - #: Volume in range [0..100]. :class:`None` before calibration. - self._volume = None - #: Volume in range [0..NUM_STEPS]. :class:`None` before calibration. - self._nad_volume = None - #: Acquire this lock before you touch the device. - self._lock = Lock() - #: The serial device through which we talk to the amplifier. - #: - #: If you set the timeout too low, the reads will never get complete - #: confirmations and calibration will decrease volume forever. + #: Volume in range [0..NUM_STEPS]. :class:`None` before calibration. + _nad_volume = None + + def __init__(self, pipe=None): + Process.__init__(self) + self.pipe = pipe + + def run(self): + # If you set the timeout too low, the reads will never get complete + # confirmations and calibration will decrease volume forever. self._device = Serial(port=MIXER_PORT, baudrate=115200, timeout=0.2) self._clear() self._device_model = self._get_device_model() self._calibrate() + while self.pipe.poll(None): + message = self.pipe.recv() + if message['command'] == 'set_volume': + self._set_volume(message['volume']) + elif message['command'] == 'calibrate': + self._calibrate() def _get_device_model(self): self._write('Main.Model?') @@ -61,27 +82,11 @@ class NadMixer(BaseMixer): while steps_left: if self._decrease_volume(): steps_left -= 1 - self._volume = 0 self._nad_volume = 0 - - def _get_volume(self): - """ - Return volume as set by client, and not a translation from - the internal volume with the same discrete steps as the device. - - If we used a translation from the internal volume, _get_volume would - not match what the client selected and _set_volume received, which will - make the volume controller "skip". This is particularily irritating in - console clients where you use +/- to adjust volume. E.g. "get: 50, - press -, set: 49, (wait 1 sec), repeat". You will never get to 48 - without pressing minus faster. - """ - return self._volume + logger.info(u'Calibration of NAD amplifier done') def _set_volume(self, volume): - self._volume = volume - if volume == 0: - self._calibrate() # Recalibrate internal volume + logger.debug(u'Setting volume to %d' % volume) self._set_nad_volume(int(round(volume * self.NUM_STEPS / 100.0))) def _set_nad_volume(self, target_volume): @@ -108,29 +113,23 @@ class NadMixer(BaseMixer): return self._readline() == 'Main.Volume-' def _clear(self): - """Clear input and output buffers while keeping the lock.""" - self._lock.acquire() + """Clear input and output buffers.""" self._device.flushInput() self._device.flushOutput() - self._lock.release() def _write(self, data): - """Write and flush data to device while keeping the lock.""" - self._lock.acquire() + """Write and flush data to device.""" if not self._device.isOpen(): self._device.open() self._device.write('\r%s\r' % data) self._device.flush() - self._lock.release() def _readline(self): """ - Read line from device while keeping the lock. The result is stripped - for leading and trailing whitespace. + Read line from device. The result is stripped for leading and trailing + whitespace. """ - self._lock.acquire() if not self._device.isOpen(): self._device.open() result = self._device.readline(eol='\r') - self._lock.release() return result.strip() From 7da3e73dbb26965d368730205eb4db1e8d63d88e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 00:18:16 +0100 Subject: [PATCH 208/341] NadMixer: Works nicely. Added power, speaker and source support. --- mopidy/mixers/nad.py | 137 +++++++++++++++++++++++++++++-------------- 1 file changed, 94 insertions(+), 43 deletions(-) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 77439312..297e9d62 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -1,6 +1,6 @@ import logging from serial import Serial -from multiprocessing import Process, Pipe +from multiprocessing import Pipe, Process from mopidy.mixers import BaseMixer from mopidy.settings import MIXER_PORT @@ -10,16 +10,18 @@ logger = logging.getLogger('mopidy.mixers.nad') class NadMixer(BaseMixer): """ The NAD mixer was created using a NAD C 355BEE amplifier, but should also - work with other NAD amplifiers supporting the same RS-232 protocol. The C - 355BEE does not give you access to the current volume. It only supports - increasing or decreasing the volume one step at the time. Other NAD - amplifiers may support more advanced volume adjustment than what is + work with other NAD amplifiers supporting the same RS-232 protocol (v2.x). + The C 355BEE does not give you access to the current volume. It only + supports increasing or decreasing the volume one step at the time. Other + NAD amplifiers may support more advanced volume adjustment than what is currently used by this mixer. Sadly, this means that if you use the remote control to change the volume on the amplifier, Mopidy will no longer report the correct volume. To recalibrate the mixer, set the volume to 0, and then back again to the - level you want. + level you want. This will reset the amplifier to a known state, including + powering on the device, selecting the configured speakers and input + sources. """ def __init__(self): @@ -33,14 +35,30 @@ class NadMixer(BaseMixer): def _set_volume(self, volume): self._volume = volume if volume == 0: - self._pipe.send({'command': 'calibrate'}) + self._pipe.send({'command': 'reset_device'}) self._pipe.send({'command': 'set_volume', 'volume': volume}) class NadTalker(Process): + #: Timeout in seconds used for read/write operations. + #: + #: If you set the timeout too low, the reads will never get complete + #: confirmations and calibration will decrease volume forever. If you set + #: the timeout too high, stuff takes more time. + TIMEOUT = 0.2 + #: Number of volume levels the device supports NUM_STEPS = 40 + #: The amplifier source to use + SOURCE = 'Aux' + + #: State of speakers A + SPEAKERS_A = 'On' + + #: State of speakers B + SPEAKERS_B = 'Off' + #: Volume in range [0..NUM_STEPS]. :class:`None` before calibration. _nad_volume = None @@ -49,29 +67,63 @@ class NadTalker(Process): self.pipe = pipe def run(self): - # If you set the timeout too low, the reads will never get complete - # confirmations and calibration will decrease volume forever. - self._device = Serial(port=MIXER_PORT, baudrate=115200, timeout=0.2) - self._clear() - self._device_model = self._get_device_model() - self._calibrate() + self._open_connection() + self._set_device_to_known_state() while self.pipe.poll(None): message = self.pipe.recv() if message['command'] == 'set_volume': self._set_volume(message['volume']) - elif message['command'] == 'calibrate': - self._calibrate() + elif message['command'] == 'reset_device': + self._set_device_to_known_state() + + def _open_connection(self): + """ + Opens serial connection to the device. + + Communication settings: 115200 bps 8N1 + """ + self._device = Serial(port=MIXER_PORT, baudrate=115200, + timeout=self.TIMEOUT) + self._get_device_model() + + def _set_device_to_known_state(self): + self._power_device_on() + self._select_speakers() + self._select_input_source() + self._calibrate_volume() def _get_device_model(self): - self._write('Main.Model?') - result = '' - while len(result) < 2: - result = self._readline() - result = result.replace('Main.Model=', '') - logger.info(u'Connected to device of model "%s"' % result) - return result + model = self._ask_device('Main.Model') + logger.info(u'Connected to device of model "%s"' % model) + return model - def _calibrate(self): + def _power_device_on(self): + logger.info(u'Powering device on') + while self._ask_device('Main.Power') != 'On': + self._command_device('Main.Power', 'On') + + def _select_speakers(self): + logger.info(u'Setting speakers A "%s"', self.SPEAKERS_A) + while self._ask_device('Main.SpeakerA') != self.SPEAKERS_A: + self._command_device('Main.SpeakerA', self.SPEAKERS_A) + logger.info(u'Setting speakers B "%s"', self.SPEAKERS_B) + while self._ask_device('Main.SpeakerB') != self.SPEAKERS_B: + self._command_device('Main.SpeakerB', self.SPEAKERS_B) + + def _select_input_source(self): + logger.info(u'Selecting input source "%s"', self.SOURCE) + while self._ask_device('Main.Source') != self.SOURCE: + self._command_device('Main.Source', self.SOURCE) + + def _ask_device(self, key): + self._write('%s?' % key) + return self._readline().replace('%s=' % key, '') + + def _command_device(self, key, value): + self._write('%s=%s' % (key, value)) + self._readline() + + def _calibrate_volume(self): """ The NAD C 355BEE amplifier has 40 different volume levels. We have no way of asking on which level we are. Thus, we must calibrate the mixer @@ -83,24 +135,21 @@ class NadTalker(Process): if self._decrease_volume(): steps_left -= 1 self._nad_volume = 0 - logger.info(u'Calibration of NAD amplifier done') + logger.info(u'Done calibrating NAD amplifier') def _set_volume(self, volume): - logger.debug(u'Setting volume to %d' % volume) - self._set_nad_volume(int(round(volume * self.NUM_STEPS / 100.0))) - - def _set_nad_volume(self, target_volume): """ Increase or decrease the amplifier volume until it matches the given - target volume. Only calls to increase and decrease that returns - :class:`True` are counted against the internal volume. + target volume. """ + logger.debug(u'Setting volume to %d' % volume) + target_nad_volume = int(round(volume * self.NUM_STEPS / 100.0)) if self._nad_volume is None: - raise Exception(u'Calibration needed') - while target_volume > self._nad_volume: + return # Calibration needed + while target_nad_volume > self._nad_volume: if self._increase_volume(): self._nad_volume += 1 - while target_volume < self._nad_volume: + while target_nad_volume < self._nad_volume: if self._decrease_volume(): self._nad_volume -= 1 @@ -112,17 +161,17 @@ class NadTalker(Process): self._write('Main.Volume-') return self._readline() == 'Main.Volume-' - def _clear(self): - """Clear input and output buffers.""" - self._device.flushInput() - self._device.flushOutput() - def _write(self, data): - """Write and flush data to device.""" + """ + Write data to device. + + Prepends and appends a newline to the data, as recommended by the NAD + documentation. + """ if not self._device.isOpen(): self._device.open() - self._device.write('\r%s\r' % data) - self._device.flush() + self._device.write('\n%s\n' % data) + logger.debug('Write: %s', data) def _readline(self): """ @@ -131,5 +180,7 @@ class NadTalker(Process): """ if not self._device.isOpen(): self._device.open() - result = self._device.readline(eol='\r') - return result.strip() + result = self._device.readline(eol='\n').strip() + if result: + logger.debug('Read: %s', result) + return result From 549f98b5822b73c97a95bd4fc9cba5c1e8ef9909 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 00:23:07 +0100 Subject: [PATCH 209/341] NadMixer: Only log if we're going to do anything --- mopidy/mixers/nad.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 297e9d62..9decf233 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -98,21 +98,21 @@ class NadTalker(Process): return model def _power_device_on(self): - logger.info(u'Powering device on') while self._ask_device('Main.Power') != 'On': + logger.info(u'Powering device on') self._command_device('Main.Power', 'On') def _select_speakers(self): - logger.info(u'Setting speakers A "%s"', self.SPEAKERS_A) while self._ask_device('Main.SpeakerA') != self.SPEAKERS_A: + logger.info(u'Setting speakers A "%s"', self.SPEAKERS_A) self._command_device('Main.SpeakerA', self.SPEAKERS_A) - logger.info(u'Setting speakers B "%s"', self.SPEAKERS_B) while self._ask_device('Main.SpeakerB') != self.SPEAKERS_B: + logger.info(u'Setting speakers B "%s"', self.SPEAKERS_B) self._command_device('Main.SpeakerB', self.SPEAKERS_B) def _select_input_source(self): - logger.info(u'Selecting input source "%s"', self.SOURCE) while self._ask_device('Main.Source') != self.SOURCE: + logger.info(u'Selecting input source "%s"', self.SOURCE) self._command_device('Main.Source', self.SOURCE) def _ask_device(self, key): From 9a298795d6da9ec665f5b328107d874ec1a44eb5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 00:23:24 +0100 Subject: [PATCH 210/341] NadMixer: Add unmuting to device resetting --- mopidy/mixers/nad.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 9decf233..43dbfdda 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -90,6 +90,7 @@ class NadTalker(Process): self._power_device_on() self._select_speakers() self._select_input_source() + self._unmute() self._calibrate_volume() def _get_device_model(self): @@ -115,6 +116,11 @@ class NadTalker(Process): logger.info(u'Selecting input source "%s"', self.SOURCE) self._command_device('Main.Source', self.SOURCE) + def _unmute(self): + while self._ask_device('Main.Mute') != 'Off': + logger.info(u'Unmuting device') + self._command_device('Main.Mute', 'Off') + def _ask_device(self, key): self._write('%s?' % key) return self._readline().replace('%s=' % key, '') From 9437db3c38cdace7fb61eca2d95d479632b396ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 00:35:37 +0100 Subject: [PATCH 211/341] DenonMixer: Add readline() method to mock --- tests/mixers/denontest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/mixers/denontest.py b/tests/mixers/denontest.py index ad7743ce..e57f0c82 100644 --- a/tests/mixers/denontest.py +++ b/tests/mixers/denontest.py @@ -11,10 +11,16 @@ class DenonMixerDeviceMock(object): def write(self, x): if x[2] != '?': self.ret_val = bytes(x) + def read(self, x): return self.ret_val + + def readline(self): + return self.ret_val + def isOpen(self): return self._open + def open(self): self._open = True From 1729d9e9627026535e64156eda28cb99ad387517 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 00:37:34 +0100 Subject: [PATCH 212/341] Use 2^31 - 1 instead of 2^31 to keep inside 32-bit integer (good I'm varying what platforms I work at) --- tests/mpd/handlertest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index f996b33f..c030ae94 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -214,7 +214,7 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_contains_playlist(self): result = dict(self.h._status_status()) self.assert_('playlist' in result) - self.assert_(int(result['playlist']) in xrange(0, 2**31)) + self.assert_(int(result['playlist']) in xrange(0, 2**31 - 1)) def test_status_method_contains_playlistlength(self): result = dict(self.h._status_status()) From 63cdcb5080f8d2db407e8793151a1a47bababb08 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 18:18:00 +0100 Subject: [PATCH 213/341] docs: Add docs for all available mixers --- docs/api/mixers.rst | 59 ++++++++++++++++++++++++++++++++++++++++++ mopidy/mixers/alsa.py | 5 ++++ mopidy/mixers/denon.py | 32 ++++++++++------------- mopidy/mixers/dummy.py | 2 ++ mopidy/mixers/nad.py | 10 ++++--- mopidy/mixers/osa.py | 8 +++--- 6 files changed, 90 insertions(+), 26 deletions(-) diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst index 256291f2..7cf5a822 100644 --- a/docs/api/mixers.rst +++ b/docs/api/mixers.rst @@ -6,3 +6,62 @@ :synopsis: Sound mixer interface. :members: :undoc-members: + + +Internal mixers +=============== + +Most users will use on of these internal mixers which controls the volume on +the computer running Mopidy. If you do not specify which mixer you want to use +in the settings, Mopidy will choose one for you based upon what OS you run. See +:attr:`mopidy.settings.default.MIXER` for the defaults. + + +:mod:`mopidy.mixers.alsa` -- ALSA mixer +--------------------------------------- + +.. automodule:: mopidy.mixers.alsa + :synopsis: ALSA mixer + :members: + + +:mod:`mopidy.mixers.dummy` -- Dummy mixer +----------------------------------------- + +.. automodule:: mopidy.mixers.dummy + :synopsis: Dummy mixer + :members: + + +:mod:`mopidy.mixers.osa` -- Osa mixer +------------------------------------- + +.. automodule:: mopidy.mixers.osa + :synopsis: Osa mixer + :members: + + +External device mixers +====================== + +Mopidy supports controlling volume on external devices instead of on the +computer running Mopidy through the use of custom mixer implementations. To +enable one of the following mixers, you must the set `MIXER` setting to point +to one of the classes found below, and possibly add some extra settings +required by the mixer you choose. + + +:mod:`mopidy.mixers.denon` -- Denon amplifier mixer +--------------------------------------------------- + +.. automodule:: mopidy.mixers.denon + :synopsis: Denon amplifier mixer + :members: + + +:mod:`mopidy.mixers.nad` -- NAD amplifier mixer +----------------------------------------------- + +.. automodule:: mopidy.mixers.nad + :synopsis: NAD amplifier mixer + :members: diff --git a/mopidy/mixers/alsa.py b/mopidy/mixers/alsa.py index 54c10a09..03133dbe 100644 --- a/mopidy/mixers/alsa.py +++ b/mopidy/mixers/alsa.py @@ -3,6 +3,11 @@ import alsaaudio from mopidy.mixers import BaseMixer class AlsaMixer(BaseMixer): + """ + Mixer which uses the Advanced Linux Sound Architecture (ALSA) to control + volume. + """ + def __init__(self): self._mixer = alsaaudio.Mixer() diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 426b2db3..594dfc4b 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -8,27 +8,21 @@ from mopidy.settings import MIXER_PORT logger = logging.getLogger(u'mopidy.mixers.denon') -""" - Mixer for controlling Denon recivers and amplifiers using the RS-232 protocol. - - Connects using the serial specifications from - Denon's RS-232 Protocol specification. - - Communication speed : 9600bps - Character length : 8 bits - Parity control : None - Start bit : 1 bit - Stop bit : 1 bit - Communication procedure : Non procedural - Communication data length : 135 bytes (maximum) - - The external mixer is the authoritative source for the current volume. - This allows the user to use his remote control the volume without - mopidy cancelling the volume setting. -""" - class DenonMixer(BaseMixer): + """ + Mixer for controlling Denon amplifiers and receivers using the RS-232 + protocol. + + The external mixer is the authoritative source for the current volume. + This allows the user to use his remote control the volume without Mopidy + cancelling the volume setting. + """ + def __init__(self): + """ + Connects using the serial specifications from Denon's RS-232 Protocol + specification: 9600bps 8N1. + """ self._device = Serial(port=MIXER_PORT, timeout=0.2) self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)] self._volume = 0 diff --git a/mopidy/mixers/dummy.py b/mopidy/mixers/dummy.py index a08e8398..3993d532 100644 --- a/mopidy/mixers/dummy.py +++ b/mopidy/mixers/dummy.py @@ -1,6 +1,8 @@ from mopidy.mixers import BaseMixer class DummyMixer(BaseMixer): + """Mixer which just stores and reports the choosen volume.""" + def __init__(self): self._volume = None diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 43dbfdda..6e95624f 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -9,6 +9,9 @@ logger = logging.getLogger('mopidy.mixers.nad') class NadMixer(BaseMixer): """ + Mixer for controlling NAD amplifiers and receivers using the NAD RS-232 + protocol. + The NAD mixer was created using a NAD C 355BEE amplifier, but should also work with other NAD amplifiers supporting the same RS-232 protocol (v2.x). The C 355BEE does not give you access to the current volume. It only @@ -18,10 +21,9 @@ class NadMixer(BaseMixer): Sadly, this means that if you use the remote control to change the volume on the amplifier, Mopidy will no longer report the correct volume. To - recalibrate the mixer, set the volume to 0, and then back again to the - level you want. This will reset the amplifier to a known state, including - powering on the device, selecting the configured speakers and input - sources. + recalibrate the mixer, set the volume to 0 through Mopidy. This will reset + the amplifier to a known state, including powering on the device, selecting + the configured speakers and input sources. """ def __init__(self): diff --git a/mopidy/mixers/osa.py b/mopidy/mixers/osa.py index 6ec12e17..6291cac1 100644 --- a/mopidy/mixers/osa.py +++ b/mopidy/mixers/osa.py @@ -3,16 +3,18 @@ import time from mopidy.mixers import BaseMixer -CACHE_TTL = 30 - class OsaMixer(BaseMixer): + """Mixer which uses ``osascript`` on OS X to control volume.""" + + CACHE_TTL = 30 + _cache = None _last_update = None def _valid_cache(self): return (self._cache is not None and self._last_update is not None - and (int(time.time() - self._last_update) < CACHE_TTL)) + and (int(time.time() - self._last_update) < self.CACHE_TTL)) def _get_volume(self): if not self._valid_cache(): From 397fdf90d25a51b324102befd18c55d16cffefb3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 18:23:23 +0100 Subject: [PATCH 214/341] docs: Add inheritance diagrams to mixers --- docs/api/mixers.rst | 10 ++++++++++ docs/conf.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst index 7cf5a822..0d0da1d4 100644 --- a/docs/api/mixers.rst +++ b/docs/api/mixers.rst @@ -24,6 +24,8 @@ in the settings, Mopidy will choose one for you based upon what OS you run. See :synopsis: ALSA mixer :members: +.. inheritance-diagram:: mopidy.mixers.alsa.AlsaMixer + :mod:`mopidy.mixers.dummy` -- Dummy mixer ----------------------------------------- @@ -32,6 +34,8 @@ in the settings, Mopidy will choose one for you based upon what OS you run. See :synopsis: Dummy mixer :members: +.. inheritance-diagram:: mopidy.mixers.dummy + :mod:`mopidy.mixers.osa` -- Osa mixer ------------------------------------- @@ -40,6 +44,8 @@ in the settings, Mopidy will choose one for you based upon what OS you run. See :synopsis: Osa mixer :members: +.. inheritance-diagram:: mopidy.mixers.osa + External device mixers ====================== @@ -58,6 +64,8 @@ required by the mixer you choose. :synopsis: Denon amplifier mixer :members: +.. inheritance-diagram:: mopidy.mixers.denon + :mod:`mopidy.mixers.nad` -- NAD amplifier mixer ----------------------------------------------- @@ -65,3 +73,5 @@ required by the mixer you choose. .. automodule:: mopidy.mixers.nad :synopsis: NAD amplifier mixer :members: + +.. inheritance-diagram:: mopidy.mixers.nad diff --git a/docs/conf.py b/docs/conf.py index 1f4cc3a2..3b00883e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,7 @@ import mopidy # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.autodoc', 'autodoc_private_members', - 'sphinx.ext.graphviz'] + 'sphinx.ext.graphviz', 'sphinx.ext.inheritance_diagram'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] From d240fcc783a8b1ab73531ad109f8379aa554274d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 18:26:59 +0100 Subject: [PATCH 215/341] docs: Add model relations graph --- docs/api/models.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/api/models.rst b/docs/api/models.rst index 75f9ab02..a0e4f0b6 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -6,3 +6,13 @@ :synopsis: Immutable data models. :members: :undoc-members: + +Model relations +=============== + +.. digraph:: model_relations + + Playlist -> Track [ label="has multiple" ] + Track -> Album [ label="has one" ] + Track -> Artist [ label="has multiple" ] + Album -> Artist [ label="has multiple" ] From 407ab639cd077f49a20eeed839c619532f219452 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 18:28:45 +0100 Subject: [PATCH 216/341] docs: Fix typo --- mopidy/mixers/dummy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mixers/dummy.py b/mopidy/mixers/dummy.py index 3993d532..38af7186 100644 --- a/mopidy/mixers/dummy.py +++ b/mopidy/mixers/dummy.py @@ -1,7 +1,7 @@ from mopidy.mixers import BaseMixer class DummyMixer(BaseMixer): - """Mixer which just stores and reports the choosen volume.""" + """Mixer which just stores and reports the chosen volume.""" def __init__(self): self._volume = None From 0dbcc591d7f681470dd226b84f8427b3c6215c75 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 19:03:06 +0100 Subject: [PATCH 217/341] Move all mixer settings from local constants to the settings file. Document mixer dependencies and settings. --- mopidy/mixers/denon.py | 13 ++++- mopidy/mixers/nad.py | 110 +++++++++++++++++++------------------ mopidy/settings/default.py | 35 +++++++----- 3 files changed, 88 insertions(+), 70 deletions(-) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 594dfc4b..8f7cd2fc 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -4,7 +4,7 @@ from threading import Lock from serial import Serial from mopidy.mixers import BaseMixer -from mopidy.settings import MIXER_PORT +from mopidy.settings import MIXER_EXT_PORT logger = logging.getLogger(u'mopidy.mixers.denon') @@ -16,6 +16,15 @@ class DenonMixer(BaseMixer): The external mixer is the authoritative source for the current volume. This allows the user to use his remote control the volume without Mopidy cancelling the volume setting. + + **Dependencies** + + - pyserial (python-serial on Debian/Ubuntu) + + **Settings** + + - :attr:`mopidy.settings.default.MIXER_EXT_PORT` -- Example: + ``/dev/ttyUSB0`` """ def __init__(self): @@ -23,7 +32,7 @@ class DenonMixer(BaseMixer): Connects using the serial specifications from Denon's RS-232 Protocol specification: 9600bps 8N1. """ - self._device = Serial(port=MIXER_PORT, timeout=0.2) + self._device = Serial(port=MIXER_EXT_PORT, timeout=0.2) self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)] self._volume = 0 self._lock = Lock() diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 6e95624f..e8c8e47b 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -3,7 +3,8 @@ from serial import Serial from multiprocessing import Pipe, Process from mopidy.mixers import BaseMixer -from mopidy.settings import MIXER_PORT +from mopidy.settings import (MIXER_EXT_PORT, MIXER_EXT_SOURCE, + MIXER_EXT_SPEAKERS_A, MIXER_EXT_SPEAKERS_B) logger = logging.getLogger('mopidy.mixers.nad') @@ -24,6 +25,19 @@ class NadMixer(BaseMixer): recalibrate the mixer, set the volume to 0 through Mopidy. This will reset the amplifier to a known state, including powering on the device, selecting the configured speakers and input sources. + + **Dependencies** + + - pyserial (python-serial on Debian/Ubuntu) + + **Settings** + + - :attr:`mopidy.settings.default.MIXER_EXT_PORT` -- + Example: ``/dev/ttyUSB0`` + - :attr:`mopidy.settings.default.MIXER_EXT_SOURCE` -- Example: ``Aux`` + - :attr:`mopidy.settings.default.MIXER_EXT_SPEAKERS_A` -- Example: ``On`` + - :attr:`mopidy.settings.default.MIXER_EXT_SPEAKERS_B` -- Example: ``Off`` + """ def __init__(self): @@ -42,26 +56,25 @@ class NadMixer(BaseMixer): class NadTalker(Process): - #: Timeout in seconds used for read/write operations. - #: - #: If you set the timeout too low, the reads will never get complete - #: confirmations and calibration will decrease volume forever. If you set - #: the timeout too high, stuff takes more time. + """ + Independent process which does the communication with the NAD device. + + Since the communication is done in an independent process, Mopidy won't + block other requests while doing rather time consuming work like + calibrating the NAD device's volume. + """ + + # Timeout in seconds used for read/write operations. + # If you set the timeout too low, the reads will never get complete + # confirmations and calibration will decrease volume forever. If you set + # the timeout too high, stuff takes more time. 0.2s seems like a good value + # for NAD C 355BEE. TIMEOUT = 0.2 - #: Number of volume levels the device supports - NUM_STEPS = 40 + # Number of volume levels the device supports. 40 for NAD C 355BEE. + VOLUME_LEVELS = 40 - #: The amplifier source to use - SOURCE = 'Aux' - - #: State of speakers A - SPEAKERS_A = 'On' - - #: State of speakers B - SPEAKERS_B = 'Off' - - #: Volume in range [0..NUM_STEPS]. :class:`None` before calibration. + # Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration. _nad_volume = None def __init__(self, pipe=None): @@ -79,12 +92,9 @@ class NadTalker(Process): self._set_device_to_known_state() def _open_connection(self): - """ - Opens serial connection to the device. - - Communication settings: 115200 bps 8N1 - """ - self._device = Serial(port=MIXER_PORT, baudrate=115200, + # Opens serial connection to the device. + # Communication settings: 115200 bps 8N1 + self._device = Serial(port=MIXER_EXT_PORT, baudrate=115200, timeout=self.TIMEOUT) self._get_device_model() @@ -106,17 +116,17 @@ class NadTalker(Process): self._command_device('Main.Power', 'On') def _select_speakers(self): - while self._ask_device('Main.SpeakerA') != self.SPEAKERS_A: - logger.info(u'Setting speakers A "%s"', self.SPEAKERS_A) - self._command_device('Main.SpeakerA', self.SPEAKERS_A) - while self._ask_device('Main.SpeakerB') != self.SPEAKERS_B: - logger.info(u'Setting speakers B "%s"', self.SPEAKERS_B) - self._command_device('Main.SpeakerB', self.SPEAKERS_B) + while self._ask_device('Main.SpeakerA') != MIXER_EXT_SPEAKERS_A: + logger.info(u'Setting speakers A "%s"', MIXER_EXT_SPEAKERS_A) + self._command_device('Main.SpeakerA', MIXER_EXT_SPEAKERS_A) + while self._ask_device('Main.SpeakerB') != MIXER_EXT_SPEAKERS_B: + logger.info(u'Setting speakers B "%s"', MIXER_EXT_SPEAKERS_B) + self._command_device('Main.SpeakerB', MIXER_EXT_SPEAKERS_B) def _select_input_source(self): - while self._ask_device('Main.Source') != self.SOURCE: - logger.info(u'Selecting input source "%s"', self.SOURCE) - self._command_device('Main.Source', self.SOURCE) + while self._ask_device('Main.Source') != MIXER_EXT_SOURCE: + logger.info(u'Selecting input source "%s"', MIXER_EXT_SOURCE) + self._command_device('Main.Source', MIXER_EXT_SOURCE) def _unmute(self): while self._ask_device('Main.Mute') != 'Off': @@ -132,13 +142,11 @@ class NadTalker(Process): self._readline() def _calibrate_volume(self): - """ - The NAD C 355BEE amplifier has 40 different volume levels. We have no - way of asking on which level we are. Thus, we must calibrate the mixer - by decreasing the volume 39 times. - """ + # The NAD C 355BEE amplifier has 40 different volume levels. We have no + # way of asking on which level we are. Thus, we must calibrate the + # mixer by decreasing the volume 39 times. logger.info(u'Calibrating NAD amplifier') - steps_left = self.NUM_STEPS - 1 + steps_left = self.VOLUME_LEVELS - 1 while steps_left: if self._decrease_volume(): steps_left -= 1 @@ -146,12 +154,10 @@ class NadTalker(Process): logger.info(u'Done calibrating NAD amplifier') def _set_volume(self, volume): - """ - Increase or decrease the amplifier volume until it matches the given - target volume. - """ + # Increase or decrease the amplifier volume until it matches the given + # target volume. logger.debug(u'Setting volume to %d' % volume) - target_nad_volume = int(round(volume * self.NUM_STEPS / 100.0)) + target_nad_volume = int(round(volume * self.VOLUME_LEVELS / 100.0)) if self._nad_volume is None: return # Calibration needed while target_nad_volume > self._nad_volume: @@ -162,30 +168,26 @@ class NadTalker(Process): self._nad_volume -= 1 def _increase_volume(self): + # Increase volume. Returns :class:`True` if confirmed by device. self._write('Main.Volume+') return self._readline() == 'Main.Volume+' def _decrease_volume(self): + # Decrease volume. Returns :class:`True` if confirmed by device. self._write('Main.Volume-') return self._readline() == 'Main.Volume-' def _write(self, data): - """ - Write data to device. - - Prepends and appends a newline to the data, as recommended by the NAD - documentation. - """ + # Write data to device. Prepends and appends a newline to the data, as + # recommended by the NAD documentation. if not self._device.isOpen(): self._device.open() self._device.write('\n%s\n' % data) logger.debug('Write: %s', data) def _readline(self): - """ - Read line from device. The result is stripped for leading and trailing - whitespace. - """ + # Read line from device. The result is stripped for leading and + # trailing whitespace. if not self._device.isOpen(): self._device.open() result = self._device.readline(eol='\n').strip() diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index 7f6d1d88..709d5c9a 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -25,7 +25,7 @@ BACKENDS = ( #: the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n %(message)s' -#: Sound mixer to use. +#: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers. #: #: Default on Linux:: #: @@ -38,25 +38,32 @@ CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n #: Default on other operating systems:: #: #: MIXER = u'mopidy.mixers.dummy.DummyMixer' -#: -#: **Available external mixers** -#: -#: .. note:: -#: Using external mixers depends on the pyserial library. -#: -#: Denon AVR/AVC via RS-232:: -#: -#: MIXER = u'mopidy.mixers.denon.DenonMixer' -#: MIXER = u'mopidy.mixers.dummy.DummyMixer' if sys.platform == 'linux2': MIXER = u'mopidy.mixers.alsa.AlsaMixer' elif sys.platform == 'darwin': MIXER = u'mopidy.mixers.osa.OsaMixer' -#: Which port the mixer is connected to if using an external mixer. -#: This must point to the device port like ``/dev/ttyUSB0`` or similar. -MIXER_PORT = None +#: External mixers only. Which port the mixer is connected to. +#: +#: This must point to the device port like ``/dev/ttyUSB0``. +#: *Default:* :class:`None` +MIXER_EXT_PORT = None + +#: External mixers only. What input source the external mixer should use. +#: +#: Example: ``Aux``. *Default:* :class:`None` +MIXER_EXT_SOURCE = None + +#: External mixers only. What state Speakers A should be in. +#: +#: *Default:* :class:`None`. +MIXER_EXT_SPEAKERS_A = None + +#: External mixers only. What state Speakers B should be in. +#: +#: *Default:* :class:`None`. +MIXER_EXT_SPEAKERS_B = None #: Which address Mopidy should bind to. Examples: #: From 94523c3660340636bef427b9e4b6b76bd3503c58 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 20:45:10 +0100 Subject: [PATCH 218/341] docs: Add example usage of a mixer --- docs/api/mixers.rst | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst index 0d0da1d4..639a017f 100644 --- a/docs/api/mixers.rst +++ b/docs/api/mixers.rst @@ -1,6 +1,24 @@ -********************************* -:mod:`mopidy.mixers` -- Mixer API -********************************* +******************** +:mod:`mopidy.mixers` +******************** + +Mixers are responsible for controlling volume. Clients of the mixers will +simply instantiate a mixer and read/write to the ``volume`` attribute:: + + >>> from mopidy.mixers.alsa import AlsaMixer + >>> mixer = AlsaMixer() + >>> mixer.volume + 100 + >>> mixer.volume = 80 + >>> mixer.volume + 80 + + +Mixer API +========= + +All mixers should subclass :class:`mopidy.mixers.BaseMixer` and override +methods as described below. .. automodule:: mopidy.mixers :synopsis: Sound mixer interface. @@ -11,7 +29,7 @@ Internal mixers =============== -Most users will use on of these internal mixers which controls the volume on +Most users will use one of these internal mixers which controls the volume on the computer running Mopidy. If you do not specify which mixer you want to use in the settings, Mopidy will choose one for you based upon what OS you run. See :attr:`mopidy.settings.default.MIXER` for the defaults. @@ -52,9 +70,10 @@ External device mixers Mopidy supports controlling volume on external devices instead of on the computer running Mopidy through the use of custom mixer implementations. To -enable one of the following mixers, you must the set `MIXER` setting to point -to one of the classes found below, and possibly add some extra settings -required by the mixer you choose. +enable one of the following mixers, you must the set +:attr:`mopidy.settings.default.MIXER` setting to point to one of the classes +found below, and possibly add some extra settings required by the mixer you +choose. :mod:`mopidy.mixers.denon` -- Denon amplifier mixer From 65bce46983b90af1453986ce69f5ed2e6a5bdd63 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 20:50:51 +0100 Subject: [PATCH 219/341] docs: Add intro to model API --- docs/api/models.rst | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/docs/api/models.rst b/docs/api/models.rst index a0e4f0b6..8be375ef 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -1,18 +1,28 @@ -********************************************* -:mod:`mopidy.models` -- Immutable data models -********************************************* +******************** +:mod:`mopidy.models` +******************** + +These immutable data models are used for all data transfer within the Mopidy +backends and between the backends and the MPD frontend. All fields are optional +and immutable. In other words, they can only be set through the class +constructor during instance creation. + + +Data model relations +==================== + +.. digraph:: model_relations + + Playlist -> Track [ label="has 0..n" ] + Track -> Album [ label="has 0..1" ] + Track -> Artist [ label="has 0..n" ] + Album -> Artist [ label="has 0..n" ] + + +Data model API +============== .. automodule:: mopidy.models :synopsis: Immutable data models. :members: :undoc-members: - -Model relations -=============== - -.. digraph:: model_relations - - Playlist -> Track [ label="has multiple" ] - Track -> Album [ label="has one" ] - Track -> Artist [ label="has multiple" ] - Album -> Artist [ label="has multiple" ] From c6964f93072a7c47c0064dc271cd79c6cbeb0fb1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 21:11:37 +0100 Subject: [PATCH 220/341] docs: Add note on what the backend API docs covers. Add graph of backend controller relations. --- docs/api/backends.rst | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 687eef31..ee53761b 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -1,6 +1,30 @@ -************************************* -:mod:`mopidy.backends` -- Backend API -************************************* +********************** +:mod:`mopidy.backends` +********************** + +The backend and its controllers +=============================== + +.. graph:: backend_relations + + backend -- current_playlist + backend -- library + backend -- playback + backend -- stored_playlists + + +Backend API +=========== + +.. note:: + + Currently this only documents the API that is available for use by + frontends like :class:`mopidy.mpd.handler`, and not what is required to + implement your own backend. :class:`mopidy.backends.BaseBackend` and its + controllers implements many of these methods in a matter that should be + independent of most concrete backend implementations, so you should + 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. From cf1e5ff4ba82f6e386d6d506b718bf61bec042e0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Mar 2010 21:20:35 +0100 Subject: [PATCH 221/341] docs: Add section on changing settings --- docs/api/settings.rst | 25 ++++++++++++++++++++++--- mopidy/settings/default.py | 4 ++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/api/settings.rst b/docs/api/settings.rst index cba8b0ae..749f275d 100644 --- a/docs/api/settings.rst +++ b/docs/api/settings.rst @@ -1,6 +1,25 @@ -****************************************** -:mod:`mopidy.settings.default` -- Settings -****************************************** +********************** +:mod:`mopidy.settings` +********************** + + +Changing settings +================= + +For any Mopidy installation you will need to change at least a couple of +settings. To do this, create a new file in the ``mopidy/settings/`` directory +named ``local.py`` and add settings you need to change from their defaults +there. + +A complete ``mopidy/settings/local.py`` may look like this:: + + MPD_SERVER_HOSTNAME = u'0.0.0.0' + SPOTIFY_USERNAME = u'alice' + SPOTIFY_USERNAME = u'mysecret' + + +Available settings +================== .. automodule:: mopidy.settings.default :synopsis: Available settings and their default values. diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index 709d5c9a..7fd41fa6 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -3,8 +3,8 @@ Available settings and their default values. .. warning:: - Do *not* change settings here. Instead, add a file called - ``mopidy/settings/local.py`` and redefine settings there. + Do *not* change settings in ``mopidy/settings/default.py``. Instead, add a + file called ``mopidy/settings/local.py`` and redefine settings there. """ import sys From 8ddff80ce382be4a5cb6566d08141de1b83bdffc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Mar 2010 00:23:26 +0100 Subject: [PATCH 222/341] docs: Add docs on all available backends --- docs/api/backends.rst | 37 ++++++++++++++++++++++++++++++++++ docs/development/internals.rst | 37 ---------------------------------- mopidy/backends/despotify.py | 24 ++++++++++++++++++++++ mopidy/backends/dummy.py | 7 +++++++ mopidy/backends/libspotify.py | 18 +++++++++++++++++ 5 files changed, 86 insertions(+), 37 deletions(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index ee53761b..e099fbf8 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -30,3 +30,40 @@ Backend API :synopsis: Backend interface. :members: :undoc-members: + + +Spotify backends +================ + +:mod:`mopidy.backends.despotify` -- Despotify backend +----------------------------------------------------- + +.. automodule:: mopidy.backends.despotify + :synopsis: Spotify backend using the despotify library. + :members: + + +:mod:`mopidy.backends.libspotify` -- Libspotify backend +------------------------------------------------------- + +.. automodule:: mopidy.backends.libspotify + :synopsis: Spotify backend using the libspotify library. + :members: + + +Other backends +============== + +:mod:`mopidy.backends.dummy` -- Dummy backend +--------------------------------------------- + +.. automodule:: mopidy.backends.dummy + :synopsis: Dummy backend used for testing. + :members: + + +GStreamer backend +----------------- + +``GstreamerBackend`` is pending merge from `adamcik/mopidy/gstreamer +`_. diff --git a/docs/development/internals.rst b/docs/development/internals.rst index 9c38f654..10a79152 100644 --- a/docs/development/internals.rst +++ b/docs/development/internals.rst @@ -31,40 +31,3 @@ not Mopidy. "spytify" -> "despotify" [ label="use C library" ] "DespotifyBackend" -> "AlsaMixer" [ label="use mixer API" ] "AlsaMixer" -> "alsaaudio" [ label="use Python library" ] - - -Notes on despotify/spytify -========================== - -`spytify `_ -is the Python bindings for the open source `despotify `_ -library. It got no documentation to speak of, but a couple of examples are -available. - -A list of the issues we currently experience with spytify, both bugs and -features we wished was there: - -- r503: Sometimes segfaults when traversing stored playlists, their tracks, - artists, and albums. As it is not predictable, it may be a concurrency issue. - -- r503: Segfaults when looking up playlists, both your own lists and other - peoples shared lists. To reproduce:: - - >>> import spytify - >>> s = spytify.Spytify('alice', 'secret') - >>> s.lookup('spotify:user:klette:playlist:5rOGYPwwKqbAcVX8bW4k5V') - Segmentation fault - - -Notes on libspotify/pyspotify -============================================ - -`pyspotify `_ is the Python bindings for -the official Spotify library, libspotify. It got no documentation to speak of, -but multiple examples are available. Like libspotify, pyspotify's calls are -mostly asynchronous. - -A list of the issues we currently experience with pyspotify, both bugs and -features we wished was there: - -- None at the moment. diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 349f3a9a..14616bae 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -14,6 +14,30 @@ logger = logging.getLogger(u'backends.despotify') ENCODING = 'utf-8' class DespotifyBackend(BaseBackend): + """ + A Spotify backend which uses the open source `despotify library + `_. + + `spytify `_ + is the Python bindings for the despotify library. It got litle + documentation, but a couple of examples are available. + + **Issues** + + - r503: Sometimes segfaults when traversing stored playlists, their tracks, + artists, and albums. As it is not predictable, it may be a concurrency + issue. + + - r503: Segfaults when looking up playlists, both your own lists and other + peoples shared lists. To reproduce:: + + >>> import spytify + >>> s = spytify.Spytify('alice', 'secret') + >>> s.lookup('spotify:user:klette:playlist:5rOGYPwwKqbAcVX8bW4k5V') + Segmentation fault + + """ + def __init__(self, *args, **kwargs): super(DespotifyBackend, self).__init__(*args, **kwargs) self.current_playlist = DespotifyCurrentPlaylistController(backend=self) diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 96a87e7d..0da24c44 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -4,6 +4,13 @@ from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, from mopidy.models import Playlist class DummyBackend(BaseBackend): + """ + A backend which implements the backend API in the simplest way possible. + Used in tests of the frontends. + + Handles URIs starting with ``dummy:``. + """ + def __init__(self, *args, **kwargs): super(DummyBackend, self).__init__(*args, **kwargs) self.current_playlist = DummyCurrentPlaylistController(backend=self) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index d2fabaf1..086920b5 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -17,6 +17,24 @@ logger = logging.getLogger(u'backends.libspotify') ENCODING = 'utf-8' class LibspotifyBackend(BaseBackend): + """ + A Spotify backend which uses the official `libspotify library + `_. + + `pyspotify `_ is the Python bindings + for libspotify. It got no documentation, but multiple examples are + available. Like libspotify, pyspotify's calls are mostly asynchronous. + + This backend should also work with `openspotify + `_, but we haven't tested + that yet. + + **Issues** + + - libspotify is badly packaged. See + http://getsatisfaction.com/spotify/topics/libspotify_please_fix_the_installation_script. + """ + def __init__(self, *args, **kwargs): super(LibspotifyBackend, self).__init__(*args, **kwargs) self.current_playlist = LibspotifyCurrentPlaylistController( From 6a2e7e2e738873a0bf895512638d86b9d6c2741a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Mar 2010 23:18:03 +0100 Subject: [PATCH 223/341] Add support for negative songpos arg in 'playlistinfo' as used by ncmpc --- mopidy/models.py | 19 ++++++++++++++----- mopidy/mpd/handler.py | 13 +++++++++++-- tests/modelstest.py | 14 ++++++++++++++ tests/mpd/handlertest.py | 4 ++++ 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 951ada6d..6d0b0dee 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -196,15 +196,24 @@ class Playlist(ImmutableObject): Optionally limit output to the slice ``[start:end]`` of the playlist. :param start: position of first track to include in output - :type start: int + :type start: int (positive or negative) :param end: position after last track to include in output - :type end: int or :class:`None` for end of list + :type end: int (positive or negative) or :class:`None` for end of list :rtype: list of lists of two-tuples """ - if end is None: - end = self.length + if start < 0: + range_start = self.length + start + else: + range_start = start + if end is not None and end < 0: + range_end = self.length - end + elif end is not None and end >= 0: + range_end = end + else: + range_end = self.length tracks = [] - for track, position in zip(self.tracks[start:end], range(start, end)): + for track, position in zip(self.tracks[start:end], + range(range_start, range_end)): tracks.append(track.mpd_format(position, search_result)) return tracks diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index d60e16e7..fdd9a5b9 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -394,7 +394,7 @@ class MpdHandler(object): return self.backend.current_playlist.playlist.mpd_format() @handle_pattern(r'^playlistinfo$') - @handle_pattern(r'^playlistinfo "(?P\d+)"$') + @handle_pattern(r'^playlistinfo "(?P-?\d+)"$') @handle_pattern(r'^playlistinfo "(?P\d+):(?P\d+)*"$') def _current_playlist_playlistinfo(self, songpos=None, start=None, end=None): @@ -406,11 +406,20 @@ class MpdHandler(object): Displays a list of all songs in the playlist, or if the optional argument is given, displays information only for the song ``SONGPOS`` or the range of songs ``START:END``. + + *ncmpc:* + + - uses negative indexes, like ``playlistinfo "-1"``, to request + information on the last track in the playlist. """ if songpos is not None: songpos = int(songpos) + start = songpos + end = songpos + 1 + if start == -1: + end = None return self.backend.current_playlist.playlist.mpd_format( - songpos, songpos + 1) + start, end) else: if start is None: start = 0 diff --git a/tests/modelstest.py b/tests/modelstest.py index 6e4717bf..43d6ca53 100644 --- a/tests/modelstest.py +++ b/tests/modelstest.py @@ -182,6 +182,20 @@ class PlaylistTest(unittest.TestCase): self.assertEqual(len(result), 1) self.assertEqual(dict(result[0])['Track'], 2) + def test_mpd_format_with_negative_start_and_no_end(self): + playlist = Playlist(tracks=[ + Track(track_no=1), Track(track_no=2), Track(track_no=3)]) + result = playlist.mpd_format(-1, None) + self.assertEqual(len(result), 1) + self.assertEqual(dict(result[0])['Track'], 3) + + def test_mpd_format_with_negative_start_and_end(self): + playlist = Playlist(tracks=[ + Track(track_no=1), Track(track_no=2), Track(track_no=3)]) + result = playlist.mpd_format(-2, -1) + self.assertEqual(len(result), 1) + self.assertEqual(dict(result[0])['Track'], 2) + def test_with_new_uri(self): tracks = [Track()] last_modified = dt.datetime.now() diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index c030ae94..58187d1e 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/handlertest.py @@ -701,6 +701,10 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): result = self.h.handle_request(u'playlistinfo "5"') self.assert_(u'OK' in result) + def test_playlistinfo_with_negative_songpos(self): + result = self.h.handle_request(u'playlistinfo "-1"') + self.assert_(u'OK' in result) + def test_playlistinfo_with_open_range(self): result = self.h.handle_request(u'playlistinfo "10:"') self.assert_(u'OK' in result) From 6d60d76cbe3496674aea442d90b8c1c14e3a5168 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Mar 2010 23:31:26 +0100 Subject: [PATCH 224/341] despotify: Add DespotifySessionManager with end_of_track callback --- mopidy/backends/despotify.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 14616bae..5bd3552b 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -51,7 +51,7 @@ class DespotifyBackend(BaseBackend): def _connect(self): logger.info(u'Connecting to Spotify') - return spytify.Spytify( + return DespotifySessionManager( settings.SPOTIFY_USERNAME.encode(ENCODING), settings.SPOTIFY_PASSWORD.encode(ENCODING)) @@ -154,3 +154,19 @@ class DespotifyTranslator(object): name=spotify_playlist.name.decode(ENCODING), tracks=[self.to_mopidy_track(t) for t in spotify_playlist.tracks], ) + + +class DespotifySessionManager(spytify.Spytify): + DESPOTIFY_NEW_TRACK = 1 + DESPOTIFY_TIME_TELL = 2 + DESPOTIFY_END_OF_PLAYLIST = 3 + DESPOTIFY_TRACK_PLAY_ERROR = 4 + + def __init__(self, *args, **kwargs): + kwargs['callback'] = self.callback + super(DespotifySessionManager, self).__init__(*args, **kwargs) + + def callback(self, signal, data): + if signal == self.DESPOTIFY_END_OF_PLAYLIST: + logger.debug('Despotify signalled end of playlist') + # TODO Ask backend to play next track From 7c51e75ee1c89e102ce884f3abb48f185b901a3a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 18 Mar 2010 23:07:54 +0100 Subject: [PATCH 225/341] docs: Add thread communication sequence diagram --- docs/_static/.placeholder | 0 docs/_static/thread_communication.png | Bin 0 -> 42748 bytes docs/_static/thread_communication.txt | 32 ++++++++++++++++++++++++++ docs/development/internals.rst | 10 ++++++++ 4 files changed, 42 insertions(+) delete mode 100644 docs/_static/.placeholder create mode 100644 docs/_static/thread_communication.png create mode 100644 docs/_static/thread_communication.txt diff --git a/docs/_static/.placeholder b/docs/_static/.placeholder deleted file mode 100644 index e69de29b..00000000 diff --git a/docs/_static/thread_communication.png b/docs/_static/thread_communication.png new file mode 100644 index 0000000000000000000000000000000000000000..4741a18cd8334506f712ad7c2c56eaea0ff47cbf GIT binary patch literal 42748 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 literal 0 HcmV?d00001 diff --git a/docs/_static/thread_communication.txt b/docs/_static/thread_communication.txt new file mode 100644 index 00000000..4357023f --- /dev/null +++ b/docs/_static/thread_communication.txt @@ -0,0 +1,32 @@ +Script for use with www.websequencediagrams.com +=============================================== + +Main -> NadMixer: start +activate NadMixer +note over NadMixer: calibrate device +NadMixer -> Main: ready +Main -> MpdHandler/Backend: start +activate MpdHandler/Backend +MpdHandler/Backend -> despotify: connect to Spotify +activate despotify +MpdHandler/Backend -> Main: ready +Main -> MpdServer/MpdSession: start +activate MpdServer/MpdSession +note over MpdServer/MpdSession: opens port +MpdServer/MpdSession -> Main: ready +Client -> MpdServer/MpdSession: connect +Client -> MpdServer/MpdSession: play 1 +MpdServer/MpdSession -> MpdHandler/Backend: play 1 +MpdHandler/Backend -> despotify: play first track +Client -> MpdServer/MpdSession: setvol 50 +MpdServer/MpdSession -> MpdHandler/Backend: setvol 50 +MpdHandler/Backend -> NadMixer: volume = 50 +Client -> MpdServer/MpdSession: status +MpdServer/MpdSession -> MpdHandler/Backend: status +MpdHandler/Backend -> NadMixer: volume? +NadMixer -> MpdHandler/Backend: volume = 50 +MpdHandler/Backend -> MpdServer/MpdSession: status response +MpdServer/MpdSession -> Client: status response +despotify -> MpdHandler/Backend: end of track callback +MpdHandler/Backend -> despotify: play second track +MpdServer/MpdSession -> MpdHandler/Backend: stop diff --git a/docs/development/internals.rst b/docs/development/internals.rst index 10a79152..0062f2df 100644 --- a/docs/development/internals.rst +++ b/docs/development/internals.rst @@ -31,3 +31,13 @@ not Mopidy. "spytify" -> "despotify" [ label="use C library" ] "DespotifyBackend" -> "AlsaMixer" [ label="use mixer API" ] "AlsaMixer" -> "alsaaudio" [ label="use Python library" ] + + +Thread communication +==================== + +.. warning:: + + This is a plan, and does not necessarily reflect what has been implemented. + +.. image:: /_static/thread_communication.png From dde696ba33b4712a83df3d34ff04caf49232dcf2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 19 Mar 2010 10:57:31 +0100 Subject: [PATCH 226/341] docs: Improve thread communication diagram with some missing notes and interactions --- docs/_static/thread_communication.png | Bin 42748 -> 48279 bytes docs/_static/thread_communication.txt | 7 ++++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/_static/thread_communication.png b/docs/_static/thread_communication.png index 4741a18cd8334506f712ad7c2c56eaea0ff47cbf..5703b1044e8f1d1c31a43f5956fc5a7441c4aead 100644 GIT binary patch literal 48279 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 TcQvR@6bf~h;?Bq&+86&1{emKs literal 42748 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 diff --git a/docs/_static/thread_communication.txt b/docs/_static/thread_communication.txt index 4357023f..66ed34e9 100644 --- a/docs/_static/thread_communication.txt +++ b/docs/_static/thread_communication.txt @@ -12,9 +12,10 @@ activate despotify MpdHandler/Backend -> Main: ready Main -> MpdServer/MpdSession: start activate MpdServer/MpdSession -note over MpdServer/MpdSession: opens port +note over MpdServer/MpdSession: open port MpdServer/MpdSession -> Main: ready Client -> MpdServer/MpdSession: connect +note over MpdServer/MpdSession: open session Client -> MpdServer/MpdSession: play 1 MpdServer/MpdSession -> MpdHandler/Backend: play 1 MpdHandler/Backend -> despotify: play first track @@ -29,4 +30,8 @@ MpdHandler/Backend -> MpdServer/MpdSession: status response MpdServer/MpdSession -> Client: status response despotify -> MpdHandler/Backend: end of track callback MpdHandler/Backend -> despotify: play second track +Client -> MpdServer/MpdSession: stop MpdServer/MpdSession -> MpdHandler/Backend: stop +MpdHandler/Backend -> despotify: stop +Client -> MpdServer/MpdSession: disconnect +note over MpdServer/MpdSession: close session From 4729605a6b6579e07ccee37bc3808ab0be3714e9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 19 Mar 2010 21:11:42 +0100 Subject: [PATCH 227/341] Add process ID to log format --- mopidy/settings/default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index 7fd41fa6..7fdb9f14 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -23,7 +23,7 @@ BACKENDS = ( #: 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 [%(threadName)s] %(name)s\n %(message)s' +CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s' #: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers. #: From 34b7f679f569f7c885c0dff86b3a8331ff2ecbd5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 19 Mar 2010 21:55:17 +0100 Subject: [PATCH 228/341] Move mopidy.__main__._get_class to mopidy.get_class --- mopidy/__init__.py | 12 ++++++++++++ mopidy/__main__.py | 14 +++----------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 176bef6a..89605726 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,11 +1,23 @@ +import logging + from mopidy import settings as raw_settings +logger = logging.getLogger('mopidy') + def get_version(): return u'0.1.dev' def get_mpd_protocol_version(): return u'0.16.0' +def get_class(name): + module_name = name[:name.rindex('.')] + class_name = name[name.rindex('.') + 1:] + logger.info('Loading: %s from %s', class_name, module_name) + module = __import__(module_name, globals(), locals(), [class_name], -1) + class_object = getattr(module, class_name) + return class_object + class SettingsError(Exception): pass diff --git a/mopidy/__main__.py b/mopidy/__main__.py index d2cf09a7..0ffb0391 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -6,15 +6,15 @@ import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import settings, SettingsError +from mopidy import get_class, settings, SettingsError from mopidy.mpd.server import MpdServer logger = logging.getLogger('mopidy') def main(): _setup_logging(2) - mixer = _get_class(settings.MIXER)() - backend = _get_class(settings.BACKENDS[0])(mixer=mixer) + mixer = get_class(settings.MIXER)() + backend = get_class(settings.BACKENDS[0])(mixer=mixer) MpdServer(backend=backend) asyncore.loop() @@ -30,14 +30,6 @@ def _setup_logging(verbosity_level): level=level, ) -def _get_class(name): - module_name = name[:name.rindex('.')] - class_name = name[name.rindex('.') + 1:] - logger.info('Loading: %s from %s', class_name, module_name) - module = __import__(module_name, globals(), locals(), [class_name], -1) - class_object = getattr(module, class_name) - return class_object - if __name__ == '__main__': try: main() From be9614da32a3c626d2a8e434a43d411d30451f7f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 19 Mar 2010 21:55:41 +0100 Subject: [PATCH 229/341] Add todo list for multiprocessing branch --- mopidy/__main__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 0ffb0391..111c0b5e 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -13,6 +13,15 @@ logger = logging.getLogger('mopidy') def main(): _setup_logging(2) + + # multiprocessing branch plan + # --------------------------- + # + # TODO Init backend in new Process (named core?) + # TODO Init mixer from backend + # TODO Init MpdHandler from backend/core + # TODO Init MpdServer in MainThread or in new Process? + mixer = get_class(settings.MIXER)() backend = get_class(settings.BACKENDS[0])(mixer=mixer) MpdServer(backend=backend) From 194f0e543edb1598b88f9f20562a92c52a5f0122 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 19 Mar 2010 22:05:43 +0100 Subject: [PATCH 230/341] Move mixer initialization from main() into backend --- mopidy/__main__.py | 4 +--- mopidy/backends/__init__.py | 5 +++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 111c0b5e..07f571ce 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -18,12 +18,10 @@ def main(): # --------------------------- # # TODO Init backend in new Process (named core?) - # TODO Init mixer from backend # TODO Init MpdHandler from backend/core # TODO Init MpdServer in MainThread or in new Process? - mixer = get_class(settings.MIXER)() - backend = get_class(settings.BACKENDS[0])(mixer=mixer) + backend = get_class(settings.BACKENDS[0])() MpdServer(backend=backend) asyncore.loop() diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 93b74498..280458a4 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -3,13 +3,14 @@ import logging import random import time +from mopidy import get_class, settings from mopidy.models import Playlist logger = logging.getLogger('backends.base') class BaseBackend(object): - def __init__(self, mixer=None): - self.mixer = mixer + def __init__(self): + self.mixer = get_class(settings.MIXER)() #: The current playlist controller. An instance of #: :class:`BaseCurrentPlaylistController`. From feda5db67f8bcba08999eccc9fefec8e9628f82f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 19 Mar 2010 22:52:49 +0100 Subject: [PATCH 231/341] Move backend init into new CoreProcess --- mopidy/__main__.py | 19 +++++++++++++++---- mopidy/core.py | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 mopidy/core.py diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 07f571ce..84b1d6a5 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,5 +1,6 @@ import asyncore import logging +from multiprocessing import Queue import os import sys @@ -7,6 +8,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) from mopidy import get_class, settings, SettingsError +from mopidy.core import CoreProcess from mopidy.mpd.server import MpdServer logger = logging.getLogger('mopidy') @@ -17,13 +19,22 @@ def main(): # multiprocessing branch plan # --------------------------- # - # TODO Init backend in new Process (named core?) # TODO Init MpdHandler from backend/core # TODO Init MpdServer in MainThread or in new Process? - backend = get_class(settings.BACKENDS[0])() - MpdServer(backend=backend) - asyncore.loop() + main_queue = Queue() + core_queue = Queue() + server_queue = Queue() + core = CoreProcess(core_queue=core_queue, + main_queue=main_queue, server_queue=server_queue) + core.start() + while True: + message = main_queue.get() + if message['command'] == 'core_ready': + MpdServer(backend=None) + asyncore.loop() + else: + logger.warning(u'Cannot handle message: %s', message) def _setup_logging(verbosity_level): if verbosity_level == 0: diff --git a/mopidy/core.py b/mopidy/core.py new file mode 100644 index 00000000..bd48c818 --- /dev/null +++ b/mopidy/core.py @@ -0,0 +1,23 @@ +import logging +from multiprocessing import Process, Queue + +from mopidy import get_class, settings + +logger = logging.getLogger('mopidy.core') + +class CoreProcess(Process): + def __init__(self, core_queue=None, main_queue=None, server_queue=None): + Process.__init__(self) + self.queue = core_queue + self.main_queue = main_queue + self.server_queue = server_queue + + def run(self): + self._setup() + while True: + message = self.queue.get() + # TODO Do something with the message + + def _setup(self): + self.backend = get_class(settings.BACKENDS[0])() + self.main_queue.put({'command': 'core_ready'}) From d9a71ca6f9d7a215328682c4b0aad5642f6bd9cc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 19 Mar 2010 23:07:06 +0100 Subject: [PATCH 232/341] Make mixer injectable again to make the tests happy --- mopidy/backends/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 280458a4..d20b6c50 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -9,8 +9,11 @@ from mopidy.models import Playlist logger = logging.getLogger('backends.base') class BaseBackend(object): - def __init__(self): - self.mixer = get_class(settings.MIXER)() + def __init__(self, mixer=None): + if mixer is not None: + self.mixer = mixer + else: + self.mixer = get_class(settings.MIXER)() #: The current playlist controller. An instance of #: :class:`BaseCurrentPlaylistController`. From 65fcfbfae9ef1a68d324aea932f983f7edd00cdf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 00:34:27 +0100 Subject: [PATCH 233/341] Add util functions for pickling and unpickling multiprocessing.Connection --- mopidy/__init__.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 89605726..5770716f 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,4 +1,6 @@ import logging +from multiprocessing.reduction import reduce_connection +import pickle from mopidy import settings as raw_settings @@ -18,6 +20,16 @@ def get_class(name): class_object = getattr(module, class_name) return class_object +def pickle_connection(connection): + return pickle.dumps(reduce_connection(connection)) + +def unpickle_connection(pickled_connection): + # From http://stackoverflow.com/questions/1446004 + unpickled = pickle.loads(pickled_connection) + func = unpickled[0] + args = unpickled[1] + return func(*args) + class SettingsError(Exception): pass From af4d1f702e607c290bd1f8e2ca5302c5c737ceba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 00:35:50 +0100 Subject: [PATCH 234/341] Add FRONTEND setting --- mopidy/settings/default.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index 7fdb9f14..9f9cdded 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -25,6 +25,11 @@ BACKENDS = ( #: the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s' +#: Protocol frontend to use. Default:: +#: +#: FRONTEND = u'mopidy.mpd.handler.MpdHandler' +FRONTEND = u'mopidy.mpd.handler.MpdHandler' + #: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers. #: #: Default on Linux:: From 0e0a9e67dd0fb6028fc56617bb8ac4e536180dd4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 00:36:43 +0100 Subject: [PATCH 235/341] Move MpdHandler usage from MpdSession to CoreProcess --- mopidy/__main__.py | 3 +-- mopidy/core.py | 23 +++++++++++++---------- mopidy/mpd/server.py | 6 +++--- mopidy/mpd/session.py | 18 ++++++++++++------ 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 84b1d6a5..ecd82216 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -19,7 +19,6 @@ def main(): # multiprocessing branch plan # --------------------------- # - # TODO Init MpdHandler from backend/core # TODO Init MpdServer in MainThread or in new Process? main_queue = Queue() @@ -31,7 +30,7 @@ def main(): while True: message = main_queue.get() if message['command'] == 'core_ready': - MpdServer(backend=None) + MpdServer(core_queue=core_queue) asyncore.loop() else: logger.warning(u'Cannot handle message: %s', message) diff --git a/mopidy/core.py b/mopidy/core.py index bd48c818..bb1674ed 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -1,23 +1,26 @@ import logging -from multiprocessing import Process, Queue +import multiprocessing -from mopidy import get_class, settings +from mopidy import get_class, settings, unpickle_connection logger = logging.getLogger('mopidy.core') -class CoreProcess(Process): +class CoreProcess(multiprocessing.Process): def __init__(self, core_queue=None, main_queue=None, server_queue=None): - Process.__init__(self) + multiprocessing.Process.__init__(self) self.queue = core_queue self.main_queue = main_queue self.server_queue = server_queue def run(self): - self._setup() + backend = get_class(settings.BACKENDS[0])() + frontend = get_class(settings.FRONTEND)(backend=backend) + self.main_queue.put({'command': 'core_ready'}) while True: message = self.queue.get() - # TODO Do something with the message - - def _setup(self): - self.backend = get_class(settings.BACKENDS[0])() - self.main_queue.put({'command': 'core_ready'}) + if message['command'] == 'mpd_request': + response = frontend.handle_request(message['request']) + connection = unpickle_connection(message['reply_to']) + connection.send(response) + else: + logger.warning(u'Cannot handle message: %s', message) diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index e0173574..270f1f6b 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -10,10 +10,10 @@ from mopidy.mpd.session import MpdSession logger = logging.getLogger(u'mpd.server') class MpdServer(asyncore.dispatcher): - def __init__(self, session_class=MpdSession, backend=None): + def __init__(self, session_class=MpdSession, core_queue=None): asyncore.dispatcher.__init__(self) self.session_class = session_class - self.backend = backend + self.core_queue = core_queue self.create_socket(socket.AF_INET, socket.SOCK_STREAM) self.set_reuse_addr() self.bind((settings.MPD_SERVER_HOSTNAME, settings.MPD_SERVER_PORT)) @@ -26,7 +26,7 @@ class MpdServer(asyncore.dispatcher): (client_socket, client_address) = self.accept() logger.info(u'Connection from: [%s]:%s', *client_address) self.session_class(self, client_socket, client_address, - backend=self.backend) + core_queue=self.core_queue) def handle_close(self): self.close() diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index 19d8c1c5..4c9bb0c5 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -1,9 +1,9 @@ import asynchat import logging +import multiprocessing -from mopidy import get_mpd_protocol_version +from mopidy import get_mpd_protocol_version, pickle_connection from mopidy.mpd import MpdAckError -from mopidy.mpd.handler import MpdHandler logger = logging.getLogger(u'mpd.session') @@ -22,14 +22,13 @@ def indent(string, places=4, linebreak=LINE_TERMINATOR): return result class MpdSession(asynchat.async_chat): - def __init__(self, server, client_socket, client_address, backend, - handler_class=MpdHandler): + def __init__(self, server, client_socket, client_address, core_queue): asynchat.async_chat.__init__(self, sock=client_socket) self.server = server self.client_address = client_address + self.core_queue = core_queue self.input_buffer = [] self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) - self.handler = handler_class(session=self, backend=backend) self.send_response(u'OK MPD %s' % get_mpd_protocol_version()) def do_close(self): @@ -51,7 +50,14 @@ class MpdSession(asynchat.async_chat): def handle_request(self, input): try: - response = self.handler.handle_request(input) + my_end, other_end = multiprocessing.Pipe() + self.core_queue.put({ + 'command': 'mpd_request', + 'request': input, + 'reply_to': pickle_connection(other_end), + }) + my_end.poll(None) + response = my_end.recv() if response is not None: self.handle_response(response) except MpdAckError, e: From 1faecdf496ecf909e043a79a41b6c92a982d0051 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 01:05:33 +0100 Subject: [PATCH 236/341] Add SERVER setting. Remove MPD_ prefix from existing server settings. --- mopidy/mpd/server.py | 4 ++-- mopidy/settings/default.py | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index 270f1f6b..a61e2aa8 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -16,11 +16,11 @@ class MpdServer(asyncore.dispatcher): self.core_queue = core_queue self.create_socket(socket.AF_INET, socket.SOCK_STREAM) self.set_reuse_addr() - self.bind((settings.MPD_SERVER_HOSTNAME, settings.MPD_SERVER_PORT)) + self.bind((settings.SERVER_HOSTNAME, settings.SERVER_PORT)) self.listen(1) self.started_at = int(time.time()) logger.info(u'Please connect to %s port %s using an MPD client.', - settings.MPD_SERVER_HOSTNAME, settings.MPD_SERVER_PORT) + settings.SERVER_HOSTNAME, settings.SERVER_PORT) def handle_accept(self): (client_socket, client_address) = self.accept() diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index 9f9cdded..874c2f87 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -70,16 +70,21 @@ MIXER_EXT_SPEAKERS_A = None #: *Default:* :class:`None`. MIXER_EXT_SPEAKERS_B = None +#: Server to use. Default:: +#: +#: SERVER = u'mopidy.mpd.server.MpdServer' +SERVER = u'mopidy.mpd.server.MpdServer' + #: Which address Mopidy should bind to. Examples: #: #: ``localhost`` #: Listens only on the loopback interface. *Default.* #: ``0.0.0.0`` #: Listens on all interfaces. -MPD_SERVER_HOSTNAME = u'localhost' +SERVER_HOSTNAME = u'localhost' #: Which TCP port Mopidy should listen to. *Default: 6600* -MPD_SERVER_PORT = 6600 +SERVER_PORT = 6600 #: Your Spotify Premium username. Used by all Spotify backends. SPOTIFY_USERNAME = u'' From e5be3ea6407f56229f826aa4b77d106fb6e9c8fa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 01:06:22 +0100 Subject: [PATCH 237/341] Remove plan for server in own process. Remove redundant blocking of server until CoreProcess is ready. --- mopidy/__main__.py | 27 ++++++--------------------- mopidy/core.py | 9 +++------ 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index ecd82216..0666d99b 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,6 +1,6 @@ import asyncore import logging -from multiprocessing import Queue +import multiprocessing import os import sys @@ -9,31 +9,16 @@ sys.path.insert(0, from mopidy import get_class, settings, SettingsError from mopidy.core import CoreProcess -from mopidy.mpd.server import MpdServer -logger = logging.getLogger('mopidy') +logger = logging.getLogger('mopidy.main') def main(): _setup_logging(2) - - # multiprocessing branch plan - # --------------------------- - # - # TODO Init MpdServer in MainThread or in new Process? - - main_queue = Queue() - core_queue = Queue() - server_queue = Queue() - core = CoreProcess(core_queue=core_queue, - main_queue=main_queue, server_queue=server_queue) + core_queue = multiprocessing.Queue() + core = CoreProcess(core_queue) core.start() - while True: - message = main_queue.get() - if message['command'] == 'core_ready': - MpdServer(core_queue=core_queue) - asyncore.loop() - else: - logger.warning(u'Cannot handle message: %s', message) + get_class(settings.SERVER)(core_queue=core_queue) + asyncore.loop() def _setup_logging(verbosity_level): if verbosity_level == 0: diff --git a/mopidy/core.py b/mopidy/core.py index bb1674ed..374945d4 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -6,18 +6,15 @@ from mopidy import get_class, settings, unpickle_connection logger = logging.getLogger('mopidy.core') class CoreProcess(multiprocessing.Process): - def __init__(self, core_queue=None, main_queue=None, server_queue=None): + def __init__(self, core_queue): multiprocessing.Process.__init__(self) - self.queue = core_queue - self.main_queue = main_queue - self.server_queue = server_queue + self.core_queue = core_queue def run(self): backend = get_class(settings.BACKENDS[0])() frontend = get_class(settings.FRONTEND)(backend=backend) - self.main_queue.put({'command': 'core_ready'}) while True: - message = self.queue.get() + message = self.core_queue.get() if message['command'] == 'mpd_request': response = frontend.handle_request(message['request']) connection = unpickle_connection(message['reply_to']) From aa8b67a327a487ec694535714e352177cfb568c1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 01:20:21 +0100 Subject: [PATCH 238/341] Make core_queue available for backends --- mopidy/backends/__init__.py | 7 ++++++- mopidy/core.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index d20b6c50..14b3a40b 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -9,12 +9,17 @@ from mopidy.models import Playlist logger = logging.getLogger('backends.base') class BaseBackend(object): - def __init__(self, mixer=None): + def __init__(self, core_queue=None, mixer=None): + self.core_queue = core_queue if mixer is not None: self.mixer = mixer else: 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. + core_queue = None + #: The current playlist controller. An instance of #: :class:`BaseCurrentPlaylistController`. current_playlist = None diff --git a/mopidy/core.py b/mopidy/core.py index 374945d4..54d1e37b 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -11,7 +11,7 @@ class CoreProcess(multiprocessing.Process): self.core_queue = core_queue def run(self): - backend = get_class(settings.BACKENDS[0])() + backend = get_class(settings.BACKENDS[0])(core_queue=self.core_queue) frontend = get_class(settings.FRONTEND)(backend=backend) while True: message = self.core_queue.get() From be769652823b667630d5290f648ccbd3ce7678b2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 01:20:51 +0100 Subject: [PATCH 239/341] Add end_of_track supportto despotify --- mopidy/backends/__init__.py | 4 ++++ mopidy/backends/despotify.py | 6 ++++-- mopidy/core.py | 2 ++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 14b3a40b..f5317ac6 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -388,6 +388,10 @@ class BasePlaybackController(object): 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.""" + self.next() + def new_playlist_loaded_callback(self): """Tell the playback controller that a new playlist has been loaded.""" self.current_track = None diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 5bd3552b..11ad45c5 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -53,7 +53,8 @@ class DespotifyBackend(BaseBackend): logger.info(u'Connecting to Spotify') return DespotifySessionManager( settings.SPOTIFY_USERNAME.encode(ENCODING), - settings.SPOTIFY_PASSWORD.encode(ENCODING)) + settings.SPOTIFY_PASSWORD.encode(ENCODING), + core_queue=self.core_queue) class DespotifyCurrentPlaylistController(BaseCurrentPlaylistController): @@ -164,9 +165,10 @@ class DespotifySessionManager(spytify.Spytify): def __init__(self, *args, **kwargs): kwargs['callback'] = self.callback + self.core_queue = kwargs.pop('core_queue') super(DespotifySessionManager, self).__init__(*args, **kwargs) def callback(self, signal, data): if signal == self.DESPOTIFY_END_OF_PLAYLIST: logger.debug('Despotify signalled end of playlist') - # TODO Ask backend to play next track + self.core_queue.put({'command': 'end_of_track'}) diff --git a/mopidy/core.py b/mopidy/core.py index 54d1e37b..acb3b984 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -19,5 +19,7 @@ class CoreProcess(multiprocessing.Process): response = frontend.handle_request(message['request']) connection = unpickle_connection(message['reply_to']) connection.send(response) + elif message['command'] == 'end_of_track': + backend.playback.end_of_track_callback() else: logger.warning(u'Cannot handle message: %s', message) From dda49dde43162a0e3a4603d44b42d505d9f2bfc5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 01:28:17 +0100 Subject: [PATCH 240/341] If no next track, stop at end of track. --- mopidy/backends/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index f5317ac6..16f41b36 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -390,7 +390,10 @@ class BasePlaybackController(object): def end_of_track_callback(self): """Tell the playback controller that end of track is reached.""" - self.next() + if self.next_track is not None: + self.next() + else: + self.stop() def new_playlist_loaded_callback(self): """Tell the playback controller that a new playlist has been loaded.""" From 3d2aa08782099cd95de1d072c68f2530163a9916 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 01:37:30 +0100 Subject: [PATCH 241/341] Only do settings checking on all-uppercase attributes (to please Sphinx) --- mopidy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 5770716f..5e7b9988 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -35,7 +35,7 @@ class SettingsError(Exception): class Settings(object): def __getattr__(self, attr): - if not hasattr(raw_settings, attr): + if attr.isupper() and not hasattr(raw_settings, attr): raise SettingsError(u'Setting "%s" is not set.' % attr) value = getattr(raw_settings, attr) if type(value) != bool and not value: From e4feb863bb7afb55f544d3f5cad965ef9edc39b1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 01:50:39 +0100 Subject: [PATCH 242/341] docs: Update class instantiation diagram --- docs/development/internals.rst | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/development/internals.rst b/docs/development/internals.rst index 0062f2df..ca6edc8a 100644 --- a/docs/development/internals.rst +++ b/docs/development/internals.rst @@ -14,22 +14,32 @@ Class instantiation and usage The following diagram shows how Mopidy with the despotify backend and ALSA mixer is wired together. The gray nodes are part of external dependencies, and -not Mopidy. +not Mopidy. The red nodes lives in the ``main`` process (running an +:mod:`asyncore` loop), while the blue nodes lives in a secondary process named +``core`` (running a service loop in :class:`mopidy.core.CoreProcess`). .. digraph:: class_instantiation_and_usage "spytify" [ color="gray" ] "despotify" [ color="gray" ] "alsaaudio" [ color="gray" ] - "__main__" -> "MpdServer" [ label="create 1" ] - "__main__" -> "AlsaMixer" [ label="create 1" ] - "__main__" -> "DespotifyBackend" [ label="create 1" ] - "MpdServer" -> "MpdSession" [ label="create 1 per client" ] + "__main__" [ color="red" ] + "CoreProcess" [ color="blue" ] + "DespotifyBackend" [ color="blue" ] + "AlsaMixer" [ color="blue" ] + "MpdHandler" [ color="blue" ] + "MpdServer" [ color="red" ] + "MpdSession" [ color="red" ] + "__main__" -> "CoreProcess" [ label="create" ] + "__main__" -> "MpdServer" [ label="create" ] + "CoreProcess" -> "DespotifyBackend" [ label="create" ] + "CoreProcess" -> "MpdHandler" [ label="create" ] + "MpdServer" -> "MpdSession" [ label="create one per client" ] "MpdSession" -> "MpdHandler" [ label="pass MPD requests to" ] "MpdHandler" -> "DespotifyBackend" [ label="use backend API" ] + "DespotifyBackend" -> "AlsaMixer" [ label="create and use mixer API" ] "DespotifyBackend" -> "spytify" [ label="use Python wrapper" ] "spytify" -> "despotify" [ label="use C library" ] - "DespotifyBackend" -> "AlsaMixer" [ label="use mixer API" ] "AlsaMixer" -> "alsaaudio" [ label="use Python library" ] From 8a1d6ca22822ea8d332a3ffa535d74903b98c475 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 02:09:20 +0100 Subject: [PATCH 243/341] docs: Update thread communication docs --- docs/_static/thread_communication.png | Bin 48279 -> 46887 bytes docs/_static/thread_communication.txt | 66 +++++++++++++------------- docs/development/internals.rst | 16 +++++-- 3 files changed, 44 insertions(+), 38 deletions(-) diff --git a/docs/_static/thread_communication.png b/docs/_static/thread_communication.png index 5703b1044e8f1d1c31a43f5956fc5a7441c4aead..95bf1892dff82ebfc806f4595ded3abfec6061fe 100644 GIT binary patch literal 46887 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 z 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 literal 48279 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 TcQvR@6bf~h;?Bq&+86&1{emKs diff --git a/docs/_static/thread_communication.txt b/docs/_static/thread_communication.txt index 66ed34e9..4119004e 100644 --- a/docs/_static/thread_communication.txt +++ b/docs/_static/thread_communication.txt @@ -1,37 +1,37 @@ Script for use with www.websequencediagrams.com =============================================== -Main -> NadMixer: start -activate NadMixer -note over NadMixer: calibrate device -NadMixer -> Main: ready -Main -> MpdHandler/Backend: start -activate MpdHandler/Backend -MpdHandler/Backend -> despotify: connect to Spotify +Main -> Core: create +activate Core +note over Core: create NadMixer +Core -> NadTalker: create +activate NadTalker +note over NadTalker: calibrate device +note over Core: create DespotifyBackend +Core -> despotify: connect to Spotify activate despotify -MpdHandler/Backend -> Main: ready -Main -> MpdServer/MpdSession: start -activate MpdServer/MpdSession -note over MpdServer/MpdSession: open port -MpdServer/MpdSession -> Main: ready -Client -> MpdServer/MpdSession: connect -note over MpdServer/MpdSession: open session -Client -> MpdServer/MpdSession: play 1 -MpdServer/MpdSession -> MpdHandler/Backend: play 1 -MpdHandler/Backend -> despotify: play first track -Client -> MpdServer/MpdSession: setvol 50 -MpdServer/MpdSession -> MpdHandler/Backend: setvol 50 -MpdHandler/Backend -> NadMixer: volume = 50 -Client -> MpdServer/MpdSession: status -MpdServer/MpdSession -> MpdHandler/Backend: status -MpdHandler/Backend -> NadMixer: volume? -NadMixer -> MpdHandler/Backend: volume = 50 -MpdHandler/Backend -> MpdServer/MpdSession: status response -MpdServer/MpdSession -> Client: status response -despotify -> MpdHandler/Backend: end of track callback -MpdHandler/Backend -> despotify: play second track -Client -> MpdServer/MpdSession: stop -MpdServer/MpdSession -> MpdHandler/Backend: stop -MpdHandler/Backend -> despotify: stop -Client -> MpdServer/MpdSession: disconnect -note over MpdServer/MpdSession: close session +note over Core: create MpdFrontend +Main -> Server: create +activate Server +note over Server: open port +Client -> Server: connect +note over Server: open session +Client -> Server: play 1 +Server -> Core: play 1 +Core -> despotify: play first track +Client -> Server: setvol 50 +Server -> Core: setvol 50 +Core -> NadTalker: volume = 50 +Client -> Server: status +Server -> Core: status +Core -> NadTalker: volume? +NadTalker -> Core: volume = 50 +Core -> Server: status response +Server -> Client: status response +despotify -> Core: end of track callback +Core -> despotify: play second track +Client -> Server: stop +Server -> Core: stop +Core -> despotify: stop +Client -> Server: disconnect +note over Server: close session diff --git a/docs/development/internals.rst b/docs/development/internals.rst index ca6edc8a..26aeb86d 100644 --- a/docs/development/internals.rst +++ b/docs/development/internals.rst @@ -43,11 +43,17 @@ not Mopidy. The red nodes lives in the ``main`` process (running an "AlsaMixer" -> "alsaaudio" [ label="use Python library" ] -Thread communication -==================== +Thread/process communication +============================ -.. warning:: - - This is a plan, and does not necessarily reflect what has been implemented. +- Everything starts with ``Main``. +- ``Main`` creates a ``Core`` process which runs the frontend, backend, and + mixer. +- Mixers *may* create an additional process for communication with external + devices, like ``NadTalker`` in this example. +- Backend libraries *may* have threads of their own, like ``despotify`` here + which has additional threads in the ``Core`` process. +- ``Server`` part currently runs in the same process and thread as ``Main``. +- ``Client`` is some external client talking to ``Server`` over a socket. .. image:: /_static/thread_communication.png From f12a7aa6ceeb2eb2274829204a55c8f0fc4fd2eb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 02:19:44 +0100 Subject: [PATCH 244/341] Comment out use of 'session' in MpdHandler, as it doesn't work anymore --- mopidy/mpd/handler.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index fdd9a5b9..2c67c44a 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -42,8 +42,7 @@ def flatten(the_list): return result class MpdHandler(object): - def __init__(self, session=None, backend=None): - self.session = session + def __init__(self, backend=None): self.backend = backend self.command_list = False @@ -179,7 +178,8 @@ class MpdHandler(object): Closes the connection to MPD. """ - self.session.do_close() + # TODO Does not work after multiprocessing branch merge + #self.session.do_close() @handle_pattern(r'^kill$') def _connection_kill(self): @@ -190,7 +190,8 @@ class MpdHandler(object): Kills MPD. """ - self.session.do_kill() + # TODO Does not work after multiprocessing branch merge + #self.session.do_kill() @handle_pattern(r'^password "(?P[^"]+)"$') def _connection_password(self, password): @@ -1104,7 +1105,8 @@ class MpdHandler(object): 'artists': 0, # TODO 'albums': 0, # TODO 'songs': 0, # TODO - 'uptime': self.session.stats_uptime(), + # TODO Does not work after multiprocessing branch merge + 'uptime': 0, # self.session.stats_uptime(), 'db_playtime': 0, # TODO 'db_update': 0, # TODO 'playtime': 0, # TODO From a2d728d78c476408213b3bd5d8730c53c2d86b67 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 02:20:09 +0100 Subject: [PATCH 245/341] Move MpdSession into mopidy.mpd.server --- mopidy/mpd/server.py | 83 +++++++++++++++++++++++++++++++++++++++---- mopidy/mpd/session.py | 77 --------------------------------------- 2 files changed, 77 insertions(+), 83 deletions(-) delete mode 100644 mopidy/mpd/session.py diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index a61e2aa8..fd5a24aa 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -1,18 +1,24 @@ +import asynchat import asyncore import logging +import multiprocessing import socket import sys import time -from mopidy import settings -from mopidy.mpd.session import MpdSession +from mopidy import get_mpd_protocol_version, pickle_connection, settings +from mopidy.mpd import MpdAckError logger = logging.getLogger(u'mpd.server') +#: All data between the client and the server is encoded in UTF-8. +ENCODING = u'utf-8' + +LINE_TERMINATOR = u'\n' + class MpdServer(asyncore.dispatcher): - def __init__(self, session_class=MpdSession, core_queue=None): + def __init__(self, core_queue=None): asyncore.dispatcher.__init__(self) - self.session_class = session_class self.core_queue = core_queue self.create_socket(socket.AF_INET, socket.SOCK_STREAM) self.set_reuse_addr() @@ -25,8 +31,7 @@ class MpdServer(asyncore.dispatcher): def handle_accept(self): (client_socket, client_address) = self.accept() logger.info(u'Connection from: [%s]:%s', *client_address) - self.session_class(self, client_socket, client_address, - core_queue=self.core_queue) + MpdSession(self, client_socket, client_address, self.core_queue) def handle_close(self): self.close() @@ -39,3 +44,69 @@ class MpdServer(asyncore.dispatcher): @property def uptime(self): return int(time.time()) - self.started_at + + +class MpdSession(asynchat.async_chat): + def __init__(self, server, client_socket, client_address, core_queue): + asynchat.async_chat.__init__(self, sock=client_socket) + self.server = server + self.client_address = client_address + self.core_queue = core_queue + self.input_buffer = [] + self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) + self.send_response(u'OK MPD %s' % get_mpd_protocol_version()) + + def do_close(self): + logger.info(u'Closing connection with [%s]:%s', *self.client_address) + self.close_when_done() + + def do_kill(self): + self.server.do_kill() + + def collect_incoming_data(self, data): + self.input_buffer.append(data) + + def found_terminator(self): + data = ''.join(self.input_buffer).strip() + self.input_buffer = [] + input = data.decode(ENCODING) + logger.debug(u'Input: %s', indent(input)) + self.handle_request(input) + + def handle_request(self, input): + try: + my_end, other_end = multiprocessing.Pipe() + self.core_queue.put({ + 'command': 'mpd_request', + 'request': input, + 'reply_to': pickle_connection(other_end), + }) + my_end.poll(None) + response = my_end.recv() + if response is not None: + self.handle_response(response) + except MpdAckError, e: + logger.warning(e) + return self.send_response(u'ACK %s' % e) + + def handle_response(self, response): + self.send_response(LINE_TERMINATOR.join(response)) + + def send_response(self, output): + logger.debug(u'Output: %s', indent(output)) + output = u'%s%s' % (output, LINE_TERMINATOR) + data = output.encode(ENCODING) + self.push(data) + + def stats_uptime(self): + return self.server.uptime + + +def indent(string, places=4, linebreak=LINE_TERMINATOR): + lines = string.split(linebreak) + if len(lines) == 1: + return string + result = u'' + for line in lines: + result += linebreak + ' ' * places + line + return result diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py deleted file mode 100644 index 4c9bb0c5..00000000 --- a/mopidy/mpd/session.py +++ /dev/null @@ -1,77 +0,0 @@ -import asynchat -import logging -import multiprocessing - -from mopidy import get_mpd_protocol_version, pickle_connection -from mopidy.mpd import MpdAckError - -logger = logging.getLogger(u'mpd.session') - -#: All data between the client and the server is encoded in UTF-8. -ENCODING = u'utf-8' - -LINE_TERMINATOR = u'\n' - -def indent(string, places=4, linebreak=LINE_TERMINATOR): - lines = string.split(linebreak) - if len(lines) == 1: - return string - result = u'' - for line in lines: - result += linebreak + ' ' * places + line - return result - -class MpdSession(asynchat.async_chat): - def __init__(self, server, client_socket, client_address, core_queue): - asynchat.async_chat.__init__(self, sock=client_socket) - self.server = server - self.client_address = client_address - self.core_queue = core_queue - self.input_buffer = [] - self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) - self.send_response(u'OK MPD %s' % get_mpd_protocol_version()) - - def do_close(self): - logger.info(u'Closing connection with [%s]:%s', *self.client_address) - self.close_when_done() - - def do_kill(self): - self.server.do_kill() - - def collect_incoming_data(self, data): - self.input_buffer.append(data) - - def found_terminator(self): - data = ''.join(self.input_buffer).strip() - self.input_buffer = [] - input = data.decode(ENCODING) - logger.debug(u'Input: %s', indent(input)) - self.handle_request(input) - - def handle_request(self, input): - try: - my_end, other_end = multiprocessing.Pipe() - self.core_queue.put({ - 'command': 'mpd_request', - 'request': input, - 'reply_to': pickle_connection(other_end), - }) - my_end.poll(None) - response = my_end.recv() - if response is not None: - self.handle_response(response) - except MpdAckError, e: - logger.warning(e) - return self.send_response(u'ACK %s' % e) - - def handle_response(self, response): - self.send_response(LINE_TERMINATOR.join(response)) - - def send_response(self, output): - logger.debug(u'Output: %s', indent(output)) - output = u'%s%s' % (output, LINE_TERMINATOR) - data = output.encode(ENCODING) - self.push(data) - - def stats_uptime(self): - return self.server.uptime From 38cecde96b89a5dfc9c5bb89a5c38b0a41297608 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 02:24:20 +0100 Subject: [PATCH 246/341] Exceptions won't jump over the multiprocessing connection --- mopidy/mpd/server.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index fd5a24aa..575acb20 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -74,20 +74,16 @@ class MpdSession(asynchat.async_chat): self.handle_request(input) def handle_request(self, input): - try: - my_end, other_end = multiprocessing.Pipe() - self.core_queue.put({ - 'command': 'mpd_request', - 'request': input, - 'reply_to': pickle_connection(other_end), - }) - my_end.poll(None) - response = my_end.recv() - if response is not None: - self.handle_response(response) - except MpdAckError, e: - logger.warning(e) - return self.send_response(u'ACK %s' % e) + my_end, other_end = multiprocessing.Pipe() + self.core_queue.put({ + 'command': 'mpd_request', + 'request': input, + 'reply_to': pickle_connection(other_end), + }) + my_end.poll(None) + response = my_end.recv() + if response is not None: + self.handle_response(response) def handle_response(self, response): self.send_response(LINE_TERMINATOR.join(response)) From 97449f7d43f47a73c2733e8a38a342c139698511 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 02:24:46 +0100 Subject: [PATCH 247/341] Return error message instead of throwing exception that can't be handled by anyone --- mopidy/mpd/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index 2c67c44a..25d0f2e1 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/handler.py @@ -62,7 +62,7 @@ class MpdHandler(object): return None else: return self.handle_response(result, add_ok) - raise MpdAckError(u'Unknown command: %s' % request) + return self.handle_response(u'ACK Unknown command: %s' % request) def handle_response(self, result, add_ok=True): response = [] From da441b36b3c740e723badb09e55ada76380f7a66 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 02:36:18 +0100 Subject: [PATCH 248/341] Rename MpdHandler to MpdFrontend --- docs/api/mpd.rst | 8 ++++++++ docs/api/mpd/handler.rst | 8 -------- docs/development/internals.rst | 8 ++++---- mopidy/mpd/{handler.py => frontend.py} | 4 ++-- mopidy/settings/default.py | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) create mode 100644 docs/api/mpd.rst delete mode 100644 docs/api/mpd/handler.rst rename mopidy/mpd/{handler.py => frontend.py} (99%) diff --git a/docs/api/mpd.rst b/docs/api/mpd.rst new file mode 100644 index 00000000..a74302dc --- /dev/null +++ b/docs/api/mpd.rst @@ -0,0 +1,8 @@ +************************************************ +:mod:`mopidy.mpd` -- MPD protocol implementation +************************************************ + +.. automodule:: mopidy.mpd.frontend + :synopsis: Our implementation of the MPD protocol. + :members: + :undoc-members: diff --git a/docs/api/mpd/handler.rst b/docs/api/mpd/handler.rst deleted file mode 100644 index 2fcd2302..00000000 --- a/docs/api/mpd/handler.rst +++ /dev/null @@ -1,8 +0,0 @@ -******************************************************** -:mod:`mopidy.mpd.handler` -- MPD protocol implementation -******************************************************** - -.. automodule:: mopidy.mpd.handler - :synopsis: Our implementation of the MPD protocol. - :members: - :undoc-members: diff --git a/docs/development/internals.rst b/docs/development/internals.rst index 26aeb86d..085b55ac 100644 --- a/docs/development/internals.rst +++ b/docs/development/internals.rst @@ -27,16 +27,16 @@ not Mopidy. The red nodes lives in the ``main`` process (running an "CoreProcess" [ color="blue" ] "DespotifyBackend" [ color="blue" ] "AlsaMixer" [ color="blue" ] - "MpdHandler" [ color="blue" ] + "MpdFrontend" [ color="blue" ] "MpdServer" [ color="red" ] "MpdSession" [ color="red" ] "__main__" -> "CoreProcess" [ label="create" ] "__main__" -> "MpdServer" [ label="create" ] "CoreProcess" -> "DespotifyBackend" [ label="create" ] - "CoreProcess" -> "MpdHandler" [ label="create" ] + "CoreProcess" -> "MpdFrontend" [ label="create" ] "MpdServer" -> "MpdSession" [ label="create one per client" ] - "MpdSession" -> "MpdHandler" [ label="pass MPD requests to" ] - "MpdHandler" -> "DespotifyBackend" [ label="use backend API" ] + "MpdSession" -> "MpdFrontend" [ label="pass MPD requests to" ] + "MpdFrontend" -> "DespotifyBackend" [ label="use backend API" ] "DespotifyBackend" -> "AlsaMixer" [ label="create and use mixer API" ] "DespotifyBackend" -> "spytify" [ label="use Python wrapper" ] "spytify" -> "despotify" [ label="use C library" ] diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/frontend.py similarity index 99% rename from mopidy/mpd/handler.py rename to mopidy/mpd/frontend.py index 25d0f2e1..cb8d109a 100644 --- a/mopidy/mpd/handler.py +++ b/mopidy/mpd/frontend.py @@ -17,7 +17,7 @@ import sys from mopidy.mpd import MpdAckError, MpdNotImplemented -logger = logging.getLogger('mpd.handler') +logger = logging.getLogger('mopidy.mpd.frontend') _request_handlers = {} @@ -41,7 +41,7 @@ def flatten(the_list): result.append(element) return result -class MpdHandler(object): +class MpdFrontend(object): def __init__(self, backend=None): self.backend = backend self.command_list = False diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index 874c2f87..3466aef0 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -27,8 +27,8 @@ CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] #: Protocol frontend to use. Default:: #: -#: FRONTEND = u'mopidy.mpd.handler.MpdHandler' -FRONTEND = u'mopidy.mpd.handler.MpdHandler' +#: FRONTEND = u'mopidy.mpd.frontend.MpdFrontend' +FRONTEND = u'mopidy.mpd.frontend.MpdFrontend' #: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers. #: From 935d7a707d050e867abe7e52e9b5c9e9a9e06f63 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 02:39:54 +0100 Subject: [PATCH 249/341] docs: Add docs for mopidy.mpd.server --- docs/api/mpd.rst | 18 +++++++++++++++--- mopidy/mpd/frontend.py | 2 +- mopidy/mpd/server.py | 14 +++++++++++++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/api/mpd.rst b/docs/api/mpd.rst index a74302dc..bd630cc2 100644 --- a/docs/api/mpd.rst +++ b/docs/api/mpd.rst @@ -1,8 +1,20 @@ -************************************************ -:mod:`mopidy.mpd` -- MPD protocol implementation -************************************************ +***************** +:mod:`mopidy.mpd` +***************** + +MPD protocol implementation +=========================== .. automodule:: mopidy.mpd.frontend :synopsis: Our implementation of the MPD protocol. :members: :undoc-members: + + +MPD server implementation +========================= + +.. automodule:: mopidy.mpd.server + :synopsis: Our MPD server implementation. + :members: + :undoc-members: diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index cb8d109a..f9404eb4 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -1,5 +1,5 @@ """ -Our MPD protocol implementation +This is our MPD protocol implementation. This is partly based upon the `MPD protocol documentation `_, which is a useful resource, but it is diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index 575acb20..a08cf4c4 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -1,3 +1,7 @@ +""" +This is our MPD server implementation. +""" + import asynchat import asyncore import logging @@ -17,7 +21,11 @@ ENCODING = u'utf-8' LINE_TERMINATOR = u'\n' class MpdServer(asyncore.dispatcher): - def __init__(self, core_queue=None): + """ + The MPD server. Creates a :class:`MpdSession` for each client connection. + """ + + def __init__(self, core_queue): asyncore.dispatcher.__init__(self) self.core_queue = core_queue self.create_socket(socket.AF_INET, socket.SOCK_STREAM) @@ -47,6 +55,10 @@ class MpdServer(asyncore.dispatcher): class MpdSession(asynchat.async_chat): + """ + The MPD client session. Dispatches MPD requests to the frontend. + """ + def __init__(self, server, client_socket, client_address, core_queue): asynchat.async_chat.__init__(self, sock=client_socket) self.server = server From 4c58d3b773b35dc1ae2b3a76566fccc4503ecc70 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 02:49:10 +0100 Subject: [PATCH 250/341] Fix tests --- tests/__main__.py | 2 +- tests/mpd/{handlertest.py => frontendtest.py} | 73 ++++++------------- 2 files changed, 25 insertions(+), 50 deletions(-) rename tests/mpd/{handlertest.py => frontendtest.py} (95%) diff --git a/tests/__main__.py b/tests/__main__.py index d3adfca0..54ad93a9 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -12,7 +12,7 @@ def main(): r.add_pair('mopidy/mixers/dummy.py', 'tests/mixers/dummytest.py') r.add_pair('mopidy/mixers/denon.py', 'tests/mixers/denontest.py') r.add_pair('mopidy/models.py', 'tests/modelstest.py') - r.add_pair('mopidy/mpd/handler.py', 'tests/mpd/handlertest.py') + r.add_pair('mopidy/mpd/frontend.py', 'tests/mpd/frontendtest.py') r.run() if __name__ == '__main__': diff --git a/tests/mpd/handlertest.py b/tests/mpd/frontendtest.py similarity index 95% rename from tests/mpd/handlertest.py rename to tests/mpd/frontendtest.py index 58187d1e..f2e7fc6c 100644 --- a/tests/mpd/handlertest.py +++ b/tests/mpd/frontendtest.py @@ -4,44 +4,30 @@ import unittest from mopidy.backends.dummy import DummyBackend from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track, Playlist -from mopidy.mpd import handler, MpdAckError - -class DummySession(object): - def do_close(self): - pass - - def do_kill(self): - pass - - def stats_uptime(self): - return 0 - +from mopidy.mpd import frontend, MpdAckError class RequestHandlerTest(unittest.TestCase): def setUp(self): self.m = DummyMixer() self.b = DummyBackend(mixer=self.m) - self.h = handler.MpdHandler(backend=self.b) + self.h = frontend.MpdFrontend(backend=self.b) def test_register_same_pattern_twice_fails(self): func = lambda: None try: - handler.handle_pattern('a pattern')(func) - handler.handle_pattern('a pattern')(func) + frontend.handle_pattern('a pattern')(func) + frontend.handle_pattern('a pattern')(func) self.fail('Registering a pattern twice shoulde raise ValueError') except ValueError: pass def test_handling_unknown_request_raises_exception(self): - try: - result = self.h.handle_request('an unhandled request') - self.fail(u'An unknown request should raise an exception') - except MpdAckError: - pass + result = self.h.handle_request('an unhandled request') + self.assert_(u'ACK Unknown command' in result[0]) def test_handling_known_request(self): expected = 'magic' - handler._request_handlers['known request'] = lambda x: expected + frontend._request_handlers['known request'] = lambda x: expected result = self.h.handle_request('known request') self.assert_(u'OK' in result) self.assert_(expected in result) @@ -50,7 +36,7 @@ class CommandListsTest(unittest.TestCase): def setUp(self): self.m = DummyMixer() self.b = DummyBackend(mixer=self.m) - self.h = handler.MpdHandler(backend=self.b) + self.h = frontend.MpdFrontend(backend=self.b) def test_command_list_begin(self): result = self.h.handle_request(u'command_list_begin') @@ -98,8 +84,7 @@ class StatusHandlerTest(unittest.TestCase): def setUp(self): self.m = DummyMixer() self.b = DummyBackend(mixer=self.m) - self.s = DummySession() - self.h = handler.MpdHandler(backend=self.b, session=self.s) + self.h = frontend.MpdFrontend(backend=self.b) def test_clearerror(self): result = self.h.handle_request(u'clearerror') @@ -308,7 +293,7 @@ class PlaybackOptionsHandlerTest(unittest.TestCase): def setUp(self): self.m = DummyMixer() self.b = DummyBackend(mixer=self.m) - self.h = handler.MpdHandler(backend=self.b) + self.h = frontend.MpdFrontend(backend=self.b) def test_consume_off(self): result = self.h.handle_request(u'consume "0"') @@ -428,7 +413,7 @@ class PlaybackControlHandlerTest(unittest.TestCase): def setUp(self): self.m = DummyMixer() self.b = DummyBackend(mixer=self.m) - self.h = handler.MpdHandler(backend=self.b) + self.h = frontend.MpdFrontend(backend=self.b) def test_next(self): result = self.h.handle_request(u'next') @@ -507,7 +492,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def setUp(self): self.m = DummyMixer() self.b = DummyBackend(mixer=self.m) - self.h = handler.MpdHandler(backend=self.b) + self.h = frontend.MpdFrontend(backend=self.b) def test_add(self): needle = Track(uri='dummy://foo') @@ -804,7 +789,7 @@ class StoredPlaylistsHandlerTest(unittest.TestCase): def setUp(self): self.m = DummyMixer() self.b = DummyBackend(mixer=self.m) - self.h = handler.MpdHandler(backend=self.b) + self.h = frontend.MpdFrontend(backend=self.b) def test_listplaylist(self): self.b.stored_playlists.playlists = [ @@ -877,7 +862,7 @@ class MusicDatabaseHandlerTest(unittest.TestCase): def setUp(self): self.m = DummyMixer() self.b = DummyBackend(mixer=self.m) - self.h = handler.MpdHandler(backend=self.b) + self.h = frontend.MpdFrontend(backend=self.b) def test_count(self): result = self.h.handle_request(u'count "tag" "needle"') @@ -910,11 +895,8 @@ class MusicDatabaseHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_find_else_should_fail(self): - try: - result = self.h.handle_request(u'find "somethingelse" "what"') - self.fail('Find with unknown type should fail') - except MpdAckError: - pass + result = self.h.handle_request(u'find "somethingelse" "what"') + self.assert_(u'ACK Unknown command' in result[0]) def test_findadd(self): result = self.h.handle_request(u'findadd "album" "what"') @@ -925,11 +907,8 @@ class MusicDatabaseHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_list_artist_with_artist_should_fail(self): - try: - result = self.h.handle_request(u'list "artist" "anartist"') - self.fail(u'Listing artists filtered by an artist should fail') - except MpdAckError: - pass + result = self.h.handle_request(u'list "artist" "anartist"') + self.assert_(u'ACK Unknown command' in result[0]) def test_list_album_without_artist(self): result = self.h.handle_request(u'list "album"') @@ -1002,11 +981,8 @@ class MusicDatabaseHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_search_else_should_fail(self): - try: - result = self.h.handle_request(u'search "sometype" "something"') - self.fail(u'Search with unknown type should fail') - except MpdAckError: - pass + result = self.h.handle_request(u'search "sometype" "something"') + self.assert_(u'ACK Unknown command' in result[0]) def test_update_without_uri(self): result = self.h.handle_request(u'update') @@ -1033,7 +1009,7 @@ class StickersHandlerTest(unittest.TestCase): def setUp(self): self.m = DummyMixer() self.b = DummyBackend(mixer=self.m) - self.h = handler.MpdHandler(backend=self.b) + self.h = frontend.MpdFrontend(backend=self.b) def test_sticker_get(self): result = self.h.handle_request( @@ -1070,8 +1046,7 @@ class ConnectionHandlerTest(unittest.TestCase): def setUp(self): self.m = DummyMixer() self.b = DummyBackend(mixer=self.m) - self.s = DummySession() - self.h = handler.MpdHandler(backend=self.b, session=self.s) + self.h = frontend.MpdFrontend(backend=self.b) def test_close(self): result = self.h.handle_request(u'close') @@ -1098,7 +1073,7 @@ class AudioOutputHandlerTest(unittest.TestCase): def setUp(self): self.m = DummyMixer() self.b = DummyBackend(mixer=self.m) - self.h = handler.MpdHandler(backend=self.b) + self.h = frontend.MpdFrontend(backend=self.b) def test_enableoutput(self): result = self.h.handle_request(u'enableoutput "0"') @@ -1120,7 +1095,7 @@ class ReflectionHandlerTest(unittest.TestCase): def setUp(self): self.m = DummyMixer() self.b = DummyBackend(mixer=self.m) - self.h = handler.MpdHandler(backend=self.b) + self.h = frontend.MpdFrontend(backend=self.b) def test_commands(self): result = self.h.handle_request(u'commands') From 590bab236480933ce6f8e059fa7454d4fd805ac5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 02:49:28 +0100 Subject: [PATCH 251/341] Remove unused methods --- mopidy/mpd/server.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index a08cf4c4..a4dec7d8 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -8,7 +8,6 @@ import logging import multiprocessing import socket import sys -import time from mopidy import get_mpd_protocol_version, pickle_connection, settings from mopidy.mpd import MpdAckError @@ -32,7 +31,6 @@ class MpdServer(asyncore.dispatcher): self.set_reuse_addr() self.bind((settings.SERVER_HOSTNAME, settings.SERVER_PORT)) self.listen(1) - self.started_at = int(time.time()) logger.info(u'Please connect to %s port %s using an MPD client.', settings.SERVER_HOSTNAME, settings.SERVER_PORT) @@ -44,15 +42,6 @@ class MpdServer(asyncore.dispatcher): def handle_close(self): self.close() - def do_kill(self): - logger.info(u'Received "kill". Shutting down.') - self.handle_close() - sys.exit(0) - - @property - def uptime(self): - return int(time.time()) - self.started_at - class MpdSession(asynchat.async_chat): """ @@ -68,13 +57,6 @@ class MpdSession(asynchat.async_chat): self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) self.send_response(u'OK MPD %s' % get_mpd_protocol_version()) - def do_close(self): - logger.info(u'Closing connection with [%s]:%s', *self.client_address) - self.close_when_done() - - def do_kill(self): - self.server.do_kill() - def collect_incoming_data(self, data): self.input_buffer.append(data) @@ -106,9 +88,6 @@ class MpdSession(asynchat.async_chat): data = output.encode(ENCODING) self.push(data) - def stats_uptime(self): - return self.server.uptime - def indent(string, places=4, linebreak=LINE_TERMINATOR): lines = string.split(linebreak) From 0afa5a5eeb65b63961c293f860deb2e6121333da Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 02:57:14 +0100 Subject: [PATCH 252/341] docs: Add inheritance diagram for mopidy.mpd.server --- docs/api/mpd.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/api/mpd.rst b/docs/api/mpd.rst index bd630cc2..021f5dcd 100644 --- a/docs/api/mpd.rst +++ b/docs/api/mpd.rst @@ -6,7 +6,7 @@ MPD protocol implementation =========================== .. automodule:: mopidy.mpd.frontend - :synopsis: Our implementation of the MPD protocol. + :synopsis: Our MPD protocol implementation. :members: :undoc-members: @@ -18,3 +18,5 @@ MPD server implementation :synopsis: Our MPD server implementation. :members: :undoc-members: + +.. inheritance-diagram:: mopidy.mpd.server From b83406afd197c5f475cfda62970330760207d364 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 02:59:44 +0100 Subject: [PATCH 253/341] Add 'mopidy' as logger name prefix all over --- mopidy/backends/__init__.py | 2 +- mopidy/backends/despotify.py | 2 +- mopidy/backends/libspotify.py | 2 +- mopidy/mpd/server.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 16f41b36..ac9fcb41 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -6,7 +6,7 @@ import time from mopidy import get_class, settings from mopidy.models import Playlist -logger = logging.getLogger('backends.base') +logger = logging.getLogger('mopidy.backends.base') class BaseBackend(object): def __init__(self, core_queue=None, mixer=None): diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 11ad45c5..92545411 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -9,7 +9,7 @@ from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, BaseStoredPlaylistsController) from mopidy.models import Artist, Album, Track, Playlist -logger = logging.getLogger(u'backends.despotify') +logger = logging.getLogger('mopidy.backends.despotify') ENCODING = 'utf-8' diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 086920b5..fd20d1c4 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -12,7 +12,7 @@ from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, BaseStoredPlaylistsController) from mopidy.models import Artist, Album, Track, Playlist -logger = logging.getLogger(u'backends.libspotify') +logger = logging.getLogger('mopidy.backends.libspotify') ENCODING = 'utf-8' diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index a4dec7d8..f995d99e 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -12,7 +12,7 @@ import sys from mopidy import get_mpd_protocol_version, pickle_connection, settings from mopidy.mpd import MpdAckError -logger = logging.getLogger(u'mpd.server') +logger = logging.getLogger('mopidy.mpd.server') #: All data between the client and the server is encoded in UTF-8. ENCODING = u'utf-8' From 749e3ef281e20e16ec6917a7fd42daa1a5051c64 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 03:15:27 +0100 Subject: [PATCH 254/341] docs: Document handle_pattern decorator --- mopidy/mpd/frontend.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index f9404eb4..f2c10bbb 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -22,6 +22,23 @@ logger = logging.getLogger('mopidy.mpd.frontend') _request_handlers = {} def handle_pattern(pattern): + """ + Decorator for connecting command handlers to command patterns. + + If you use named groups in the pattern, the decorated method will get the + groups as keyword arguments. If the group is optional, remember to give the + argument a default value. + + For example, if the command is ``do that thing`` the ``what`` argument will + be ``this thing``:: + + @handle_pattern('^do (?P.+)$') + def do(what): + ... + + :param pattern: regexp pattern for matching commands + :type pattern: string + """ def decorator(func): if pattern in _request_handlers: raise ValueError(u'Tried to redefine handler for %s with %s' % ( From b4bc81e19e3d07bc6be3b6add96883dd6a2f7485 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 03:22:44 +0100 Subject: [PATCH 255/341] Move utility functions to mopidy.utils --- mopidy/__init__.py | 24 ---------------------- mopidy/__main__.py | 3 ++- mopidy/backends/__init__.py | 3 ++- mopidy/core.py | 3 ++- mopidy/mpd/frontend.py | 10 +-------- mopidy/mpd/server.py | 16 ++++----------- mopidy/utils.py | 41 +++++++++++++++++++++++++++++++++++++ 7 files changed, 52 insertions(+), 48 deletions(-) create mode 100644 mopidy/utils.py diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 5e7b9988..1969e9c8 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,35 +1,11 @@ -import logging -from multiprocessing.reduction import reduce_connection -import pickle - from mopidy import settings as raw_settings -logger = logging.getLogger('mopidy') - def get_version(): return u'0.1.dev' def get_mpd_protocol_version(): return u'0.16.0' -def get_class(name): - module_name = name[:name.rindex('.')] - class_name = name[name.rindex('.') + 1:] - logger.info('Loading: %s from %s', class_name, module_name) - module = __import__(module_name, globals(), locals(), [class_name], -1) - class_object = getattr(module, class_name) - return class_object - -def pickle_connection(connection): - return pickle.dumps(reduce_connection(connection)) - -def unpickle_connection(pickled_connection): - # From http://stackoverflow.com/questions/1446004 - unpickled = pickle.loads(pickled_connection) - func = unpickled[0] - args = unpickled[1] - return func(*args) - class SettingsError(Exception): pass diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 0666d99b..b2dc9123 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -7,8 +7,9 @@ import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import get_class, settings, SettingsError +from mopidy import settings, SettingsError from mopidy.core import CoreProcess +from mopidy.utils import get_class logger = logging.getLogger('mopidy.main') diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index ac9fcb41..b9ec17d6 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -3,8 +3,9 @@ import logging import random import time -from mopidy import get_class, settings +from mopidy import settings from mopidy.models import Playlist +from mopidy.utils import get_class logger = logging.getLogger('mopidy.backends.base') diff --git a/mopidy/core.py b/mopidy/core.py index acb3b984..4eb65fbb 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -1,7 +1,8 @@ import logging import multiprocessing -from mopidy import get_class, settings, unpickle_connection +from mopidy import settings +from mopidy.utils import get_class, unpickle_connection logger = logging.getLogger('mopidy.core') diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index f2c10bbb..71aea66e 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -16,6 +16,7 @@ import re import sys from mopidy.mpd import MpdAckError, MpdNotImplemented +from mopidy.utils import flatten logger = logging.getLogger('mopidy.mpd.frontend') @@ -49,15 +50,6 @@ def handle_pattern(pattern): return func return decorator -def flatten(the_list): - result = [] - for element in the_list: - if isinstance(element, list): - result.extend(flatten(element)) - else: - result.append(element) - return result - class MpdFrontend(object): def __init__(self, backend=None): self.backend = backend diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index f995d99e..3b4d42ad 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -9,14 +9,16 @@ import multiprocessing import socket import sys -from mopidy import get_mpd_protocol_version, pickle_connection, settings +from mopidy import get_mpd_protocol_version, settings from mopidy.mpd import MpdAckError +from mopidy.utils import indent, pickle_connection logger = logging.getLogger('mopidy.mpd.server') -#: All data between the client and the server is encoded in UTF-8. +#: The MPD protocol uses UTF-8 for encoding all data. ENCODING = u'utf-8' +#: The MPD protocol uses ``\n`` as line terminator. LINE_TERMINATOR = u'\n' class MpdServer(asyncore.dispatcher): @@ -87,13 +89,3 @@ class MpdSession(asynchat.async_chat): output = u'%s%s' % (output, LINE_TERMINATOR) data = output.encode(ENCODING) self.push(data) - - -def indent(string, places=4, linebreak=LINE_TERMINATOR): - lines = string.split(linebreak) - if len(lines) == 1: - return string - result = u'' - for line in lines: - result += linebreak + ' ' * places + line - return result diff --git a/mopidy/utils.py b/mopidy/utils.py new file mode 100644 index 00000000..90bd8d75 --- /dev/null +++ b/mopidy/utils.py @@ -0,0 +1,41 @@ +import logging +from multiprocessing.reduction import reduce_connection +import pickle + +logger = logging.getLogger('mopidy.utils') + +def flatten(the_list): + result = [] + for element in the_list: + if isinstance(element, list): + result.extend(flatten(element)) + else: + result.append(element) + return result + +def get_class(name): + module_name = name[:name.rindex('.')] + class_name = name[name.rindex('.') + 1:] + logger.info('Loading: %s', name) + module = __import__(module_name, globals(), locals(), [class_name], -1) + class_object = getattr(module, class_name) + return class_object + +def indent(string, places=4, linebreak='\n'): + lines = string.split(linebreak) + if len(lines) == 1: + return string + result = u'' + for line in lines: + result += linebreak + ' ' * places + line + return result + +def pickle_connection(connection): + return pickle.dumps(reduce_connection(connection)) + +def unpickle_connection(pickled_connection): + # From http://stackoverflow.com/questions/1446004 + unpickled = pickle.loads(pickled_connection) + func = unpickled[0] + args = unpickled[1] + return func(*args) From e00e50c9cc553211ab057e68f49536b62c8ef343 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 03:27:48 +0100 Subject: [PATCH 256/341] Prettify unpickle_connection --- mopidy/utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mopidy/utils.py b/mopidy/utils.py index 90bd8d75..c7024a7d 100644 --- a/mopidy/utils.py +++ b/mopidy/utils.py @@ -35,7 +35,5 @@ def pickle_connection(connection): def unpickle_connection(pickled_connection): # From http://stackoverflow.com/questions/1446004 - unpickled = pickle.loads(pickled_connection) - func = unpickled[0] - args = unpickled[1] + (func, args) = pickle.loads(pickled_connection) return func(*args) From 1259b3d70f284848d65475ce460dfea07cf79203 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 10:28:59 +0100 Subject: [PATCH 257/341] NadMixer: Only control speaker sets and input sources if defined in settings --- mopidy/mixers/nad.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index e8c8e47b..51592f30 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -94,6 +94,7 @@ class NadTalker(Process): def _open_connection(self): # Opens serial connection to the device. # Communication settings: 115200 bps 8N1 + logger.info(u'Connecting to serial device "%s"', MIXER_EXT_PORT) self._device = Serial(port=MIXER_EXT_PORT, baudrate=115200, timeout=self.TIMEOUT) self._get_device_model() @@ -107,7 +108,7 @@ class NadTalker(Process): def _get_device_model(self): model = self._ask_device('Main.Model') - logger.info(u'Connected to device of model "%s"' % model) + logger.info(u'Connected to device of model "%s"', model) return model def _power_device_on(self): @@ -116,17 +117,20 @@ class NadTalker(Process): self._command_device('Main.Power', 'On') def _select_speakers(self): - while self._ask_device('Main.SpeakerA') != MIXER_EXT_SPEAKERS_A: - logger.info(u'Setting speakers A "%s"', MIXER_EXT_SPEAKERS_A) - self._command_device('Main.SpeakerA', MIXER_EXT_SPEAKERS_A) - while self._ask_device('Main.SpeakerB') != MIXER_EXT_SPEAKERS_B: - logger.info(u'Setting speakers B "%s"', MIXER_EXT_SPEAKERS_B) - self._command_device('Main.SpeakerB', MIXER_EXT_SPEAKERS_B) + if MIXER_EXT_SPEAKERS_A is not None: + while self._ask_device('Main.SpeakerA') != MIXER_EXT_SPEAKERS_A: + logger.info(u'Setting speakers A "%s"', MIXER_EXT_SPEAKERS_A) + self._command_device('Main.SpeakerA', MIXER_EXT_SPEAKERS_A) + if MIXER_EXT_SPEAKERS_B is not None: + while self._ask_device('Main.SpeakerB') != MIXER_EXT_SPEAKERS_B: + logger.info(u'Setting speakers B "%s"', MIXER_EXT_SPEAKERS_B) + self._command_device('Main.SpeakerB', MIXER_EXT_SPEAKERS_B) def _select_input_source(self): - while self._ask_device('Main.Source') != MIXER_EXT_SOURCE: - logger.info(u'Selecting input source "%s"', MIXER_EXT_SOURCE) - self._command_device('Main.Source', MIXER_EXT_SOURCE) + if MIXER_EXT_SOURCE is not None: + while self._ask_device('Main.Source') != MIXER_EXT_SOURCE: + logger.info(u'Selecting input source "%s"', MIXER_EXT_SOURCE) + self._command_device('Main.Source', MIXER_EXT_SOURCE) def _unmute(self): while self._ask_device('Main.Mute') != 'Off': From 2819d21bf9a2ba9ee5d29fac925066b35ef877ae Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 11:31:09 +0100 Subject: [PATCH 258/341] Rename mopidy.core to mopidy.process --- mopidy/__main__.py | 4 ++-- mopidy/{core.py => process.py} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename mopidy/{core.py => process.py} (95%) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index b2dc9123..6bc08d83 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -8,7 +8,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) from mopidy import settings, SettingsError -from mopidy.core import CoreProcess +from mopidy.process import CoreProcess from mopidy.utils import get_class logger = logging.getLogger('mopidy.main') @@ -18,7 +18,7 @@ def main(): core_queue = multiprocessing.Queue() core = CoreProcess(core_queue) core.start() - get_class(settings.SERVER)(core_queue=core_queue) + get_class(settings.SERVER)(core_queue) asyncore.loop() def _setup_logging(verbosity_level): diff --git a/mopidy/core.py b/mopidy/process.py similarity index 95% rename from mopidy/core.py rename to mopidy/process.py index 4eb65fbb..673b542d 100644 --- a/mopidy/core.py +++ b/mopidy/process.py @@ -4,7 +4,7 @@ import multiprocessing from mopidy import settings from mopidy.utils import get_class, unpickle_connection -logger = logging.getLogger('mopidy.core') +logger = logging.getLogger('mopidy.process') class CoreProcess(multiprocessing.Process): def __init__(self, core_queue): From d88895f81c563fe76f1b4d217f9b25092c40d861 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 11:48:06 +0100 Subject: [PATCH 259/341] Log class loading to debug level --- mopidy/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils.py b/mopidy/utils.py index c7024a7d..99a050be 100644 --- a/mopidy/utils.py +++ b/mopidy/utils.py @@ -16,7 +16,7 @@ def flatten(the_list): def get_class(name): module_name = name[:name.rindex('.')] class_name = name[name.rindex('.') + 1:] - logger.info('Loading: %s', name) + logger.debug('Loading: %s', name) module = __import__(module_name, globals(), locals(), [class_name], -1) class_object = getattr(module, class_name) return class_object From f8c440d849a794298f119735ebe10290fc66f2ec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 11:48:39 +0100 Subject: [PATCH 260/341] Add -v and -q options for more or less output --- mopidy/__main__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 6bc08d83..5fb95c75 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,6 +1,7 @@ import asyncore import logging import multiprocessing +import optparse import os import sys @@ -14,13 +15,24 @@ from mopidy.utils import get_class logger = logging.getLogger('mopidy.main') def main(): - _setup_logging(2) + options, args = _parse_options() + _setup_logging(options.verbosity_level) core_queue = multiprocessing.Queue() core = CoreProcess(core_queue) core.start() get_class(settings.SERVER)(core_queue) asyncore.loop() +def _parse_options(): + parser = optparse.OptionParser() + parser.add_option('-q', '--quiet', + action='store_const', const=0, dest='verbosity_level', + help='less output (warning level)') + parser.add_option('-v', '--verbose', + action='store_const', const=2, dest='verbosity_level', + help='more output (debug level)') + return parser.parse_args() + def _setup_logging(verbosity_level): if verbosity_level == 0: level = logging.WARNING From c034558214af2aa728c8d7a15519d0fb1741b6f2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 11:49:45 +0100 Subject: [PATCH 261/341] despotify: Log to info level when done caching playlists --- mopidy/backends/despotify.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 92545411..7920da13 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -110,6 +110,7 @@ class DespotifyStoredPlaylistsController(BaseStoredPlaylistsController): self._playlists = playlists logger.debug(u'Available playlists: %s', u', '.join([u'<%s>' % p.name for p in self.playlists])) + logger.info(u'Done caching stored playlists') class DespotifyTranslator(object): From 55ea5a2d3ea12218a37d05ca992cf6f68bc318df Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 11:52:28 +0100 Subject: [PATCH 262/341] Improve logging from MpdServer --- mopidy/mpd/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index 3b4d42ad..fd5b5fb3 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -33,12 +33,12 @@ class MpdServer(asyncore.dispatcher): self.set_reuse_addr() self.bind((settings.SERVER_HOSTNAME, settings.SERVER_PORT)) self.listen(1) - logger.info(u'Please connect to %s port %s using an MPD client.', + logger.info(u'MPD server running at [%s]:%s', settings.SERVER_HOSTNAME, settings.SERVER_PORT) def handle_accept(self): (client_socket, client_address) = self.accept() - logger.info(u'Connection from: [%s]:%s', *client_address) + logger.info(u'MPD client connection from [%s]:%s', *client_address) MpdSession(self, client_socket, client_address, self.core_queue) def handle_close(self): From 55cc5b681d03084078c788dc7e55766d0b9551a0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 12:05:17 +0100 Subject: [PATCH 263/341] Formatting --- mopidy/__main__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 5fb95c75..f59e6322 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -40,10 +40,7 @@ def _setup_logging(verbosity_level): level = logging.DEBUG else: level = logging.INFO - logging.basicConfig( - format=settings.CONSOLE_LOG_FORMAT, - level=level, - ) + logging.basicConfig(format=settings.CONSOLE_LOG_FORMAT, level=level) if __name__ == '__main__': try: From 0504be1cf0debef474d9b449796269ba0bdc696d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 12:21:47 +0100 Subject: [PATCH 264/341] Catch Ctrl+C nicely in CoreProcess to avoid stacktrace --- mopidy/process.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/mopidy/process.py b/mopidy/process.py index 673b542d..144cc4b9 100644 --- a/mopidy/process.py +++ b/mopidy/process.py @@ -1,5 +1,6 @@ import logging import multiprocessing +import sys from mopidy import settings from mopidy.utils import get_class, unpickle_connection @@ -12,15 +13,26 @@ class CoreProcess(multiprocessing.Process): self.core_queue = core_queue def run(self): - backend = get_class(settings.BACKENDS[0])(core_queue=self.core_queue) - frontend = get_class(settings.FRONTEND)(backend=backend) - while True: - message = self.core_queue.get() - if message['command'] == 'mpd_request': - response = frontend.handle_request(message['request']) - connection = unpickle_connection(message['reply_to']) - connection.send(response) - elif message['command'] == 'end_of_track': - backend.playback.end_of_track_callback() - else: - logger.warning(u'Cannot handle message: %s', message) + try: + self._setup() + while True: + message = self.core_queue.get() + self._process_message(message) + except KeyboardInterrupt: + logger.info(u'Interrupted by user') + sys.exit(0) + + def _setup(self): + self._backend = get_class(settings.BACKENDS[0])( + core_queue=self.core_queue) + self._frontend = get_class(settings.FRONTEND)(backend=self._backend) + + def _process_message(self, message): + if message['command'] == 'mpd_request': + response = self._frontend.handle_request(message['request']) + connection = unpickle_connection(message['reply_to']) + connection.send(response) + elif message['command'] == 'end_of_track': + self._backend.playback.end_of_track_callback() + else: + logger.warning(u'Cannot handle message: %s', message) From 7749de7045323536b6bf5ec5f2949275cc9a6a73 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 12:22:21 +0100 Subject: [PATCH 265/341] Log SystemExit exceptions as errors --- mopidy/__main__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index f59e6322..17f77e86 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -46,6 +46,11 @@ if __name__ == '__main__': try: main() except KeyboardInterrupt: - sys.exit('\nInterrupted by user') + logger.info(u'Interrupted by user') + sys.exit(0) except SettingsError, e: - sys.exit('%s' % e) + logger.error(e) + sys.exit(1) + except SystemExit, e: + logger.error(e) + sys.exit(1) From c98d2f9a9796d7dcf063a7baae2b7b3b457a3c4c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 12:22:47 +0100 Subject: [PATCH 266/341] Exit on MPD server startup errors --- mopidy/mpd/server.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index fd5b5fb3..e9265ece 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -28,13 +28,16 @@ class MpdServer(asyncore.dispatcher): def __init__(self, core_queue): asyncore.dispatcher.__init__(self) - self.core_queue = core_queue - self.create_socket(socket.AF_INET, socket.SOCK_STREAM) - self.set_reuse_addr() - self.bind((settings.SERVER_HOSTNAME, settings.SERVER_PORT)) - self.listen(1) - logger.info(u'MPD server running at [%s]:%s', - settings.SERVER_HOSTNAME, settings.SERVER_PORT) + try: + self.core_queue = core_queue + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.set_reuse_addr() + self.bind((settings.SERVER_HOSTNAME, settings.SERVER_PORT)) + self.listen(1) + logger.info(u'MPD server running at [%s]:%s', + settings.SERVER_HOSTNAME, settings.SERVER_PORT) + except IOError, e: + sys.exit('MPD server startup failed: %s' % e) def handle_accept(self): (client_socket, client_address) = self.accept() From 7a5850d3b1f508d2a0ffae3f4b68f84f145ee98d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 12:24:13 +0100 Subject: [PATCH 267/341] Fail before second thread is started if MPD server cant start --- mopidy/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 17f77e86..85f40453 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -18,9 +18,9 @@ def main(): options, args = _parse_options() _setup_logging(options.verbosity_level) core_queue = multiprocessing.Queue() + get_class(settings.SERVER)(core_queue) core = CoreProcess(core_queue) core.start() - get_class(settings.SERVER)(core_queue) asyncore.loop() def _parse_options(): From b21a3e73fcead32e2a87f24cbf8beb72a3b8d1e3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 12:39:56 +0100 Subject: [PATCH 268/341] Add BaseProcess which does common error handling for all subprocesses --- mopidy/mixers/nad.py | 9 +++++---- mopidy/process.py | 26 +++++++++++++++++--------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 51592f30..7dd0b387 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -1,8 +1,9 @@ import logging from serial import Serial -from multiprocessing import Pipe, Process +from multiprocessing import Pipe from mopidy.mixers import BaseMixer +from mopidy.process import BaseProcess from mopidy.settings import (MIXER_EXT_PORT, MIXER_EXT_SOURCE, MIXER_EXT_SPEAKERS_A, MIXER_EXT_SPEAKERS_B) @@ -55,7 +56,7 @@ class NadMixer(BaseMixer): self._pipe.send({'command': 'set_volume', 'volume': volume}) -class NadTalker(Process): +class NadTalker(BaseProcess): """ Independent process which does the communication with the NAD device. @@ -78,10 +79,10 @@ class NadTalker(Process): _nad_volume = None def __init__(self, pipe=None): - Process.__init__(self) + super(NadTalker, self).__init__() self.pipe = pipe - def run(self): + def _run(self): self._open_connection() self._set_device_to_known_state() while self.pipe.poll(None): diff --git a/mopidy/process.py b/mopidy/process.py index 144cc4b9..2b05eefc 100644 --- a/mopidy/process.py +++ b/mopidy/process.py @@ -7,21 +7,29 @@ from mopidy.utils import get_class, unpickle_connection logger = logging.getLogger('mopidy.process') -class CoreProcess(multiprocessing.Process): - def __init__(self, core_queue): - multiprocessing.Process.__init__(self) - self.core_queue = core_queue - +class BaseProcess(multiprocessing.Process): def run(self): try: - self._setup() - while True: - message = self.core_queue.get() - self._process_message(message) + self._run() except KeyboardInterrupt: logger.info(u'Interrupted by user') sys.exit(0) + def _run(self): + raise NotImplementedError + + +class CoreProcess(BaseProcess): + def __init__(self, core_queue): + super(CoreProcess, self).__init__() + self.core_queue = core_queue + + def _run(self): + self._setup() + while True: + message = self.core_queue.get() + self._process_message(message) + def _setup(self): self._backend = get_class(settings.BACKENDS[0])( core_queue=self.core_queue) From 432d70ff51c5efbb47760b815aab2d6fbbf63c65 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 16:50:18 +0100 Subject: [PATCH 269/341] Remove unused imports --- mopidy/mpd/frontend.py | 1 - mopidy/mpd/server.py | 1 - tests/backends/__init__.py | 2 -- tests/mixers/denontest.py | 1 - tests/mpd/frontendtest.py | 2 +- 5 files changed, 1 insertion(+), 6 deletions(-) diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index 71aea66e..8ffa08ab 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -13,7 +13,6 @@ implement our own MPD server which is compatible with the numerous existing import datetime as dt import logging import re -import sys from mopidy.mpd import MpdAckError, MpdNotImplemented from mopidy.utils import flatten diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index e9265ece..e6abb633 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -10,7 +10,6 @@ import socket import sys from mopidy import get_mpd_protocol_version, settings -from mopidy.mpd import MpdAckError from mopidy.utils import indent, pickle_connection logger = logging.getLogger('mopidy.mpd.server') diff --git a/tests/backends/__init__.py b/tests/backends/__init__.py index cec58c73..d91467dd 100644 --- a/tests/backends/__init__.py +++ b/tests/backends/__init__.py @@ -1,5 +1,3 @@ -from mopidy.models import Track - class BaseCurrentPlaylistControllerTest(object): uris = [] backend_class = None diff --git a/tests/mixers/denontest.py b/tests/mixers/denontest.py index e57f0c82..d0fb9a46 100644 --- a/tests/mixers/denontest.py +++ b/tests/mixers/denontest.py @@ -1,5 +1,4 @@ import unittest -import os from mopidy.mixers.denon import DenonMixer diff --git a/tests/mpd/frontendtest.py b/tests/mpd/frontendtest.py index f2e7fc6c..56c8f830 100644 --- a/tests/mpd/frontendtest.py +++ b/tests/mpd/frontendtest.py @@ -4,7 +4,7 @@ import unittest from mopidy.backends.dummy import DummyBackend from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track, Playlist -from mopidy.mpd import frontend, MpdAckError +from mopidy.mpd import frontend class RequestHandlerTest(unittest.TestCase): def setUp(self): From a82f7f77038b28c94a83fedae48796f0ae060fa1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 16:54:39 +0100 Subject: [PATCH 270/341] docs: Mark NAD/Denon mixer as done and add DNLA/UPnP to roadmap --- docs/development/roadmap.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index 01fac609..2add39aa 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -53,5 +53,5 @@ Crazy stuff we had to write down somewhere way. - AirPort Express support, like in `PulseAudio `_. -- NAD/Denon amplifier mixer through their RS-232 connection. (This I'm actually - going to sooner rather than later. --jodal) +- **Done.** NAD/Denon amplifier mixer through their RS-232 connection. +- DNLA and/or UPnP support. From 0c5c88e04f4847a0847bce830a9ef0585d0ecbab Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 17:15:44 +0100 Subject: [PATCH 271/341] libspotify: Use core_queue instead of direct backend access from callbacks --- mopidy/backends/libspotify.py | 19 +++++++++++-------- mopidy/process.py | 4 ++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index fd20d1c4..dc8a7662 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -50,7 +50,8 @@ class LibspotifyBackend(BaseBackend): def _connect(self): logger.info(u'Connecting to Spotify') spotify = LibspotifySessionManager( - settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD, backend=self) + settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD, + core_queue=self.core_queue) spotify.start() return spotify @@ -119,6 +120,7 @@ class LibspotifyStoredPlaylistsController(BaseStoredPlaylistsController): self._playlists = playlists logger.debug(u'Available playlists: %s', u', '.join([u'<%s>' % p.name for p in self.playlists])) + logger.info(u'Done refreshing stored playlists') class LibspotifyTranslator(object): @@ -173,10 +175,11 @@ class LibspotifyTranslator(object): class LibspotifySessionManager(SpotifySessionManager, threading.Thread): - def __init__(self, username, password, backend): + def __init__(self, username, password, core_queue): SpotifySessionManager.__init__(self, username, password) threading.Thread.__init__(self) - self.backend = backend + self.core_queue = core_queue + self.connected = threading.Event() self.audio = AlsaController() self.playlists = [] @@ -191,15 +194,14 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): logger.debug('Got playlist container') except Exception, e: logger.exception(e) + self.connected.set() def logged_out(self, session): logger.info('Logged out') def metadata_updated(self, session): logger.debug('Metadata updated') - # XXX This changes data "owned" by another thread, and leads to - # segmentation fault. We should use locking and messaging here. - self.backend.stored_playlists.refresh() + self.core_queue.put({'command': 'stored_playlists_updated'}) def connection_error(self, session, error): logger.error('Connection error: %s', error) @@ -215,14 +217,15 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def play_token_lost(self, session): logger.debug('Play token lost') - self.backend.playback.stop() + self.core_queue.put({'command': 'stop_playback'}) def log_message(self, session, data): logger.debug(data) def end_of_track(self, session): logger.debug('End of track') - self.backend.playback.next() + self.core_queue.put({'command': 'end_of_track'}) def search(self, query, callback): + self.connected.wait() self.session.search(query, callback) diff --git a/mopidy/process.py b/mopidy/process.py index 2b05eefc..9680f566 100644 --- a/mopidy/process.py +++ b/mopidy/process.py @@ -42,5 +42,9 @@ class CoreProcess(BaseProcess): connection.send(response) elif message['command'] == 'end_of_track': self._backend.playback.end_of_track_callback() + elif message['command'] == 'stop_playback': + self._backend.playback.stop() + elif message['command'] == 'stored_playlists_updated': + self._backend.stored_playlists.refresh() else: logger.warning(u'Cannot handle message: %s', message) From e8d4f01382e326c24904ee4d7879a2302fed5e45 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 20 Mar 2010 18:09:07 +0100 Subject: [PATCH 272/341] libspotify: Move all data translation into pyspotify thread. Rewrite search. --- mopidy/backends/libspotify.py | 70 ++++++++++++++++++++--------------- mopidy/process.py | 4 +- 2 files changed, 43 insertions(+), 31 deletions(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index dc8a7662..0f786778 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -1,5 +1,6 @@ import datetime as dt import logging +import multiprocessing import threading from spotify import Link @@ -44,7 +45,6 @@ class LibspotifyBackend(BaseBackend): self.stored_playlists = LibspotifyStoredPlaylistsController( backend=self) self.uri_handlers = [u'spotify:', u'http://open.spotify.com/'] - self.translate = LibspotifyTranslator() self.spotify = self._connect() def _connect(self): @@ -61,27 +61,19 @@ class LibspotifyCurrentPlaylistController(BaseCurrentPlaylistController): class LibspotifyLibraryController(BaseLibraryController): - _search_results = None - _search_results_received = threading.Event() - def search(self, type, what): - # FIXME When searching while playing music, this is really slow, like - # 12-14s between querying and getting results. - self._search_results_received.clear() if type is u'any': query = what else: query = u'%s:%s' % (type, what) - def callback(results, userdata): - logger.debug(u'Search results received') - self._search_results = results - self._search_results_received.set() - self.backend.spotify.search(query.encode(ENCODING), callback) - self._search_results_received.wait() - result = Playlist(tracks=[self.backend.translate.to_mopidy_track(t) - for t in self._search_results.tracks()]) - self._search_results = None - return result + my_end, other_end = multiprocessing.Pipe() + self.backend.spotify.search(query.encode(ENCODING), other_end) + my_end.poll(None) + logger.debug(u'In search method, receiving search results') + playlist = my_end.recv() + logger.debug(u'In search method, done receiving search results') + logger.debug(['%s' % t.name for t in playlist.tracks]) + return playlist find_exact = search @@ -111,16 +103,7 @@ class LibspotifyPlaybackController(BasePlaybackController): class LibspotifyStoredPlaylistsController(BaseStoredPlaylistsController): - def refresh(self): - logger.info(u'Refreshing stored playlists') - playlists = [] - for spotify_playlist in self.backend.spotify.playlists: - playlists.append( - self.backend.translate.to_mopidy_playlist(spotify_playlist)) - self._playlists = playlists - logger.debug(u'Available playlists: %s', - u', '.join([u'<%s>' % p.name for p in self.playlists])) - logger.info(u'Done refreshing stored playlists') + pass class LibspotifyTranslator(object): @@ -180,6 +163,7 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): threading.Thread.__init__(self) self.core_queue = core_queue self.connected = threading.Event() + self.translate = LibspotifyTranslator() self.audio = AlsaController() self.playlists = [] @@ -187,6 +171,7 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): self.connect() def logged_in(self, session, error): + """Callback used by pyspotify""" logger.info('Logged in') self.session = session try: @@ -197,35 +182,62 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): self.connected.set() def logged_out(self, session): + """Callback used by pyspotify""" logger.info('Logged out') def metadata_updated(self, session): + """Callback used by pyspotify""" logger.debug('Metadata updated') - self.core_queue.put({'command': 'stored_playlists_updated'}) + playlists = [] + for spotify_playlist in session.playlist_container(): + playlists.append( + self.translate.to_mopidy_playlist(spotify_playlist)) + self.core_queue.put({ + 'command': 'set_stored_playlists', + 'playlists': playlists, + }) def connection_error(self, session, error): + """Callback used by pyspotify""" logger.error('Connection error: %s', error) def message_to_user(self, session, message): + """Callback used by pyspotify""" logger.info(message) def notify_main_thread(self, session): + """Callback used by pyspotify""" logger.debug('Notify main thread') def music_delivery(self, *args, **kwargs): + """Callback used by pyspotify""" self.audio.music_delivery(*args, **kwargs) def play_token_lost(self, session): + """Callback used by pyspotify""" logger.debug('Play token lost') self.core_queue.put({'command': 'stop_playback'}) def log_message(self, session, data): + """Callback used by pyspotify""" logger.debug(data) def end_of_track(self, session): + """Callback used by pyspotify""" logger.debug('End of track') self.core_queue.put({'command': 'end_of_track'}) - def search(self, query, callback): + def search(self, query, connection): + """Search method used by Mopidy backend""" self.connected.wait() + def callback(results, userdata): + logger.debug(u'In search callback, translating search results') + logger.debug(results.tracks()) + # TODO Include results from results.albums(), etc. too + playlist = Playlist(tracks=[ + self.translate.to_mopidy_track(t) + for t in results.tracks()]) + logger.debug(u'In search callback, sending search results') + logger.debug(['%s' % t.name for t in playlist.tracks]) + connection.send(playlist) self.session.search(query, callback) diff --git a/mopidy/process.py b/mopidy/process.py index 9680f566..6e739e88 100644 --- a/mopidy/process.py +++ b/mopidy/process.py @@ -44,7 +44,7 @@ class CoreProcess(BaseProcess): self._backend.playback.end_of_track_callback() elif message['command'] == 'stop_playback': self._backend.playback.stop() - elif message['command'] == 'stored_playlists_updated': - self._backend.stored_playlists.refresh() + elif message['command'] == 'set_stored_playlists': + self._backend.stored_playlists.playlists = message['playlists'] else: logger.warning(u'Cannot handle message: %s', message) From 85ee2b71a17b8fe141e6fc0527f0646bc4384f7c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 21 Mar 2010 03:23:53 +0100 Subject: [PATCH 273/341] libspotify: Remove unused code --- mopidy/backends/libspotify.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 0f786778..894e9b05 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -165,7 +165,6 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): self.connected = threading.Event() self.translate = LibspotifyTranslator() self.audio = AlsaController() - self.playlists = [] def run(self): self.connect() @@ -174,11 +173,6 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): """Callback used by pyspotify""" logger.info('Logged in') self.session = session - try: - self.playlists = session.playlist_container() - logger.debug('Got playlist container') - except Exception, e: - logger.exception(e) self.connected.set() def logged_out(self, session): @@ -187,7 +181,7 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def metadata_updated(self, session): """Callback used by pyspotify""" - logger.debug('Metadata updated') + logger.debug('Metadata updated, refreshing stored playlists') playlists = [] for spotify_playlist in session.playlist_container(): playlists.append( From e27438bcec04e5e9a0325e92fbf75b00b7c0da32 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Mar 2010 21:36:24 +0100 Subject: [PATCH 274/341] Add spotify_uri_to_int(uri) util function --- mopidy/utils.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/mopidy/utils.py b/mopidy/utils.py index 99a050be..6b47e02b 100644 --- a/mopidy/utils.py +++ b/mopidy/utils.py @@ -37,3 +37,49 @@ def unpickle_connection(pickled_connection): # From http://stackoverflow.com/questions/1446004 (func, args) = pickle.loads(pickled_connection) return func(*args) + +def spotify_uri_to_int(uri, output_bits=31): + """ + Stable one-way translation from Spotify URI to 31-bit integer. + + Spotify track URIs has 62^22 possible values, which requires 131 bits of + storage. The original MPD server uses 32-bit unsigned integers for track + IDs. GMPC seems to think the track ID is a signed integer, thus we use 31 + output bits. + + In other words, this function throws away 100 bits of information. Since we + only use the track IDs to identify a track within a single Mopidy instance, + this information loss is acceptable. The chances of getting two different + tracks with the same track ID loaded in the same Mopidy instance is still + rather slim. 1 to 2,147,483,648 to be exact. + + Normal usage, with data loss:: + + >>> spotify_uri_to_int('spotify:track:5KRRcT67VNIZUygEbMoIC1') + 624351954 + + No data loss, may be converted back into a Spotify URI:: + + >>> spotify_uri_to_int('spotify:track:5KRRcT67VNIZUygEbMoIC1', + ... output_bits=131) + 101411513484007705241035418492696638725L + + :param uri: Spotify URI on the format ``spotify:track:*`` + :type uri: string + :param output_bits: number of bits of information kept in the return value + :type output_bits: int + :rtype: int + """ + + CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + BITS_PER_CHAR = 6 # int(math.ceil(math.log(len(CHARS), 2))) + + key = uri.split(':')[-1] + full_id = 0 + for i, char in enumerate(key): + full_id ^= CHARS.index(char) << BITS_PER_CHAR * i + compressed_id = 0 + while full_id != 0: + compressed_id ^= (full_id & (2 ** output_bits - 1)) + full_id >>= output_bits + return int(compressed_id) From b734948dcb2a649272dabfa70abef7282d669f72 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Mar 2010 21:36:55 +0100 Subject: [PATCH 275/341] Use spotify_uri_to_int in LibspotifyTranslator and DespotifyTranslator, making both stateless --- mopidy/backends/despotify.py | 10 ++-------- mopidy/backends/libspotify.py | 10 ++-------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 7920da13..6e4ccaec 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -8,6 +8,7 @@ from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, BaseLibraryController, BasePlaybackController, BaseStoredPlaylistsController) from mopidy.models import Artist, Album, Track, Playlist +from mopidy.utils import spotify_uri_to_int logger = logging.getLogger('mopidy.backends.despotify') @@ -114,15 +115,8 @@ class DespotifyStoredPlaylistsController(BaseStoredPlaylistsController): class DespotifyTranslator(object): - uri_to_id_map = {} - next_id = 0 - def to_mopidy_id(self, spotify_uri): - if spotify_uri not in self.uri_to_id_map: - this_id = self.next_id - self.next_id += 1 - self.uri_to_id_map[spotify_uri] = this_id - return self.uri_to_id_map[spotify_uri] + return spotify_uri_to_int(spotify_uri) def to_mopidy_artist(self, spotify_artist): return Artist( diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 894e9b05..2672079e 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -12,6 +12,7 @@ from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, BaseLibraryController, BasePlaybackController, BaseStoredPlaylistsController) from mopidy.models import Artist, Album, Track, Playlist +from mopidy.utils import spotify_uri_to_int logger = logging.getLogger('mopidy.backends.libspotify') @@ -107,15 +108,8 @@ class LibspotifyStoredPlaylistsController(BaseStoredPlaylistsController): class LibspotifyTranslator(object): - uri_to_id_map = {} - next_id = 0 - def to_mopidy_id(self, spotify_uri): - if spotify_uri not in self.uri_to_id_map: - this_id = self.next_id - self.next_id += 1 - self.uri_to_id_map[spotify_uri] = this_id - return self.uri_to_id_map[spotify_uri] + return spotify_uri_to_int(spotify_uri) def to_mopidy_artist(self, spotify_artist): if not spotify_artist.is_loaded(): From b352da3214623236a2f664e051cdd847c11cd5ab Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Mar 2010 21:48:41 +0100 Subject: [PATCH 276/341] Make methods of DespotifyTranslator and LibspotifyTranslator stateless classmethods --- mopidy/backends/despotify.py | 30 +++++++++++++++++------------- mopidy/backends/libspotify.py | 28 ++++++++++++++++------------ 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 6e4ccaec..9e429606 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -46,7 +46,6 @@ class DespotifyBackend(BaseBackend): self.playback = DespotifyPlaybackController(backend=self) self.stored_playlists = DespotifyStoredPlaylistsController(backend=self) self.uri_handlers = [u'spotify:', u'http://open.spotify.com/'] - self.translate = DespotifyTranslator() self.spotify = self._connect() self.stored_playlists.refresh() @@ -65,7 +64,7 @@ class DespotifyCurrentPlaylistController(BaseCurrentPlaylistController): class DespotifyLibraryController(BaseLibraryController): def lookup(self, uri): track = self.backend.spotify.lookup(uri.encode(ENCODING)) - return self.backend.translate.to_mopidy_track(track) + return DespotifyTranslator.to_mopidy_track(track) def search(self, type, what): if type == u'track': @@ -78,7 +77,7 @@ class DespotifyLibraryController(BaseLibraryController): if (result is None or result.playlist.tracks[0].get_uri() == 'spotify:track:0000000000000000000000'): return Playlist() - return self.backend.translate.to_mopidy_playlist(result.playlist) + return DespotifyTranslator.to_mopidy_playlist(result.playlist) find_exact = search @@ -107,7 +106,7 @@ class DespotifyStoredPlaylistsController(BaseStoredPlaylistsController): playlists = [] for spotify_playlist in self.backend.spotify.stored_playlists: playlists.append( - self.backend.translate.to_mopidy_playlist(spotify_playlist)) + DespotifyTranslator.to_mopidy_playlist(spotify_playlist)) self._playlists = playlists logger.debug(u'Available playlists: %s', u', '.join([u'<%s>' % p.name for p in self.playlists])) @@ -115,19 +114,23 @@ class DespotifyStoredPlaylistsController(BaseStoredPlaylistsController): class DespotifyTranslator(object): - def to_mopidy_id(self, spotify_uri): + @classmethod + def to_mopidy_id(cls, spotify_uri): return spotify_uri_to_int(spotify_uri) - def to_mopidy_artist(self, spotify_artist): + @classmethod + def to_mopidy_artist(cls, spotify_artist): return Artist( uri=spotify_artist.get_uri(), name=spotify_artist.name.decode(ENCODING) ) - def to_mopidy_album(self, spotify_album_name): + @classmethod + def to_mopidy_album(cls, spotify_album_name): return Album(name=spotify_album_name.decode(ENCODING)) - def to_mopidy_track(self, spotify_track): + @classmethod + def to_mopidy_track(cls, spotify_track): if dt.MINYEAR <= int(spotify_track.year) <= dt.MAXYEAR: date = dt.date(spotify_track.year, 1, 1) else: @@ -135,20 +138,21 @@ class DespotifyTranslator(object): return Track( uri=spotify_track.get_uri(), name=spotify_track.title.decode(ENCODING), - artists=[self.to_mopidy_artist(a) for a in spotify_track.artists], - album=self.to_mopidy_album(spotify_track.album), + artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists], + album=cls.to_mopidy_album(spotify_track.album), track_no=spotify_track.tracknumber, date=date, length=spotify_track.length, bitrate=320, - id=self.to_mopidy_id(spotify_track.get_uri()), + id=cls.to_mopidy_id(spotify_track.get_uri()), ) - def to_mopidy_playlist(self, spotify_playlist): + @classmethod + def to_mopidy_playlist(cls, spotify_playlist): return Playlist( uri=spotify_playlist.get_uri(), name=spotify_playlist.name.decode(ENCODING), - tracks=[self.to_mopidy_track(t) for t in spotify_playlist.tracks], + tracks=[cls.to_mopidy_track(t) for t in spotify_playlist.tracks], ) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index 2672079e..edd4567e 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -108,10 +108,12 @@ class LibspotifyStoredPlaylistsController(BaseStoredPlaylistsController): class LibspotifyTranslator(object): - def to_mopidy_id(self, spotify_uri): + @classmethod + def to_mopidy_id(cls, spotify_uri): return spotify_uri_to_int(spotify_uri) - def to_mopidy_artist(self, spotify_artist): + @classmethod + def to_mopidy_artist(cls, spotify_artist): if not spotify_artist.is_loaded(): return Artist(name=u'[loading...]') return Artist( @@ -119,35 +121,38 @@ class LibspotifyTranslator(object): name=spotify_artist.name().decode(ENCODING), ) - def to_mopidy_album(self, spotify_album): + @classmethod + def to_mopidy_album(cls, spotify_album): if not spotify_album.is_loaded(): return Album(name=u'[loading...]') # TODO pyspotify got much more data on albums than this return Album(name=spotify_album.name().decode(ENCODING)) - def to_mopidy_track(self, spotify_track): + @classmethod + def to_mopidy_track(cls, spotify_track): if not spotify_track.is_loaded(): return Track(name=u'[loading...]') uri = str(Link.from_track(spotify_track, 0)) return Track( uri=uri, name=spotify_track.name().decode(ENCODING), - artists=[self.to_mopidy_artist(a) for a in spotify_track.artists()], - album=self.to_mopidy_album(spotify_track.album()), + artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists()], + album=cls.to_mopidy_album(spotify_track.album()), track_no=spotify_track.index(), date=dt.date(spotify_track.album().year(), 1, 1), length=spotify_track.duration(), bitrate=320, - id=self.to_mopidy_id(uri), + id=cls.to_mopidy_id(uri), ) - def to_mopidy_playlist(self, spotify_playlist): + @classmethod + def to_mopidy_playlist(cls, spotify_playlist): if not spotify_playlist.is_loaded(): return Playlist(name=u'[loading...]') return Playlist( uri=str(Link.from_playlist(spotify_playlist)), name=spotify_playlist.name().decode(ENCODING), - tracks=[self.to_mopidy_track(t) for t in spotify_playlist], + tracks=[cls.to_mopidy_track(t) for t in spotify_playlist], ) @@ -157,7 +162,6 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): threading.Thread.__init__(self) self.core_queue = core_queue self.connected = threading.Event() - self.translate = LibspotifyTranslator() self.audio = AlsaController() def run(self): @@ -179,7 +183,7 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): playlists = [] for spotify_playlist in session.playlist_container(): playlists.append( - self.translate.to_mopidy_playlist(spotify_playlist)) + LibspotifyTranslator.to_mopidy_playlist(spotify_playlist)) self.core_queue.put({ 'command': 'set_stored_playlists', 'playlists': playlists, @@ -223,7 +227,7 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): logger.debug(results.tracks()) # TODO Include results from results.albums(), etc. too playlist = Playlist(tracks=[ - self.translate.to_mopidy_track(t) + LibspotifyTranslator.to_mopidy_track(t) for t in results.tracks()]) logger.debug(u'In search callback, sending search results') logger.debug(['%s' % t.name for t in playlist.tracks]) From 26bc1dc4358bad1db3ccc3fe3619f00f07946f31 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Mar 2010 21:57:44 +0100 Subject: [PATCH 277/341] Fail hard if Spotify login fails --- mopidy/backends/despotify.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 9e429606..f75c2d9c 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -51,10 +51,14 @@ class DespotifyBackend(BaseBackend): def _connect(self): logger.info(u'Connecting to Spotify') - return DespotifySessionManager( - settings.SPOTIFY_USERNAME.encode(ENCODING), - settings.SPOTIFY_PASSWORD.encode(ENCODING), - core_queue=self.core_queue) + try: + return DespotifySessionManager( + settings.SPOTIFY_USERNAME.encode(ENCODING), + settings.SPOTIFY_PASSWORD.encode(ENCODING), + core_queue=self.core_queue) + except spytify.SpytifyError as e: + logger.exception(e) + sys.exit(1) class DespotifyCurrentPlaylistController(BaseCurrentPlaylistController): From bf0560231827f5f9fcb4210810617078fcdc5524 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Mar 2010 22:09:00 +0100 Subject: [PATCH 278/341] Add support for 'playlistfind filename ...' --- mopidy/mpd/frontend.py | 11 +++++++++++ tests/mpd/frontendtest.py | 15 +++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index 8ffa08ab..7a402ebe 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -371,6 +371,7 @@ class MpdFrontend(object): """ return self._current_playlist_playlistinfo() + @handle_pattern(r'^playlistfind (?P[^"]+) "(?P[^"]+)"$') @handle_pattern(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') def _current_playlist_playlistfind(self, tag, needle): """ @@ -379,7 +380,17 @@ class MpdFrontend(object): ``playlistfind {TAG} {NEEDLE}`` Finds songs in the current playlist with strict matching. + + *GMPC:* + + - does not add quotes around the tag. """ + if tag == 'filename': + try: + track = self.backend.current_playlist.get_by_uri(needle) + return track.mpd_format() + except KeyError: + return None raise MpdNotImplemented # TODO @handle_pattern(r'^playlistid( "(?P\d+)")*$') diff --git a/tests/mpd/frontendtest.py b/tests/mpd/frontendtest.py index 56c8f830..881ee181 100644 --- a/tests/mpd/frontendtest.py +++ b/tests/mpd/frontendtest.py @@ -652,6 +652,21 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): result = self.h.handle_request(u'playlistfind "tag" "needle"') self.assert_(u'ACK Not implemented' in result) + def test_playlistfind_by_filename(self): + result = self.h.handle_request(u'playlistfind "filename" "file:///dev/null"') + self.assert_(u'OK' in result) + + def test_playlistfind_by_filename_without_quotes(self): + result = self.h.handle_request(u'playlistfind filename "file:///dev/null"') + self.assert_(u'OK' in result) + + def test_playlistfind_by_filename_in_current_playlist(self): + self.b.current_playlist.playlist = Playlist(tracks=[ + Track(uri='file:///exists')]) + result = self.h.handle_request(u'playlistfind filename "file:///exists"') + self.assert_(u'file: file:///exists' in result) + self.assert_(u'OK' in result) + def test_playlistid_without_songid(self): self.b.current_playlist.load(Playlist( tracks=[Track(name='a', id=33), Track(name='b', id=38)])) From b9ba8a929a539f24d674aed7d7ee98b490a6fcd3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Mar 2010 23:41:32 +0100 Subject: [PATCH 279/341] Switch to a StrictVersion-compatible version number --- mopidy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 1969e9c8..c1f8fed8 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,7 +1,7 @@ from mopidy import settings as raw_settings def get_version(): - return u'0.1.dev' + return u'0.1.0a0.dev0' def get_mpd_protocol_version(): return u'0.16.0' From 3235d2a1eaa0746f3724dc32907d19dde33a8a1f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Mar 2010 23:42:02 +0100 Subject: [PATCH 280/341] Add --version option support --- mopidy/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 85f40453..efe74da7 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -8,7 +8,7 @@ import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import settings, SettingsError +from mopidy import get_version, settings, SettingsError from mopidy.process import CoreProcess from mopidy.utils import get_class @@ -24,7 +24,7 @@ def main(): asyncore.loop() def _parse_options(): - parser = optparse.OptionParser() + parser = optparse.OptionParser(version='Mopidy %s' % get_version()) parser.add_option('-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') From accb873f8f1f64793cd102fb76c191a71bc30216 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Mar 2010 23:42:28 +0100 Subject: [PATCH 281/341] despotify: Log track play errors --- mopidy/backends/despotify.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index f75c2d9c..6757a285 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -175,3 +175,5 @@ class DespotifySessionManager(spytify.Spytify): if signal == self.DESPOTIFY_END_OF_PLAYLIST: logger.debug('Despotify signalled end of playlist') self.core_queue.put({'command': 'end_of_track'}) + elif signal == self.DESPOTIFY_TRACK_PLAY_ERROR: + logger.error('Despotify signalled track play error') From 4e00b89af0c4416bd8e874401da8e64dbfa5a760 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Mar 2010 23:42:56 +0100 Subject: [PATCH 282/341] Update .gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0ddcf2a3..7f47f084 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,12 @@ *.swp .coverage .idea +MANIFEST +build/ +dist/ docs/_build -pip-log.txt mopidy/settings/local.py +pip-log.txt spotify_appkey.key src/ tmp/ From fe7f58c6fa5b8e5daa3ad5cada872305d379f1d8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 22 Mar 2010 23:44:17 +0100 Subject: [PATCH 283/341] Add setup.py with friends --- MANIFEST.in | 4 ++++ bin/mopidy | 5 +++++ setup.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 MANIFEST.in create mode 100644 bin/mopidy create mode 100644 setup.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..6235b2c8 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include COPYING +include *.rst +include requirements*.txt +recursive-include docs *.rst diff --git a/bin/mopidy b/bin/mopidy new file mode 100644 index 00000000..0472518e --- /dev/null +++ b/bin/mopidy @@ -0,0 +1,5 @@ +#! /usr/bin/env python + +if __name__ == '__main__': + from mopidy.__main__ import main + main() diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..8d3389a5 --- /dev/null +++ b/setup.py @@ -0,0 +1,58 @@ +from distutils.core import setup +from distutils.command.install import INSTALL_SCHEMES +import os + +from mopidy import get_version + +def fullsplit(path, result=None): + """ + Split a pathname into components (the opposite of os.path.join) in a + platform-neutral way. + """ + if result is None: + result = [] + head, tail = os.path.split(path) + if head == '': + return [tail] + result + if head == path: + return result + return fullsplit(head, [tail] + result) + +# Tell distutils to put the data_files in platform-specific installation +# locations. See here for an explanation: +# http://groups.google.com/group/comp.lang.python/browse_thread/thread/35ec7b2fed36eaec/2105ee4d9e8042cb +for scheme in INSTALL_SCHEMES.values(): + scheme['data'] = scheme['purelib'] + +# Compile the list of packages available, because distutils doesn't have +# an easy way to do this. +packages, data_files = [], [] +root_dir = os.path.dirname(__file__) +if root_dir != '': + os.chdir(root_dir) +project_dir = 'mopidy' + +for dirpath, dirnames, filenames in os.walk(project_dir): + # Ignore dirnames that start with '.' + for i, dirname in enumerate(dirnames): + if dirname.startswith('.'): + del dirnames[i] + if '__init__.py' in filenames: + packages.append('.'.join(fullsplit(dirpath))) + elif filenames: + data_files.append([dirpath, + [os.path.join(dirpath, f) for f in filenames]]) + +setup( + name='Mopidy', + version=get_version(), + author='Stein Magnus Jodal', + author_email='stein.magnus@jodal.no', + packages=packages, + data_files=data_files, + scripts=['bin/mopidy'], + url='http://www.mopidy.com/', + license='GPLv2', + description='MPD server with Spotify support', + long_description=open('README.rst').read(), +) From 93b981de0732658c5e3a983ab1227bc767edf59d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 00:11:39 +0100 Subject: [PATCH 284/341] BaseProcess: Log SettingsError and exit --- mopidy/process.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/process.py b/mopidy/process.py index 6e739e88..9459f816 100644 --- a/mopidy/process.py +++ b/mopidy/process.py @@ -2,7 +2,7 @@ import logging import multiprocessing import sys -from mopidy import settings +from mopidy import settings, SettingsError from mopidy.utils import get_class, unpickle_connection logger = logging.getLogger('mopidy.process') @@ -14,6 +14,9 @@ class BaseProcess(multiprocessing.Process): except KeyboardInterrupt: logger.info(u'Interrupted by user') sys.exit(0) + except SettingsError, e: + logger.error(e) + sys.exit(1) def _run(self): raise NotImplementedError From 79b5942831c6bafd4da0d3584740fb62be14e4ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 00:23:11 +0100 Subject: [PATCH 285/341] Add get_or_create_dotdir util function --- mopidy/utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mopidy/utils.py b/mopidy/utils.py index 6b47e02b..697c48f4 100644 --- a/mopidy/utils.py +++ b/mopidy/utils.py @@ -1,5 +1,6 @@ import logging from multiprocessing.reduction import reduce_connection +import os import pickle logger = logging.getLogger('mopidy.utils') @@ -21,6 +22,13 @@ def get_class(name): class_object = getattr(module, class_name) return class_object +def get_or_create_dotdir(): + dotdir = os.path.expanduser(u'~/.mopidy/') + if not os.path.isdir(dotdir): + logger.info(u'Creating %s', dotdir) + os.mkdir(dotdir, 0755) + return dotdir + def indent(string, places=4, linebreak='\n'): lines = string.split(linebreak) if len(lines) == 1: From 9a09692e998ecc6588608d23dcaff577dca4e9f3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 00:44:15 +0100 Subject: [PATCH 286/341] docs: Moved the Mopidy settings file --- docs/api/settings.rst | 6 +++--- docs/installation/index.rst | 4 ++-- mopidy/settings/__init__.py | 16 +++++++++++----- mopidy/settings/default.py | 2 +- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/api/settings.rst b/docs/api/settings.rst index 749f275d..7d85a68a 100644 --- a/docs/api/settings.rst +++ b/docs/api/settings.rst @@ -7,11 +7,11 @@ Changing settings ================= For any Mopidy installation you will need to change at least a couple of -settings. To do this, create a new file in the ``mopidy/settings/`` directory -named ``local.py`` and add settings you need to change from their defaults +settings. To do this, create a new file in the ``~/.mopidy/`` directory +named ``settings.py`` and add settings you need to change from their defaults there. -A complete ``mopidy/settings/local.py`` may look like this:: +A complete ``~/.mopidy/settings.py`` may look like this:: MPD_SERVER_HOSTNAME = u'0.0.0.0' SPOTIFY_USERNAME = u'alice' diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 934c9181..e9121ed5 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -55,7 +55,7 @@ And on OS X, assuming you allready got git installed, e.g. from Homebrew:: Spotify settings ================ -Create a file named ``local.py`` in the directory ``mopidy/settings/``. Enter +Create a file named ``settings.py`` in the directory ``~/.mopidy/``. Enter your Spotify Premium account's username and password into the file, like this:: SPOTIFY_USERNAME = u'myusername' @@ -68,7 +68,7 @@ Switching backend Currently the despotify backend is the default. If you want to use the libspotify backend instead, copy the Spotify application key to ``mopidy/spotify_appkey.key``, and add the following to -``mopidy/mopidy/settings/local.py``:: +``~/.mopidy/settings.py``:: BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',) diff --git a/mopidy/settings/__init__.py b/mopidy/settings/__init__.py index 58715fd0..caee252e 100644 --- a/mopidy/settings/__init__.py +++ b/mopidy/settings/__init__.py @@ -1,6 +1,12 @@ -from mopidy.settings.default import * +import os -try: - from mopidy.settings.local import * -except ImportError: - pass +from mopidy.settings.default import * +from mopidy.utils import get_or_create_dotdir + +dotdir = get_or_create_dotdir() +settings_file = os.path.join(dotdir, u'settings.py') +if not os.path.isfile(settings_file): + logger.warning(u'Settings not found: %s', settings_file) +else: + sys.path.insert(0, dotdir) + from settings import * diff --git a/mopidy/settings/default.py b/mopidy/settings/default.py index 3466aef0..03b831b8 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings/default.py @@ -4,7 +4,7 @@ Available settings and their default values. .. warning:: Do *not* change settings in ``mopidy/settings/default.py``. Instead, add a - file called ``mopidy/settings/local.py`` and redefine settings there. + file called ``~/.mopidy/settings.py`` and redefine settings there. """ import sys From bbc44350fc7596857e9d6fcb068617f599113c4e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 00:53:09 +0100 Subject: [PATCH 287/341] Convert mopidy.settings from package to file --- mopidy/{settings/default.py => settings.py} | 17 +++++++++++++++-- mopidy/settings/__init__.py | 12 ------------ 2 files changed, 15 insertions(+), 14 deletions(-) rename mopidy/{settings/default.py => settings.py} (82%) delete mode 100644 mopidy/settings/__init__.py diff --git a/mopidy/settings/default.py b/mopidy/settings.py similarity index 82% rename from mopidy/settings/default.py rename to mopidy/settings.py index 03b831b8..90163c45 100644 --- a/mopidy/settings/default.py +++ b/mopidy/settings.py @@ -3,12 +3,16 @@ Available settings and their default values. .. warning:: - Do *not* change settings in ``mopidy/settings/default.py``. Instead, add a - file called ``~/.mopidy/settings.py`` and redefine settings there. + Do *not* change settings in ``mopidy/settings.py``. Instead, add a file + called ``~/.mopidy/settings.py`` and redefine settings there. """ +from __future__ import absolute_import +import os import sys +from mopidy.utils import get_or_create_dotdir + #: List of playback backends to use. Default:: #: #: BACKENDS = (u'mopidy.backends.despotify.DespotifyBackend',) @@ -91,3 +95,12 @@ SPOTIFY_USERNAME = u'' #: Your Spotify Premium password. Used by all Spotify backends. SPOTIFY_PASSWORD = u'' + +# Import user specific settings +dotdir = get_or_create_dotdir() +settings_file = os.path.join(dotdir, u'settings.py') +if not os.path.isfile(settings_file): + logger.warning(u'Settings not found: %s', settings_file) +else: + sys.path.insert(0, dotdir) + from settings import * diff --git a/mopidy/settings/__init__.py b/mopidy/settings/__init__.py deleted file mode 100644 index caee252e..00000000 --- a/mopidy/settings/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -import os - -from mopidy.settings.default import * -from mopidy.utils import get_or_create_dotdir - -dotdir = get_or_create_dotdir() -settings_file = os.path.join(dotdir, u'settings.py') -if not os.path.isfile(settings_file): - logger.warning(u'Settings not found: %s', settings_file) -else: - sys.path.insert(0, dotdir) - from settings import * From 861f4957aa4dacc066357a416daae4dbfc2eefa7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 00:53:27 +0100 Subject: [PATCH 288/341] docs: Update all references to mopidy.settings.default --- docs/api/mixers.rst | 4 ++-- docs/api/settings.rst | 2 +- docs/installation/index.rst | 2 +- mopidy/mixers/denon.py | 3 +-- mopidy/mixers/nad.py | 9 ++++----- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst index 639a017f..aa5998d0 100644 --- a/docs/api/mixers.rst +++ b/docs/api/mixers.rst @@ -32,7 +32,7 @@ Internal mixers Most users will use one of these internal mixers which controls the volume on the computer running Mopidy. If you do not specify which mixer you want to use in the settings, Mopidy will choose one for you based upon what OS you run. See -:attr:`mopidy.settings.default.MIXER` for the defaults. +:attr:`mopidy.settings.MIXER` for the defaults. :mod:`mopidy.mixers.alsa` -- ALSA mixer @@ -71,7 +71,7 @@ External device mixers Mopidy supports controlling volume on external devices instead of on the computer running Mopidy through the use of custom mixer implementations. To enable one of the following mixers, you must the set -:attr:`mopidy.settings.default.MIXER` setting to point to one of the classes +:attr:`mopidy.settings.MIXER` setting to point to one of the classes found below, and possibly add some extra settings required by the mixer you choose. diff --git a/docs/api/settings.rst b/docs/api/settings.rst index 7d85a68a..c2d21657 100644 --- a/docs/api/settings.rst +++ b/docs/api/settings.rst @@ -21,7 +21,7 @@ A complete ``~/.mopidy/settings.py`` may look like this:: Available settings ================== -.. automodule:: mopidy.settings.default +.. automodule:: mopidy.settings :synopsis: Available settings and their default values. :members: :undoc-members: diff --git a/docs/installation/index.rst b/docs/installation/index.rst index e9121ed5..a19e2705 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -72,7 +72,7 @@ libspotify backend instead, copy the Spotify application key to BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',) -For a full list of available settings, see :mod:`mopidy.settings.default`. +For a full list of available settings, see :mod:`mopidy.settings`. Running Mopidy diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 8f7cd2fc..ae5871d9 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -23,8 +23,7 @@ class DenonMixer(BaseMixer): **Settings** - - :attr:`mopidy.settings.default.MIXER_EXT_PORT` -- Example: - ``/dev/ttyUSB0`` + - :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0`` """ def __init__(self): diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 7dd0b387..d82a19a3 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -33,11 +33,10 @@ class NadMixer(BaseMixer): **Settings** - - :attr:`mopidy.settings.default.MIXER_EXT_PORT` -- - Example: ``/dev/ttyUSB0`` - - :attr:`mopidy.settings.default.MIXER_EXT_SOURCE` -- Example: ``Aux`` - - :attr:`mopidy.settings.default.MIXER_EXT_SPEAKERS_A` -- Example: ``On`` - - :attr:`mopidy.settings.default.MIXER_EXT_SPEAKERS_B` -- Example: ``Off`` + - :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0`` + - :attr:`mopidy.settings.MIXER_EXT_SOURCE` -- Example: ``Aux`` + - :attr:`mopidy.settings.MIXER_EXT_SPEAKERS_A` -- Example: ``On`` + - :attr:`mopidy.settings.MIXER_EXT_SPEAKERS_B` -- Example: ``Off`` """ From f16a70d2e4b94b56fdb3dd941739ca8255b38e23 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 01:01:48 +0100 Subject: [PATCH 289/341] Create ~/.mopidy in main and not when importing settings --- mopidy/__main__.py | 3 ++- mopidy/settings.py | 4 +--- mopidy/utils.py | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index efe74da7..aad44375 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -10,13 +10,14 @@ sys.path.insert(0, from mopidy import get_version, settings, SettingsError from mopidy.process import CoreProcess -from mopidy.utils import get_class +from mopidy.utils import get_class, get_or_create_dotdir logger = logging.getLogger('mopidy.main') def main(): options, args = _parse_options() _setup_logging(options.verbosity_level) + get_or_create_dotdir('~/.mopidy/') core_queue = multiprocessing.Queue() get_class(settings.SERVER)(core_queue) core = CoreProcess(core_queue) diff --git a/mopidy/settings.py b/mopidy/settings.py index 90163c45..8e9fb291 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -11,8 +11,6 @@ from __future__ import absolute_import import os import sys -from mopidy.utils import get_or_create_dotdir - #: List of playback backends to use. Default:: #: #: BACKENDS = (u'mopidy.backends.despotify.DespotifyBackend',) @@ -97,7 +95,7 @@ SPOTIFY_USERNAME = u'' SPOTIFY_PASSWORD = u'' # Import user specific settings -dotdir = get_or_create_dotdir() +dotdir = os.path.expanduser(u'~/.mopidy/') settings_file = os.path.join(dotdir, u'settings.py') if not os.path.isfile(settings_file): logger.warning(u'Settings not found: %s', settings_file) diff --git a/mopidy/utils.py b/mopidy/utils.py index 697c48f4..0142e15c 100644 --- a/mopidy/utils.py +++ b/mopidy/utils.py @@ -22,8 +22,8 @@ def get_class(name): class_object = getattr(module, class_name) return class_object -def get_or_create_dotdir(): - dotdir = os.path.expanduser(u'~/.mopidy/') +def get_or_create_dotdir(dotdir): + dotdir = os.path.expanduser(dotdir) if not os.path.isdir(dotdir): logger.info(u'Creating %s', dotdir) os.mkdir(dotdir, 0755) From 9e90654aa2d17fe2aa4a91aa2e706184e93ea552 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 01:04:44 +0100 Subject: [PATCH 290/341] Do not use logger we do not have --- mopidy/settings.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 8e9fb291..5c4cdb12 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -97,8 +97,6 @@ SPOTIFY_PASSWORD = u'' # Import user specific settings dotdir = os.path.expanduser(u'~/.mopidy/') settings_file = os.path.join(dotdir, u'settings.py') -if not os.path.isfile(settings_file): - logger.warning(u'Settings not found: %s', settings_file) -else: +if os.path.isfile(settings_file): sys.path.insert(0, dotdir) from settings import * From 46e405479c7dfb639db1586bddb552ba2fd11a48 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 13:09:45 +0100 Subject: [PATCH 291/341] Remove mopidy/settings/local.py from .gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 7f47f084..540acdb8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,7 @@ MANIFEST build/ dist/ -docs/_build -mopidy/settings/local.py +docs/_build/ pip-log.txt spotify_appkey.key src/ From 9daabfdc2ea910de00eac9aecf71f8f4f15946f3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 13:11:32 +0100 Subject: [PATCH 292/341] docs: Update 'MPD server running' log example --- docs/installation/index.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index a19e2705..a93f8203 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -82,10 +82,10 @@ To start Mopidy, go to the root of the Mopidy project, then simply run:: python mopidy -When Mopidy says ``Please connect to localhost port 6600 using an MPD client.`` -it's ready to accept connections by any MPD client. You can find a list of tons -of MPD clients at http://mpd.wikia.com/wiki/Clients. We use Sonata, GMPC, -ncmpc, and ncmpcpp during development. The first two are GUI clients, while the -last two are terminal clients. +When Mopidy says ``MPD server running at [localhost]:6600`` it's ready to +accept connections by any MPD client. You can find a list of tons of MPD +clients at http://mpd.wikia.com/wiki/Clients. We use Sonata, GMPC, ncmpc, and +ncmpcpp during development. The first two are GUI clients, while the last two +are terminal clients. To stop Mopidy, press ``CTRL+C``. From 65dd1c7d0d02c94aeaa0649a87ac8e5dae777eab Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 21:50:33 +0100 Subject: [PATCH 293/341] docs: Add link from mopidy.settings.BACKEND to backend docs --- mopidy/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 5c4cdb12..42454a79 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -11,7 +11,8 @@ from __future__ import absolute_import import os import sys -#: List of playback backends to use. Default:: +#: List of playback backends to use. See :mod:`mopidy.backends` for all +#: available backends. Default:: #: #: BACKENDS = (u'mopidy.backends.despotify.DespotifyBackend',) #: From b5ec357f9eda9d48e6c3f5a43563fe9f5fdf4198 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 22:30:28 +0100 Subject: [PATCH 294/341] docs: Add install instructions for released versions --- docs/installation/index.rst | 49 ++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index a19e2705..3361982d 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -3,11 +3,11 @@ Installation ************ Mopidy itself is a breeze to install, as it just requires a standard Python -installation. The libraries we depend on to connect to the Spotify service is -far more tricky to get working for the time being. Until installation of these -libraries are either well documented by their developers, or the libraries are -packaged for various Linux distributions, we will supply our own installation -guides here. +2.6 or newer installation. The libraries we depend on to connect to the Spotify +service is far more tricky to get working for the time being. Until +installation of these libraries are either well documented by their developers, +or the libraries are packaged for various Linux distributions, we will supply +our own installation guides here. .. toctree:: :maxdepth: 1 @@ -40,16 +40,43 @@ Dependencies - see :doc:`libspotify` -To install Mopidy itself (i.e. no backend dependencies), on Debian/Ubuntu:: - sudo aptitude install python-alsaaudio git-core +Install latest release +====================== + +To install the currently latest release of Mopidy using ``pip``:: + + sudo aptitude install python-pip # On Ubuntu/Debian + sudo brew install pip # On OS X + sudo pip install Mopidy + +To later upgrade to the latest release:: + + sudo pip install -U Mopidy + +If you for some reason can't use ``pip``, try ``easy_install``. + + +Install development version +=========================== + +If you want to follow Mopidy development closer, you may install the +development version of Mopidy:: + + sudo aptitude install git-core # On Ubuntu/Debian + sudo brew install git # On OS X git clone git://github.com/jodal/mopidy.git cd mopidy/ + sudo python setup.py install -And on OS X, assuming you allready got git installed, e.g. from Homebrew:: +To later update to the very latest version:: - git clone git://github.com/jodal/mopidy.git cd mopidy/ + git pull + sudo python setup.py install + +For an introduction to ``git``, please visit `git-scm.com +`_. Spotify settings @@ -78,9 +105,9 @@ For a full list of available settings, see :mod:`mopidy.settings`. Running Mopidy ============== -To start Mopidy, go to the root of the Mopidy project, then simply run:: +To start Mopidy, simply open a terminal and run:: - python mopidy + mopidy When Mopidy says ``Please connect to localhost port 6600 using an MPD client.`` it's ready to accept connections by any MPD client. You can find a list of tons From 88bbcc371678ed0e20b5cb70fb15b30660a44797 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 22:34:01 +0100 Subject: [PATCH 295/341] libspotify: Move appkey and cache into ~/.mopidy --- mopidy/backends/libspotify.py | 8 +++++++- mopidy/settings.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index edd4567e..67b65318 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -1,5 +1,6 @@ import datetime as dt import logging +import os import multiprocessing import threading @@ -7,7 +8,7 @@ from spotify import Link from spotify.manager import SpotifySessionManager from spotify.alsahelper import AlsaController -from mopidy import settings +from mopidy import get_version, settings from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, BaseLibraryController, BasePlaybackController, BaseStoredPlaylistsController) @@ -157,6 +158,11 @@ class LibspotifyTranslator(object): class LibspotifySessionManager(SpotifySessionManager, threading.Thread): + cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) + settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) + appkey_file = os.path.expanduser(settings.SPOTIFY_LIB_APPKEY) + user_agent = 'Mopidy %s' % get_version() + def __init__(self, username, password, core_queue): SpotifySessionManager.__init__(self, username, password) threading.Thread.__init__(self) diff --git a/mopidy/settings.py b/mopidy/settings.py index 42454a79..6cb2928e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -95,6 +95,12 @@ SPOTIFY_USERNAME = u'' #: Your Spotify Premium password. Used by all Spotify backends. SPOTIFY_PASSWORD = u'' +#: Path to your libspotify application key. Used by LibspotifyBackend. +SPOTIFY_LIB_APPKEY = u'~/.mopidy/spotify_appkey.key' + +#: Path to the libspotify cache. Used by LibspotifyBackend. +SPOTIFY_LIB_CACHE = u'~/.mopidy/libspotify_cache' + # Import user specific settings dotdir = os.path.expanduser(u'~/.mopidy/') settings_file = os.path.join(dotdir, u'settings.py') From cb8037038f1720bb4999a8d61b736ee7da9fd1f1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 22:34:18 +0100 Subject: [PATCH 296/341] Remove spotify_appkey.key from .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7f47f084..c5630d13 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,5 @@ dist/ docs/_build mopidy/settings/local.py pip-log.txt -spotify_appkey.key src/ tmp/ From c920804573877486832c59e932b62e088b396452 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 22:35:19 +0100 Subject: [PATCH 297/341] docs: Move docs on switching backends to the libspotify installation docs --- docs/installation/index.rst | 11 ----------- docs/installation/libspotify.rst | 10 ++++++++++ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 3361982d..97a930f7 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -88,17 +88,6 @@ your Spotify Premium account's username and password into the file, like this:: SPOTIFY_USERNAME = u'myusername' SPOTIFY_PASSWORD = u'mysecret' - -Switching backend -================= - -Currently the despotify backend is the default. If you want to use the -libspotify backend instead, copy the Spotify application key to -``mopidy/spotify_appkey.key``, and add the following to -``~/.mopidy/settings.py``:: - - BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',) - For a full list of available settings, see :mod:`mopidy.settings`. diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst index c5a0a5d8..6fbe57f5 100644 --- a/docs/installation/libspotify.rst +++ b/docs/installation/libspotify.rst @@ -55,3 +55,13 @@ Test your libspotify setup:: ``LD_LIBRARY_PATH`` every time you are going to use libspotify (in other words before starting Mopidy). + +Setting up Mopidy to use libspotify +=================================== + +Currently :mod:`mopidy.backends.despotify` is the default +backend. If you want to use :mod:`mopidy.backends.libspotify` +instead, copy the Spotify application key to ``~/.mopidy/spotify_appkey.key``, +and add the following to ``~/.mopidy/settings.py``:: + + BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',) From b62e240e64786ea23c6ef30ec9003fd43e8f09ae Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 23:09:14 +0100 Subject: [PATCH 298/341] docs: Move {despotify,libspotify} installation to the dependencies section --- docs/installation/index.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 16e9d6e9..bacefb77 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -7,18 +7,18 @@ Mopidy itself is a breeze to install, as it just requires a standard Python service is far more tricky to get working for the time being. Until installation of these libraries are either well documented by their developers, or the libraries are packaged for various Linux distributions, we will supply -our own installation guides here. - -.. toctree:: - :maxdepth: 1 - - despotify - libspotify +our own installation guides. Dependencies ============ +.. toctree:: + :hidden: + + despotify + libspotify + - Python >= 2.6 - Dependencies for at least one Mopidy mixer: From d48d8d4d86882611b0add5845d690d314ae7c5a8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 23:18:20 +0100 Subject: [PATCH 299/341] docs: Update settings.py example --- docs/api/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/settings.rst b/docs/api/settings.rst index c2d21657..4679a535 100644 --- a/docs/api/settings.rst +++ b/docs/api/settings.rst @@ -13,7 +13,7 @@ there. A complete ``~/.mopidy/settings.py`` may look like this:: - MPD_SERVER_HOSTNAME = u'0.0.0.0' + SERVER_HOSTNAME = u'0.0.0.0' SPOTIFY_USERNAME = u'alice' SPOTIFY_USERNAME = u'mysecret' From fdf3d4f11b438969c42d1e2c400f5434d2e7b09f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 23 Mar 2010 23:54:49 +0100 Subject: [PATCH 300/341] Replace CoverageTestRunner with nosetests --- .gitignore | 2 + docs/development/contributing.rst | 19 ++++---- mopidy/backends/despotify.py | 5 +- requirements-tests.txt | 2 +- setup.cfg | 8 ++++ tests/__main__.py | 19 +------- tests/backends/__init__.py | 46 ------------------- tests/backends/base_test.py | 46 +++++++++++++++++++ tests/mixers/__init__.py | 0 tests/mixers/{denontest.py => denon_test.py} | 0 tests/mixers/{dummytest.py => dummy_test.py} | 0 tests/{modelstest.py => models_test.py} | 0 tests/mpd/__init__.py | 0 .../mpd/{frontendtest.py => frontend_test.py} | 0 14 files changed, 70 insertions(+), 77 deletions(-) create mode 100644 setup.cfg create mode 100644 tests/backends/base_test.py create mode 100644 tests/mixers/__init__.py rename tests/mixers/{denontest.py => denon_test.py} (100%) rename tests/mixers/{dummytest.py => dummy_test.py} (100%) rename tests/{modelstest.py => models_test.py} (100%) create mode 100644 tests/mpd/__init__.py rename tests/mpd/{frontendtest.py => frontend_test.py} (100%) diff --git a/.gitignore b/.gitignore index c4b78634..cc090815 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,10 @@ .idea MANIFEST build/ +cover/ dist/ docs/_build/ +nosetests.xml pip-log.txt src/ tmp/ diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index 2889c210..f3d68e6d 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -44,20 +44,22 @@ style guidelines, with a couple of notable exceptions: Running tests ============= -To run tests, you need a couple of dependencies. Some can be installed through +To run tests, you need a couple of dependencies. They can be installed through Debian/Ubuntu package management:: - sudo aptitude install python-coverage + sudo aptitude install python-coverage python-nose -The rest (or all dependencies if you want to) can be installed using pip:: +Or, they can be installed using ``pip``:: - sudo aptitude install python-pip python-setuptools bzr sudo pip install -r requirements-tests.txt -Then, to run all tests:: +Then, to run all tests, go to the project directory and run:: python tests +For more documentation on testing Mopidy, check out the `nose docs +`_. + Writing documentation ===================== @@ -66,15 +68,10 @@ To write documentation, we use `Sphinx `_. See their site for lots of documentation on how to use Sphinx. To generate HTML or LaTeX from the documentation files, you need some additional dependencies. -You can either install them through Debian/Ubuntu package management:: +You can install them through Debian/Ubuntu package management:: sudo aptitude install python-sphinx python-pygraphviz graphviz -Or, install them using pip:: - - sudo aptitude install python-pip python-setuptools graphviz - sudo pip install -r requirements-docs.txt - Then, to generate docs:: cd docs/ diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index 6757a285..ea0404dc 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -32,9 +32,10 @@ class DespotifyBackend(BaseBackend): - r503: Segfaults when looking up playlists, both your own lists and other peoples shared lists. To reproduce:: - >>> import spytify - >>> s = spytify.Spytify('alice', 'secret') + >>> import spytify # doctest: +SKIP + >>> s = spytify.Spytify('alice', 'secret') # doctest: +SKIP >>> s.lookup('spotify:user:klette:playlist:5rOGYPwwKqbAcVX8bW4k5V') + ... # doctest: +SKIP Segmentation fault """ diff --git a/requirements-tests.txt b/requirements-tests.txt index 0342fb1c..33f49451 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -1,2 +1,2 @@ coverage --e bzr+http://liw.iki.fi/bzr/coverage-test-runner/trunk/#egg=CoverageTestRunner +nose diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..c0e68566 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,8 @@ +[nosetests] +verbosity = 1 +with-doctest = 1 +with-coverage = 1 +cover-package = mopidy +cover-inclusive = 1 +cover-html = 1 +with-xunit = 1 diff --git a/tests/__main__.py b/tests/__main__.py index 54ad93a9..e2bb3e72 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -1,19 +1,4 @@ -import logging -import os -import sys - -from CoverageTestRunner import CoverageTestRunner - -def main(): - logging.basicConfig(level=logging.CRITICAL) - sys.path.insert(0, - os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) - r = CoverageTestRunner() - r.add_pair('mopidy/mixers/dummy.py', 'tests/mixers/dummytest.py') - r.add_pair('mopidy/mixers/denon.py', 'tests/mixers/denontest.py') - r.add_pair('mopidy/models.py', 'tests/modelstest.py') - r.add_pair('mopidy/mpd/frontend.py', 'tests/mpd/frontendtest.py') - r.run() +import nose if __name__ == '__main__': - main() + nose.main() diff --git a/tests/backends/__init__.py b/tests/backends/__init__.py index d91467dd..e69de29b 100644 --- a/tests/backends/__init__.py +++ b/tests/backends/__init__.py @@ -1,46 +0,0 @@ -class BaseCurrentPlaylistControllerTest(object): - uris = [] - backend_class = None - - def setUp(self): - self.backend = self.backend_class() - - def test_add(self): - playlist = self.backend.current_playlist - - for uri in self.uris: - playlist.add(uri) - self.assertEqual(uri, playlist.tracks[-1].uri) - - def test_add_at_position(self): - playlist = self.backend.current_playlist - - for uri in self.uris: - playlist.add(uri, 0) - self.assertEqual(uri, playlist.tracks[0].uri) - - # FIXME test other placements - -class BasePlaybackControllerTest(object): - backend_class = None - - def setUp(self): - self.backend = self.backend_class() - - def test_play(self): - playback = self.backend.playback - - self.assertEqual(playback.state, playback.STOPPED) - - playback.play() - - self.assertEqual(playback.state, playback.PLAYING) - - def test_next(self): - playback = self.backend.playback - - current_song = playback.playlist_position - - playback.next() - - self.assertEqual(playback.playlist_position, current_song+1) diff --git a/tests/backends/base_test.py b/tests/backends/base_test.py new file mode 100644 index 00000000..d91467dd --- /dev/null +++ b/tests/backends/base_test.py @@ -0,0 +1,46 @@ +class BaseCurrentPlaylistControllerTest(object): + uris = [] + backend_class = None + + def setUp(self): + self.backend = self.backend_class() + + def test_add(self): + playlist = self.backend.current_playlist + + for uri in self.uris: + playlist.add(uri) + self.assertEqual(uri, playlist.tracks[-1].uri) + + def test_add_at_position(self): + playlist = self.backend.current_playlist + + for uri in self.uris: + playlist.add(uri, 0) + self.assertEqual(uri, playlist.tracks[0].uri) + + # FIXME test other placements + +class BasePlaybackControllerTest(object): + backend_class = None + + def setUp(self): + self.backend = self.backend_class() + + def test_play(self): + playback = self.backend.playback + + self.assertEqual(playback.state, playback.STOPPED) + + playback.play() + + self.assertEqual(playback.state, playback.PLAYING) + + def test_next(self): + playback = self.backend.playback + + current_song = playback.playlist_position + + playback.next() + + self.assertEqual(playback.playlist_position, current_song+1) diff --git a/tests/mixers/__init__.py b/tests/mixers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mixers/denontest.py b/tests/mixers/denon_test.py similarity index 100% rename from tests/mixers/denontest.py rename to tests/mixers/denon_test.py diff --git a/tests/mixers/dummytest.py b/tests/mixers/dummy_test.py similarity index 100% rename from tests/mixers/dummytest.py rename to tests/mixers/dummy_test.py diff --git a/tests/modelstest.py b/tests/models_test.py similarity index 100% rename from tests/modelstest.py rename to tests/models_test.py diff --git a/tests/mpd/__init__.py b/tests/mpd/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mpd/frontendtest.py b/tests/mpd/frontend_test.py similarity index 100% rename from tests/mpd/frontendtest.py rename to tests/mpd/frontend_test.py From f7a4c379dee598fb4e5a361ac3475569d6013b90 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 24 Mar 2010 00:11:53 +0100 Subject: [PATCH 301/341] docs: Add link to online test coverage reports --- docs/development/contributing.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index f3d68e6d..ae42d611 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -56,10 +56,16 @@ Or, they can be installed using ``pip``:: Then, to run all tests, go to the project directory and run:: python tests + # or + nosetests -For more documentation on testing Mopidy, check out the `nose docs +For more documentation on testing, check out the `nose docs `_. +The coverage report at http://www.mopidy.com/coverage/ is automatically updated +within 10 minutes after an update is pushed to ``jodal/mopidy/master`` at +GitHub. + Writing documentation ===================== From c6f134a35a0c913addb518cd6db9b1c89104b0d1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 24 Mar 2010 20:01:29 +0100 Subject: [PATCH 302/341] Turn off test coverage to make testing instant --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index c0e68566..7e52ba92 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [nosetests] verbosity = 1 with-doctest = 1 -with-coverage = 1 +#with-coverage = 1 cover-package = mopidy cover-inclusive = 1 cover-html = 1 From b3994f75451dd6b8ee03bc32284851c3010f15f5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 24 Mar 2010 22:51:34 +0100 Subject: [PATCH 303/341] Ignore .noseids --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index cc090815..4d1182cb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.swp .coverage .idea +.noseids MANIFEST build/ cover/ From f2e5708ada28639dcdc96dff6501f6c5e6314416 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 00:09:26 +0100 Subject: [PATCH 304/341] Make version number adhere to distutils.version.StrictVersion rules --- mopidy/__init__.py | 2 +- tests/version_test.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 tests/version_test.py diff --git a/mopidy/__init__.py b/mopidy/__init__.py index c1f8fed8..a7ccade1 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,7 +1,7 @@ from mopidy import settings as raw_settings def get_version(): - return u'0.1.0a0.dev0' + return u'0.1.0a0' def get_mpd_protocol_version(): return u'0.16.0' diff --git a/tests/version_test.py b/tests/version_test.py new file mode 100644 index 00000000..4a9b948d --- /dev/null +++ b/tests/version_test.py @@ -0,0 +1,15 @@ +from distutils.version import StrictVersion as SV +import unittest + +from mopidy import get_version + +class VersionTest(unittest.TestCase): + def test_current_version_is_parsable_as_a_strict_version_number(self): + SV(get_version()) + + def test_versions_can_be_strictly_ordered(self): + self.assert_(SV(get_version()) < SV('0.1.0a1')) + 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')) + self.assert_(SV('0.2.0') < SV('1.0.0')) From 58498ae7d2d5239438b0c2713e1868ec44577d83 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 00:42:49 +0100 Subject: [PATCH 305/341] Move changelist higher in the ToC --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 644b8d57..2489dee2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,9 +7,9 @@ Contents :maxdepth: 3 installation/index + changes development/index api/index - changes Indices and tables ================== From de4ae7f7ecb7f9d4e64a28b8283cc8faee0acba6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 13:17:05 +0100 Subject: [PATCH 306/341] Update README.rst to refer to installation docs instead of asking users to wait for a release --- README.rst | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index feacb595..51a48248 100644 --- a/README.rst +++ b/README.rst @@ -7,11 +7,10 @@ Mopidy is an `MPD `_ server with a can search for music in Spotify's vast archive, manage Spotify play lists and play music from Spotify. -Mopidy is currently under development. Unless you want to contribute to the -development, you should probably wait for our first release before trying out -Mopidy. +To install Mopidy, check out +`the installation docs `_. -* `Source code `_ * `Documentation `_ +* `Source code `_ * `Presentation of Mopidy `_ * IRC: ``#mopidy`` at `irc.freenode.net `_ From 0853e3ea33cd94f81268e69c8d61c254632f5249 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 13:22:03 +0100 Subject: [PATCH 307/341] Add thanks to jorgenpt and winjer --- AUTHORS.rst | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 357cb311..e8c7f224 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -3,7 +3,12 @@ Authors Contributors to Mopidy in the order of appearance: -* Stein Magnus Jodal -* Johannes Knutsen -* Thomas Adamcik -* Kristian Klette +- Stein Magnus Jodal +- Johannes Knutsen +- Thomas Adamcik +- Kristian Klette + +Also, we would like to thank: + +- Jørgen P. Tjernø for his work on the Python wrapper for Despotify. +- Doug Winter for his work on the Python wrapper for libspotify. From d8c0c3e6ea998da0fe954a9dd2cc06bf5ce6dfe0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 13:26:32 +0100 Subject: [PATCH 308/341] Add authors page to doc site --- docs/authors.rst | 1 + docs/index.rst | 1 + 2 files changed, 2 insertions(+) create mode 100644 docs/authors.rst diff --git a/docs/authors.rst b/docs/authors.rst new file mode 100644 index 00000000..e122f914 --- /dev/null +++ b/docs/authors.rst @@ -0,0 +1 @@ +.. include:: ../AUTHORS.rst diff --git a/docs/index.rst b/docs/index.rst index 2489dee2..2a6dbfc5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,6 +10,7 @@ Contents changes development/index api/index + authors Indices and tables ================== From 467b21aa6a6199ec6ebe2938ede7498e650c0acf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 13:31:06 +0100 Subject: [PATCH 309/341] Add issue tracker link to README --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 51a48248..0d9db0b8 100644 --- a/README.rst +++ b/README.rst @@ -12,5 +12,6 @@ To install Mopidy, check out * `Documentation `_ * `Source code `_ -* `Presentation of Mopidy `_ +* `Issue tracker `_ * IRC: ``#mopidy`` at `irc.freenode.net `_ +* `Presentation of Mopidy `_ From f9a5a5fb984811bba43621cfb263736dfbe640fe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 13:33:53 +0100 Subject: [PATCH 310/341] Update roadmap --- docs/development/roadmap.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index 2add39aa..8ea9948a 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -29,7 +29,8 @@ Stuff we really want to do, but just not right now Adamcik. - Support multiple backends at the same time. It would be really nice to have tracks from local disk and Spotify tracks in the same playlist. -- Package Mopidy as a `Python package `_. +- **[Done]** Package Mopidy as a `Python package + `_. - Get a build server, i.e. `Hudson `_, up and running which runs our test suite on all relevant platforms (Ubuntu, OS X, etc.) and creates nightly packages (see next items). @@ -53,5 +54,5 @@ Crazy stuff we had to write down somewhere way. - AirPort Express support, like in `PulseAudio `_. -- **Done.** NAD/Denon amplifier mixer through their RS-232 connection. +- **[Done]** NAD/Denon amplifier mixer through their RS-232 connection. - DNLA and/or UPnP support. From c1189186d58f078d8f8dbdf30ad6c01a2fc5e515 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 13:37:13 +0100 Subject: [PATCH 311/341] Use :pep: instead of links to refer to PEPs --- docs/development/contributing.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index ae42d611..70c3e35a 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -9,8 +9,8 @@ Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at Code style ========== -We generally follow the `PEP-8 `_ -style guidelines, with a couple of notable exceptions: +We generally follow the :pep:`8` style guidelines, with a couple of notable +exceptions: - We indent continuation lines with four spaces more than the previous line. For example:: From aea416924f3823027ef747a6e0f179872f199c0d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 13:43:12 +0100 Subject: [PATCH 312/341] Extend test running docs --- docs/development/contributing.rst | 36 +++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index 70c3e35a..ec3e5fbb 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -55,16 +55,32 @@ Or, they can be installed using ``pip``:: Then, to run all tests, go to the project directory and run:: - python tests - # or nosetests -For more documentation on testing, check out the `nose docs +For example:: + + $ nosetests + ...................................................................... + ...................................................................... + ...................................................................... + ....... + ---------------------------------------------------------------------- + Ran 217 tests in 0.267s + + OK + +To run tests with test coverage statistics:: + + nosetests --with-coverage + +For more documentation on testing, check out the `nose documentation `_. -The coverage report at http://www.mopidy.com/coverage/ is automatically updated -within 10 minutes after an update is pushed to ``jodal/mopidy/master`` at -GitHub. +.. note:: + + The test coverage report at http://www.mopidy.com/coverage/ is + automatically updated within 10 minutes after an update is pushed to + ``jodal/mopidy/master`` at GitHub. Writing documentation @@ -84,7 +100,9 @@ Then, to generate docs:: make # For help on available targets make html # To generate HTML docs -The documentation at http://www.mopidy.com/docs/ is automatically updated -within 10 minutes after a documentation update is pushed to -``jodal/mopidy/master`` at GitHub. +.. note:: + + The documentation at http://www.mopidy.com/docs/ is automatically updated + within 10 minutes after a documentation update is pushed to + ``jodal/mopidy/master`` at GitHub. From ba6bbc7aad8bd7d08ae4c7cbc367970a7d377f0a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 13:49:40 +0100 Subject: [PATCH 313/341] Steal code style and commit guidelines from 'comics' --- docs/development/contributing.rst | 69 +++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index ec3e5fbb..8a096cd0 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -9,36 +9,61 @@ Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at Code style ========== -We generally follow the :pep:`8` style guidelines, with a couple of notable -exceptions: +- Follow :pep:`8` unless otherwise noted. `pep8.py + `_ can be used to check your code against + the guidelines, however remember that matching the style of the surrounding + code is also important. -- We indent continuation lines with four spaces more than the previous line. - For example:: +- Use four spaces for indentation, *never* tabs. - from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, - BasePlaybackController, BaseLibraryController, - BaseStoredPlaylistsController) +- Use CamelCase with initial caps for class names:: - And *not*:: + ClassNameWithCamelCase - from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, - BasePlaybackController, BaseLibraryController, - BaseStoredPlaylistsController) +- Use underscore to split variable, function and method names for + readability. Don't use CamelCase. -- An exception to the previous exception: When continuing control flow - statements like ``if``, ``for`` and ``while``, we indent with eight spaces - more than the previous line. In other words, the line is indented one level - further to the right than the following block of code. For example:: + :: - if (old_state in (self.PLAYING, self.STOPPED) - and new_state == self.PLAYING): - self._play_time_start() + lower_case_with_underscores - And *not*:: +- Use the fact that empty strings, lists and tuples are :class:`False` and + don't compare boolean values using ``==`` and ``!=``. - if (old_state in (self.PLAYING, self.STOPPED) - and new_state == self.PLAYING): - self._play_time_start() +- Follow whitespace rules as described in :pep:`8`. Good examples:: + + spam(ham[1], {eggs: 2}) + spam(1) + dict['key'] = list[index] + +- Limit lines to 80 characters and avoid trailing whitespace. However note that + wrapped lines should be *one* indentation level in from level above, except + for ``if``, ``for``, ``with``, and ``while`` lines which should have two + levels of indentation:: + + if (foo and bar ... + baz and foobar): + a = 1 + + from foobar import (foo, bar, ... + baz) + +- For consistency, prefer ``'`` over ``"`` for strings, unless the string + contains ``'``. + +- Take a look at :pep:`20` for a nice peek into a general mindset useful for + Python coding. + + +Commit guidelines +================= + +- Keep commits small and on topic. + +- If a commit looks too big you should be working in a feature branch not a + single commit. + +- Merge feature branches with ``--no-ff`` to keep track of the merge. Running tests From 48027da3c5f0ff1034772d33906e37d149215fd1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 13:51:53 +0100 Subject: [PATCH 314/341] Release v0.1.0a0 --- docs/changes.rst | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 3e4edadd..cd54336c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,11 +5,19 @@ Changes This change log is used to track all major changes to Mopidy. -0.1 (unreleased) -================ +0.1.0a0 (2010-03-27) +==================== -Initial version. +"*Release early. Release often. Listen to your customers.*" wrote Eric S. +Raymond in *The Cathedral and the Bazaar*. -Features: +Three months of development should be more than enough. We have more to do, but +Mopidy is working and usable. 0.1.0a0 is an alpha release, which basicly means +we will still change APIs, add features, etc. before the final 0.1.0 release. +But the software is usable as is, so we release it. Please give it a try and +give us feedback, either at our IRC channel or through the `issue tracker +`_. Thanks! -* *TODO:* Fill out +**Changes** + +- Initial version. No changelog available. From 27266d8d3efdebb6f3e496e3b6cfa9e82cef3f08 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 13:59:17 +0100 Subject: [PATCH 315/341] Add receipe on creating new releases --- docs/development/contributing.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index 8a096cd0..f53a971b 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -131,3 +131,23 @@ Then, to generate docs:: within 10 minutes after a documentation update is pushed to ``jodal/mopidy/master`` at GitHub. + +Creating releases +================= + +1. Update changelog and commit it. + +2. Tag release:: + + git tag -a -m "Release v0.1.0a0" v0.1.0a0 + +3. Push to GitHub:: + + git push + git push --tags + +4. Build package and upload to PyPI:: + + python setup.py sdist upload + +5. Spread the word. From b34270174e81723c6e9fdb0888ab9928b485e359 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 15:38:52 +0100 Subject: [PATCH 316/341] Add Google Analytics tracking to docs --- docs/_templates/layout.html | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/_templates/layout.html diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html new file mode 100644 index 00000000..d6cb00e9 --- /dev/null +++ b/docs/_templates/layout.html @@ -0,0 +1,15 @@ +{% extends "!layout.html" %} + +{% block footer %} +{{ super() }} + + +{% endblock %} From 9a80dd5fb56b0aa0322f9644e964e01367302623 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 16:43:44 +0100 Subject: [PATCH 317/341] Add removal of MANIFEST from release howto --- docs/development/contributing.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index f53a971b..b06b7311 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -148,6 +148,7 @@ Creating releases 4. Build package and upload to PyPI:: + rm MANIFEST # Will be regenerated by setup.py python setup.py sdist upload 5. Spread the word. From 06a6beb94a6420e74cf89081c5ff0243eb799b96 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 17:28:46 +0100 Subject: [PATCH 318/341] Add PyPI trove categories --- setup.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/setup.py b/setup.py index 8d3389a5..bbf300f7 100644 --- a/setup.py +++ b/setup.py @@ -55,4 +55,15 @@ setup( license='GPLv2', description='MPD server with Spotify support', long_description=open('README.rst').read(), + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Environment :: No Input/Output (Daemon)', + 'Intended Audience :: End Users/Desktop', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Topic :: Multimedia :: Sound/Audio :: Players', + ], ) From f3754139303509a6aa354cecf5c692b06ed61825 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Mar 2010 23:23:57 +0100 Subject: [PATCH 319/341] Ignore coverage.xml --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4d1182cb..6f127051 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ MANIFEST build/ cover/ +coverage.xml dist/ docs/_build/ nosetests.xml From ca4e9ae1998b78f91fa8d1ae777e1b9f2137b67c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Mar 2010 00:53:38 +0100 Subject: [PATCH 320/341] Do not run doctests as default, as they require all imported modules to be present on the system --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 7e52ba92..e09a7b15 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [nosetests] verbosity = 1 -with-doctest = 1 +#with-doctest = 1 #with-coverage = 1 cover-package = mopidy cover-inclusive = 1 From 57e23fd49670c253211db69097ef6527598fea34 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Mar 2010 22:10:49 +0200 Subject: [PATCH 321/341] docs: A section on Hudson CI --- docs/development/contributing.rst | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst index b06b7311..a2ef4a15 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -101,11 +101,19 @@ To run tests with test coverage statistics:: For more documentation on testing, check out the `nose documentation `_. -.. note:: - The test coverage report at http://www.mopidy.com/coverage/ is - automatically updated within 10 minutes after an update is pushed to - ``jodal/mopidy/master`` at GitHub. +Continuous integration server +============================= + +We run a continuous integration server called Hudson at +http://hudson.mopidy.com/ that runs all test on multiple platforms (Ubuntu, OS +X, etc.) for every commit we push to GitHub. If the build is broken or fixed, +Hudson will issue notifications to our IRC channel. + +In addition to running tests, Hudson also does coverage statistics and uses +pylint to check for errors and possible improvements in our code. So, if you're +out of work, the code coverage and pylint data in Hudson should give you a +place to start. Writing documentation From b4f3605915efdc9637eb8d01a10ab2702969929f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Mar 2010 22:23:39 +0200 Subject: [PATCH 322/341] despotify: Add missing import --- mopidy/backends/despotify.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index ea0404dc..ea74de10 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -1,5 +1,6 @@ import datetime as dt import logging +import sys import spytify From 3e4514cf64a82041da15cf3af784475ab6d0d7dd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Mar 2010 11:59:01 +0200 Subject: [PATCH 323/341] Add requirements for external mixers --- requirements-external-mixers.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 requirements-external-mixers.txt diff --git a/requirements-external-mixers.txt b/requirements-external-mixers.txt new file mode 100644 index 00000000..f6c1a1f5 --- /dev/null +++ b/requirements-external-mixers.txt @@ -0,0 +1 @@ +pyserial From 690116197390617eeb88c42994aedaed59a7612d Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Mon, 29 Mar 2010 19:19:02 +0200 Subject: [PATCH 324/341] Support album track listing in gmpc by implementing support for command: list album "[ALBUM]" artist "[ARTIST]" --- mopidy/mpd/frontend.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index 7a402ebe..55cbfeeb 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -577,6 +577,8 @@ class MpdFrontend(object): r'"(?P[^"]+)"$') @handle_pattern(r'^find "(?P(album|artist|title))" ' r'"(?P[^"]+)"$') + @handle_pattern(r'^find (?P(album)) ' + r'"(?P[^"]+)" artist "([^"]+)"$') def _music_db_find(self, type, what): """ *musicpd.org, music database section:* @@ -589,6 +591,8 @@ class MpdFrontend(object): *GMPC:* - does not add quotes around the type argument. + - also uses ``find album "[ALBUM]" artist "[ARTIST]"`` to list album + tracks. *ncmpc:* From 1498c2cbba60a440d46c73fa0d0b5ac851245782 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Mar 2010 20:29:07 +0200 Subject: [PATCH 325/341] Try to fix exception message handling on Solaris and/or Python 2.6.5 --- mopidy/mpd/__init__.py | 7 ++++--- mopidy/mpd/frontend.py | 22 +++++++++++----------- tests/mpd/exception_test.py | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 tests/mpd/exception_test.py diff --git a/mopidy/mpd/__init__.py b/mopidy/mpd/__init__.py index 8f352807..afb125ee 100644 --- a/mopidy/mpd/__init__.py +++ b/mopidy/mpd/__init__.py @@ -1,6 +1,7 @@ class MpdAckError(Exception): - pass + def __init__(self, msg): + self.msg = msg class MpdNotImplemented(MpdAckError): - def __init__(self, *args): - super(MpdNotImplemented, self).__init__(u'Not implemented', *args) + def __init__(self): + super(MpdNotImplemented, self).__init__(u'Not implemented') diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index 7a402ebe..5a1fa3d9 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -64,8 +64,8 @@ class MpdFrontend(object): groups = matches.groupdict() try: result = _request_handlers[pattern](self, **groups) - except MpdAckError, e: - return self.handle_response(u'ACK %s' % e, add_ok=False) + except MpdAckError as e: + return self.handle_response(u'ACK %s' % e.msg, add_ok=False) if self.command_list is not False: return None else: @@ -285,7 +285,7 @@ class MpdFrontend(object): songpos = int(songpos) track = self.backend.current_playlist.playlist.tracks[songpos] self.backend.current_playlist.remove(track) - except IndexError, e: + except IndexError as e: raise MpdAckError(u'Position out of bounds') @handle_pattern(r'^deleteid "(?P\d+)"$') @@ -301,7 +301,7 @@ class MpdFrontend(object): try: track = self.backend.current_playlist.get_by_id(songid) return self.backend.current_playlist.remove(track) - except KeyError, e: + except KeyError as e: raise MpdAckError(unicode(e)) @handle_pattern(r'^clear$') @@ -408,8 +408,8 @@ class MpdFrontend(object): songid = int(songid) track = self.backend.current_playlist.get_by_id(songid) return track.mpd_format() - except KeyError, e: - raise MpdAckError(e) + except KeyError as e: + raise MpdAckError(unicode(e)) else: return self.backend.current_playlist.playlist.mpd_format() @@ -821,7 +821,7 @@ class MpdFrontend(object): try: track = self.backend.current_playlist.get_by_id(songid) return self.backend.playback.play(track) - except KeyError, e: + except KeyError as e: raise MpdAckError(unicode(e)) @handle_pattern(r'^play "(?P\d+)"$') @@ -1331,8 +1331,8 @@ class MpdFrontend(object): try: return ['file: %s' % t.uri for t in self.backend.stored_playlists.get_by_name(name).tracks] - except KeyError, e: - raise MpdAckError(e) + except KeyError as e: + raise MpdAckError(unicode(e)) @handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') def _stored_playlists_listplaylistinfo(self, name): @@ -1351,8 +1351,8 @@ class MpdFrontend(object): try: return self.backend.stored_playlists.get_by_name(name).mpd_format( search_result=True) - except KeyError, e: - raise MpdAckError(e) + except KeyError as e: + raise MpdAckError(unicode(e)) @handle_pattern(r'^listplaylists$') def _stored_playlists_listplaylists(self): diff --git a/tests/mpd/exception_test.py b/tests/mpd/exception_test.py new file mode 100644 index 00000000..61dd262b --- /dev/null +++ b/tests/mpd/exception_test.py @@ -0,0 +1,19 @@ +import unittest + +from mopidy.mpd import MpdAckError, MpdNotImplemented + +class MpdExceptionsTest(unittest.TestCase): + def test_key_error_wrapped_in_mpd_ack_error(self): + try: + try: + raise KeyError('Track X not found') + except KeyError as e: + raise MpdAckError(unicode(e)) + except MpdAckError as e: + self.assertEqual(e.msg, u'Track X not found') + + def test_mpd_not_implemented_is_a_mpd_ack_error(self): + try: + raise MpdNotImplemented + except MpdAckError as e: + self.assertEqual(e.msg, u'Not implemented') From 61dadc02b6b33e525a5da94df60af340e868b6bf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Mar 2010 20:55:33 +0200 Subject: [PATCH 326/341] Add MopidyException base exception --- mopidy/__init__.py | 15 ++++++++++++++- mopidy/mpd/__init__.py | 7 ++++--- mopidy/mpd/frontend.py | 3 ++- tests/mpd/exception_test.py | 4 ++-- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index a7ccade1..d94913b0 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -6,7 +6,20 @@ def get_version(): def get_mpd_protocol_version(): return u'0.16.0' -class SettingsError(Exception): +class MopidyException(Exception): + def __init__(self, message): + self.message = message + + @property + def message(self): + """Reimplement message field that was deprecated in Python 2.6""" + return self._message + + @message.setter + def message(self, message): + self._message = message + +class SettingsError(MopidyException): pass class Settings(object): diff --git a/mopidy/mpd/__init__.py b/mopidy/mpd/__init__.py index afb125ee..c0685891 100644 --- a/mopidy/mpd/__init__.py +++ b/mopidy/mpd/__init__.py @@ -1,6 +1,7 @@ -class MpdAckError(Exception): - def __init__(self, msg): - self.msg = msg +from mopidy import MopidyException + +class MpdAckError(MopidyException): + pass class MpdNotImplemented(MpdAckError): def __init__(self): diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index 5a1fa3d9..6a4cf098 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -65,7 +65,8 @@ class MpdFrontend(object): try: result = _request_handlers[pattern](self, **groups) except MpdAckError as e: - return self.handle_response(u'ACK %s' % e.msg, add_ok=False) + return self.handle_response(u'ACK %s' % e.message, + add_ok=False) if self.command_list is not False: return None else: diff --git a/tests/mpd/exception_test.py b/tests/mpd/exception_test.py index 61dd262b..07701d0d 100644 --- a/tests/mpd/exception_test.py +++ b/tests/mpd/exception_test.py @@ -10,10 +10,10 @@ class MpdExceptionsTest(unittest.TestCase): except KeyError as e: raise MpdAckError(unicode(e)) except MpdAckError as e: - self.assertEqual(e.msg, u'Track X not found') + self.assertEqual(e.message, u'Track X not found') def test_mpd_not_implemented_is_a_mpd_ack_error(self): try: raise MpdNotImplemented except MpdAckError as e: - self.assertEqual(e.msg, u'Not implemented') + self.assertEqual(e.message, u'Not implemented') From bcd9fd66e84865058fd626bccfcaad2e6ddfc84a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Mar 2010 20:56:29 +0200 Subject: [PATCH 327/341] Use index to get KeyError message instead of unicode() as its behaviour varies between Solaris/Py2.6.5 and Ubuntu/Py2.6.4 --- mopidy/mpd/frontend.py | 10 +++++----- tests/mpd/exception_test.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index 6a4cf098..7949f3a2 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -303,7 +303,7 @@ class MpdFrontend(object): track = self.backend.current_playlist.get_by_id(songid) return self.backend.current_playlist.remove(track) except KeyError as e: - raise MpdAckError(unicode(e)) + raise MpdAckError(e[0]) @handle_pattern(r'^clear$') def _current_playlist_clear(self): @@ -410,7 +410,7 @@ class MpdFrontend(object): track = self.backend.current_playlist.get_by_id(songid) return track.mpd_format() except KeyError as e: - raise MpdAckError(unicode(e)) + raise MpdAckError(e[0]) else: return self.backend.current_playlist.playlist.mpd_format() @@ -823,7 +823,7 @@ class MpdFrontend(object): track = self.backend.current_playlist.get_by_id(songid) return self.backend.playback.play(track) except KeyError as e: - raise MpdAckError(unicode(e)) + raise MpdAckError(e[0]) @handle_pattern(r'^play "(?P\d+)"$') def _playback_playpos(self, songpos): @@ -1333,7 +1333,7 @@ class MpdFrontend(object): return ['file: %s' % t.uri for t in self.backend.stored_playlists.get_by_name(name).tracks] except KeyError as e: - raise MpdAckError(unicode(e)) + raise MpdAckError(e[0]) @handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') def _stored_playlists_listplaylistinfo(self, name): @@ -1353,7 +1353,7 @@ class MpdFrontend(object): return self.backend.stored_playlists.get_by_name(name).mpd_format( search_result=True) except KeyError as e: - raise MpdAckError(unicode(e)) + raise MpdAckError(e[0]) @handle_pattern(r'^listplaylists$') def _stored_playlists_listplaylists(self): diff --git a/tests/mpd/exception_test.py b/tests/mpd/exception_test.py index 07701d0d..029063e8 100644 --- a/tests/mpd/exception_test.py +++ b/tests/mpd/exception_test.py @@ -6,9 +6,9 @@ class MpdExceptionsTest(unittest.TestCase): def test_key_error_wrapped_in_mpd_ack_error(self): try: try: - raise KeyError('Track X not found') + raise KeyError(u'Track X not found') except KeyError as e: - raise MpdAckError(unicode(e)) + raise MpdAckError(e[0]) except MpdAckError as e: self.assertEqual(e.message, u'Track X not found') From ecfd774e64e8c137c02f4f462eca6259970e0a94 Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Tue, 30 Mar 2010 01:29:07 +0200 Subject: [PATCH 328/341] test find album "what" artist "what" command --- tests/mpd/frontend_test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/mpd/frontend_test.py b/tests/mpd/frontend_test.py index 881ee181..6705ed6f 100644 --- a/tests/mpd/frontend_test.py +++ b/tests/mpd/frontend_test.py @@ -913,6 +913,10 @@ class MusicDatabaseHandlerTest(unittest.TestCase): result = self.h.handle_request(u'find "somethingelse" "what"') self.assert_(u'ACK Unknown command' in result[0]) + def test_find_album_and_artist(self): + result = self.h.handle_request(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) From 818e602221d024da641998029116d3be409d6f0f Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Tue, 30 Mar 2010 01:59:34 +0200 Subject: [PATCH 329/341] handle gmpc issuing playid "-1" after playlst replacement --- mopidy/mpd/frontend.py | 10 +++++++++- tests/mpd/frontend_test.py | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index 7a92a6c8..c3ce90a1 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -814,6 +814,7 @@ class MpdFrontend(object): return self.backend.playback.play() @handle_pattern(r'^playid "(?P\d+)"$') + @handle_pattern(r'^playid "(?P-1)"$') def _playback_playid(self, songid): """ *musicpd.org, playback section:* @@ -821,10 +822,17 @@ class MpdFrontend(object): ``playid [SONGID]`` Begins playing the playlist at song ``SONGID``. + + *GMPC:* + + - issues ``playid "-1"`` after playlist replacement. """ songid = int(songid) try: - track = self.backend.current_playlist.get_by_id(songid) + if songid == -1: + track = self.backend.current_playlist.playlist.tracks[0] + else: + track = self.backend.current_playlist.get_by_id(songid) return self.backend.playback.play(track) except KeyError as e: raise MpdAckError(e[0]) diff --git a/tests/mpd/frontend_test.py b/tests/mpd/frontend_test.py index 6705ed6f..58294935 100644 --- a/tests/mpd/frontend_test.py +++ b/tests/mpd/frontend_test.py @@ -465,6 +465,14 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) + def test_playid_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'playid "-1"') + self.assert_(u'OK' in result) + self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) + self.assertEquals(self.b.playback.current_track, track) + def test_playid_which_does_not_exist(self): self.b.current_playlist.load(Playlist(tracks=[Track(id=0)])) result = self.h.handle_request(u'playid "1"') From 5a9b141657d97e7885830d85d73681562cddd823 Mon Sep 17 00:00:00 2001 From: Johannes Knutsen Date: Tue, 30 Mar 2010 02:34:58 +0200 Subject: [PATCH 330/341] set current_track to None on clear commands - required by Sonata --- mopidy/backends/__init__.py | 1 + tests/mpd/frontend_test.py | 1 + 2 files changed, 2 insertions(+) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index b9ec17d6..4e6ff96a 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -87,6 +87,7 @@ class BaseCurrentPlaylistController(object): def clear(self): """Clear the current playlist.""" self.backend.playback.stop() + self.backend.playback.current_track = None self.playlist = Playlist() def get_by_id(self, id): diff --git a/tests/mpd/frontend_test.py b/tests/mpd/frontend_test.py index 58294935..ba3d0fec 100644 --- a/tests/mpd/frontend_test.py +++ b/tests/mpd/frontend_test.py @@ -543,6 +543,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEquals(self.b.current_playlist.playlist.length, 5) result = self.h.handle_request(u'clear') self.assertEquals(self.b.current_playlist.playlist.length, 0) + self.assertEquals(self.b.playback.current_track, None) self.assert_(u'OK' in result) def test_delete_songpos(self): From aedbf82d5b5b9f3b9ef7f9c42c2a58bee77e500e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Mar 2010 20:09:42 +0200 Subject: [PATCH 331/341] Mark 'get build server' as done --- docs/development/roadmap.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index 8ea9948a..7e9cfc1a 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -31,9 +31,9 @@ Stuff we really want to do, but just not right now tracks from local disk and Spotify tracks in the same playlist. - **[Done]** Package Mopidy as a `Python package `_. -- Get a build server, i.e. `Hudson `_, up and running - which runs our test suite on all relevant platforms (Ubuntu, OS X, etc.) and - creates nightly packages (see next items). +- **[Done]** Get a build server, i.e. `Hudson `_, up and + running which runs our test suite on all relevant platforms (Ubuntu, OS X, + etc.) and creates nightly packages (see next items). - Create `Debian packages `_ of all our dependencies and Mopidy itself (hosted in our own Debian repo until we get stuff into the various distros) to make Debian/Ubuntu installation a breeze. From db4f449be99d7b66bd7c46a1a3af8b46424421c6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Mar 2010 18:26:55 +0200 Subject: [PATCH 332/341] Add tests for DummyCurrentPlaylistController.get_by_{id,uri} --- tests/backends/get_test.py | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/backends/get_test.py diff --git a/tests/backends/get_test.py b/tests/backends/get_test.py new file mode 100644 index 00000000..9d962fe3 --- /dev/null +++ b/tests/backends/get_test.py @@ -0,0 +1,46 @@ +import unittest + +from mopidy.backends.dummy import DummyBackend, DummyCurrentPlaylistController +from mopidy.models import Playlist, Track + +class CurrentPlaylistGetTest(unittest.TestCase): + def setUp(self): + self.b = DummyBackend() + self.c = self.b.current_playlist + + def test_get_by_id_returns_unique_match(self): + track = Track(id=1) + self.c.playlist = Playlist(tracks=[Track(id=13), track, Track(id=17)]) + self.assertEqual(track, self.c.get_by_id(1)) + + def test_get_by_id_returns_first_of_multiple_matches(self): + track = Track(id=1) + self.c.playlist = Playlist(tracks=[Track(id=13), track, track]) + self.assertEqual(track, self.c.get_by_id(1)) + + def test_get_by_id_raises_keyerror_if_no_match(self): + self.c.playlist = Playlist(tracks=[Track(id=13), Track(id=17)]) + try: + self.c.get_by_id(1) + self.fail(u'Should raise KeyError if no match') + except KeyError: + pass + + def test_get_by_uri_returns_unique_match(self): + track = Track(uri='a') + self.c.playlist = Playlist( + tracks=[Track(uri='z'), track, Track(uri='y')]) + self.assertEqual(track, self.c.get_by_uri('a')) + + def test_get_by_uri_returns_first_of_multiple_matches(self): + track = Track(uri='a') + self.c.playlist = Playlist(tracks=[Track(uri='z'), track, track]) + self.assertEqual(track, self.c.get_by_uri('a')) + + def test_get_by_uri_raises_keyerror_if_no_match(self): + self.c.playlist = Playlist(tracks=[Track(uri='z'), Track(uri='y')]) + try: + self.c.get_by_uri('a') + self.fail(u'Should raise KeyError if no match') + except KeyError: + pass From c24c7818e5cd809e1f000df5ee5b1f4ca2385c0f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Mar 2010 18:41:30 +0200 Subject: [PATCH 333/341] Refactor BaseCurrentPlaylistController.get_by_{id,uri} into a generic get(**criteria) --- mopidy/backends/__init__.py | 35 +++++++++++++++-------------------- tests/backends/get_test.py | 32 ++++++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 4e6ff96a..6af2fe6d 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -90,33 +90,28 @@ class BaseCurrentPlaylistController(object): self.backend.playback.current_track = None self.playlist = Playlist() - def get_by_id(self, id): + def get(self, **criteria): """ - Get track by ID. Raises :class:`KeyError` if not found. + Get track by given criterias. Raises :class:`KeyError` if not found. - :param id: track ID - :type id: int + Examples:: + + get(id=1) # Returns track with ID 1 + get(uri='xyz') # Returns track with URI 'xyz' + + :param **criteria: on or more criteria to match by + :type **criteria: dict :rtype: :class:`mopidy.models.Track` """ - matches = filter(lambda t: t.id == id, self._playlist.tracks) + matches = self._playlist.tracks + for (key, value) in criteria.iteritems(): + matches = filter(lambda t: getattr(t, key) == value, matches) if matches: return matches[0] else: - raise KeyError('Track with ID "%s" not found' % id) - - def get_by_uri(self, uri): - """ - Get track by URI. Raises :class:`KeyError` if not found. - - :param uri: track URI - :type uri: string - :rtype: :class:`mopidy.models.Track` - """ - matches = filter(lambda t: t.uri == uri, self._playlist.tracks) - if matches: - return matches[0] - else: - raise KeyError('Track with URI "%s" not found' % uri) + criteria_string = ', '.join( + ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) + raise KeyError(u'Track matching "%s" not found' % criteria_string) def load(self, playlist): """ diff --git a/tests/backends/get_test.py b/tests/backends/get_test.py index 9d962fe3..1bb60553 100644 --- a/tests/backends/get_test.py +++ b/tests/backends/get_test.py @@ -11,17 +11,17 @@ class CurrentPlaylistGetTest(unittest.TestCase): def test_get_by_id_returns_unique_match(self): track = Track(id=1) self.c.playlist = Playlist(tracks=[Track(id=13), track, Track(id=17)]) - self.assertEqual(track, self.c.get_by_id(1)) + self.assertEqual(track, self.c.get(id=1)) def test_get_by_id_returns_first_of_multiple_matches(self): track = Track(id=1) self.c.playlist = Playlist(tracks=[Track(id=13), track, track]) - self.assertEqual(track, self.c.get_by_id(1)) + self.assertEqual(track, self.c.get(id=1)) def test_get_by_id_raises_keyerror_if_no_match(self): self.c.playlist = Playlist(tracks=[Track(id=13), Track(id=17)]) try: - self.c.get_by_id(1) + self.c.get(id=1) self.fail(u'Should raise KeyError if no match') except KeyError: pass @@ -30,17 +30,33 @@ class CurrentPlaylistGetTest(unittest.TestCase): track = Track(uri='a') self.c.playlist = Playlist( tracks=[Track(uri='z'), track, Track(uri='y')]) - self.assertEqual(track, self.c.get_by_uri('a')) + self.assertEqual(track, self.c.get(uri='a')) def test_get_by_uri_returns_first_of_multiple_matches(self): track = Track(uri='a') self.c.playlist = Playlist(tracks=[Track(uri='z'), track, track]) - self.assertEqual(track, self.c.get_by_uri('a')) + self.assertEqual(track, self.c.get(uri='a')) def test_get_by_uri_raises_keyerror_if_no_match(self): self.c.playlist = Playlist(tracks=[Track(uri='z'), Track(uri='y')]) try: - self.c.get_by_uri('a') + self.c.get(uri='a') self.fail(u'Should raise KeyError if no match') - except KeyError: - pass + except KeyError as e: + self.assertEqual(u'Track matching "uri=a" not found', e[0]) + + def test_get_by_multiple_criteria_returns_elements_matching_all(self): + track1 = Track(id=1, uri='a') + track2 = Track(id=1, uri='b') + track3 = Track(id=2, uri='b') + self.c.playlist = Playlist(tracks=[track1, track2, track3]) + self.assertEqual(track1, self.c.get(id=1, uri='a')) + self.assertEqual(track2, self.c.get(id=1, uri='b')) + self.assertEqual(track3, self.c.get(id=2, uri='b')) + + def test_get_by_criteria_that_is_not_present_in_all_elements(self): + track1 = Track(id=1) + track2 = Track(uri='b') + track3 = Track(id=2) + self.c.playlist = Playlist(tracks=[track1, track2, track3]) + self.assertEqual(track1, self.c.get(id=1)) From 7039031161dc3e6e5677164edb60566337810348 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Mar 2010 20:49:58 +0200 Subject: [PATCH 334/341] Update frontend to use new BaseCurrentPlaylistController.get() --- mopidy/mpd/frontend.py | 14 +++++++------- tests/mpd/frontend_test.py | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index c3ce90a1..5c18f802 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -300,7 +300,7 @@ class MpdFrontend(object): """ songid = int(songid) try: - track = self.backend.current_playlist.get_by_id(songid) + track = self.backend.current_playlist.get(id=songid) return self.backend.current_playlist.remove(track) except KeyError as e: raise MpdAckError(e[0]) @@ -353,7 +353,7 @@ class MpdFrontend(object): """ songid = int(songid) to = int(to) - track = self.backend.current_playlist.get_by_id(songid) + track = self.backend.current_playlist.get(id=songid) position = self.backend.current_playlist.playlist.tracks.index(track) self.backend.current_playlist.move(position, position + 1, to) @@ -388,7 +388,7 @@ class MpdFrontend(object): """ if tag == 'filename': try: - track = self.backend.current_playlist.get_by_uri(needle) + track = self.backend.current_playlist.get(uri=needle) return track.mpd_format() except KeyError: return None @@ -407,7 +407,7 @@ class MpdFrontend(object): if songid is not None: try: songid = int(songid) - track = self.backend.current_playlist.get_by_id(songid) + track = self.backend.current_playlist.get(id=songid) return track.mpd_format() except KeyError as e: raise MpdAckError(e[0]) @@ -549,8 +549,8 @@ class MpdFrontend(object): """ songid1 = int(songid1) songid2 = int(songid2) - song1 = self.backend.current_playlist.get_by_id(songid1) - song2 = self.backend.current_playlist.get_by_id(songid2) + song1 = self.backend.current_playlist.get(id=songid1) + song2 = self.backend.current_playlist.get(id=songid2) songpos1 = self.backend.current_playlist.playlist.tracks.index(song1) songpos2 = self.backend.current_playlist.playlist.tracks.index(song2) self._current_playlist_swap(songpos1, songpos2) @@ -832,7 +832,7 @@ class MpdFrontend(object): if songid == -1: track = self.backend.current_playlist.playlist.tracks[0] else: - track = self.backend.current_playlist.get_by_id(songid) + track = self.backend.current_playlist.get(id=songid) return self.backend.playback.play(track) except KeyError as e: raise MpdAckError(e[0]) diff --git a/tests/mpd/frontend_test.py b/tests/mpd/frontend_test.py index ba3d0fec..ffb6da67 100644 --- a/tests/mpd/frontend_test.py +++ b/tests/mpd/frontend_test.py @@ -476,7 +476,7 @@ class PlaybackControlHandlerTest(unittest.TestCase): def test_playid_which_does_not_exist(self): self.b.current_playlist.load(Playlist(tracks=[Track(id=0)])) result = self.h.handle_request(u'playid "1"') - self.assert_(u'ACK Track with ID "1" not found' in result) + self.assert_(u'ACK Track matching "id=1" not found' in result) def test_previous(self): result = self.h.handle_request(u'previous') @@ -598,7 +598,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEquals(self.b.current_playlist.playlist.length, 2) result = self.h.handle_request(u'deleteid "0"') self.assertEquals(self.b.current_playlist.playlist.length, 2) - self.assert_(u'ACK Track with ID "0" not found' in result) + self.assert_(u'ACK Track matching "id=0" not found' in result) def test_move_songpos(self): self.b.current_playlist.load(Playlist(tracks=[ @@ -700,7 +700,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.b.current_playlist.load(Playlist( tracks=[Track(name='a', id=33), Track(name='b', id=38)])) result = self.h.handle_request(u'playlistid "25"') - self.assert_(u'ACK Track with ID "25" not found' in result) + self.assert_(u'ACK Track matching "id=25" not found' in result) def test_playlistinfo_without_songpos_or_range(self): result = self.h.handle_request(u'playlistinfo') From cb3740138ac829a56b9ca3417f1e4540d2242b09 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Mar 2010 20:58:26 +0200 Subject: [PATCH 335/341] Explicitly use DummyMixer in tests --- tests/backends/get_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/backends/get_test.py b/tests/backends/get_test.py index 1bb60553..3fc35255 100644 --- a/tests/backends/get_test.py +++ b/tests/backends/get_test.py @@ -1,11 +1,13 @@ import unittest from mopidy.backends.dummy import DummyBackend, DummyCurrentPlaylistController +from mopidy.mixers.dummy import DummyMixer from mopidy.models import Playlist, Track class CurrentPlaylistGetTest(unittest.TestCase): def setUp(self): - self.b = DummyBackend() + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) self.c = self.b.current_playlist def test_get_by_id_returns_unique_match(self): From 96ddf9a881eac0a098ad64f479d31a9e5dfdb954 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Mar 2010 21:11:58 +0200 Subject: [PATCH 336/341] Improve BCPC.get() docstring --- mopidy/backends/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 6af2fe6d..1b7749a0 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -96,11 +96,12 @@ class BaseCurrentPlaylistController(object): Examples:: - get(id=1) # Returns track with ID 1 - get(uri='xyz') # Returns track with URI 'xyz' + get(id=1) # Returns track with ID 1 + get(uri='xyz') # Returns track with URI 'xyz' + get(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz' - :param **criteria: on or more criteria to match by - :type **criteria: dict + :param criteria: on or more criteria to match by + :type criteria: dict :rtype: :class:`mopidy.models.Track` """ matches = self._playlist.tracks From 682b3cc3542951d2bb23f619b3c9f3a65ec4f2f1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Mar 2010 23:18:16 +0200 Subject: [PATCH 337/341] Test current BaseStoredPlaylistController.get_by_name() behaviour --- tests/backends/get_test.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/backends/get_test.py b/tests/backends/get_test.py index 3fc35255..8377cb82 100644 --- a/tests/backends/get_test.py +++ b/tests/backends/get_test.py @@ -6,8 +6,7 @@ from mopidy.models import Playlist, Track class CurrentPlaylistGetTest(unittest.TestCase): def setUp(self): - self.m = DummyMixer() - self.b = DummyBackend(mixer=self.m) + self.b = DummyBackend(mixer=DummyMixer()) self.c = self.b.current_playlist def test_get_by_id_returns_unique_match(self): @@ -62,3 +61,31 @@ class CurrentPlaylistGetTest(unittest.TestCase): track3 = Track(id=2) self.c.playlist = Playlist(tracks=[track1, track2, track3]) self.assertEqual(track1, self.c.get(id=1)) + + +class StoredPlaylistsGetTest(unittest.TestCase): + def setUp(self): + self.b = DummyBackend(mixer=DummyMixer()) + self.s = self.b.stored_playlists + + def test_get_by_name_returns_unique_match(self): + playlist = Playlist(name='b') + self.s.playlists = [Playlist(name='a'), playlist] + self.assertEqual(playlist, self.s.get_by_name('b')) + + def test_get_by_name_returns_first_of_multiple_matches(self): + playlist = Playlist(name='b') + self.s.playlists = [playlist, Playlist(name='a'), Playlist(name='b')] + try: + self.s.get_by_name('b') + self.fail(u'Should raise KeyError if multiple matches') + except KeyError as e: + self.assertEqual(u'Name "b" matched multiple elements', e[0]) + + def test_get_by_id_raises_keyerror_if_no_match(self): + self.s.playlists = [Playlist(name='a'), Playlist(name='b')] + try: + self.s.get_by_name('c') + self.fail(u'Should raise KeyError if no match') + except KeyError as e: + self.assertEqual(u'Name "c" not found', e[0]) From 89346aa76b3c02cc559c9cd6ef9f7e6143fcd6df Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Mar 2010 23:28:10 +0200 Subject: [PATCH 338/341] Refactor BaseStoredPlaylistsController.get_by_name to take multiple criteria --- mopidy/backends/__init__.py | 28 +++++++++++++++++++--------- tests/backends/get_test.py | 18 +++++++++--------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 1b7749a0..1a096c51 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -509,23 +509,33 @@ class BaseStoredPlaylistsController(object): """ raise NotImplementedError - def get_by_name(self, name): + def get(self, **criteria): """ - Get playlist with given name from the set of stored playlists. + Get playlist by given criterias from the set of stored playlists. - Raises :exc:`KeyError` if not a unique match is found. + Raises :exc:`LookupError` if a unique match is not found. - :param name: playlist name - :type name: string + Examples:: + + get(name='a') # Returns track with name 'a' + get(uri='xyz') # Returns track with URI 'xyz' + get(name='a', uri='xyz') # Returns track with name 'a' and URI 'xyz' + + :param criteria: on or more criteria to match by + :type criteria: dict :rtype: :class:`mopidy.models.Playlist` """ - matches = filter(lambda p: name == p.name, self._playlists) + matches = self._playlists + for (key, value) in criteria.iteritems(): + matches = filter(lambda p: getattr(p, key) == value, matches) if len(matches) == 1: return matches[0] - elif len(matches) == 0: - raise KeyError('Name "%s" not found' % name) + criteria_string = ', '.join( + ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) + if len(matches) == 0: + raise LookupError('"%s" match no playlists' % criteria_string) else: - raise KeyError('Name "%s" matched multiple elements' % name) + raise LookupError('"%s" match multiple playlists' % criteria_string) def lookup(self, uri): """ diff --git a/tests/backends/get_test.py b/tests/backends/get_test.py index 8377cb82..5cf5f014 100644 --- a/tests/backends/get_test.py +++ b/tests/backends/get_test.py @@ -71,21 +71,21 @@ class StoredPlaylistsGetTest(unittest.TestCase): def test_get_by_name_returns_unique_match(self): playlist = Playlist(name='b') self.s.playlists = [Playlist(name='a'), playlist] - self.assertEqual(playlist, self.s.get_by_name('b')) + self.assertEqual(playlist, self.s.get(name='b')) def test_get_by_name_returns_first_of_multiple_matches(self): playlist = Playlist(name='b') self.s.playlists = [playlist, Playlist(name='a'), Playlist(name='b')] try: - self.s.get_by_name('b') - self.fail(u'Should raise KeyError if multiple matches') - except KeyError as e: - self.assertEqual(u'Name "b" matched multiple elements', e[0]) + self.s.get(name='b') + self.fail(u'Should raise LookupError if multiple matches') + except LookupError as e: + self.assertEqual(u'"name=b" match multiple playlists', e[0]) def test_get_by_id_raises_keyerror_if_no_match(self): self.s.playlists = [Playlist(name='a'), Playlist(name='b')] try: - self.s.get_by_name('c') - self.fail(u'Should raise KeyError if no match') - except KeyError as e: - self.assertEqual(u'Name "c" not found', e[0]) + self.s.get(name='c') + self.fail(u'Should raise LookupError if no match') + except LookupError as e: + self.assertEqual(u'"name=c" match no playlists', e[0]) From 34ebdaf8de413ead9ea6d2d7ee0bbbddb5c0da64 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Mar 2010 23:30:27 +0200 Subject: [PATCH 339/341] Update frontend to use new SPLC.get() --- mopidy/mpd/frontend.py | 8 ++++---- tests/mpd/frontend_test.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index 5c18f802..97ceb097 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -1343,8 +1343,8 @@ class MpdFrontend(object): """ try: return ['file: %s' % t.uri - for t in self.backend.stored_playlists.get_by_name(name).tracks] - except KeyError as e: + for t in self.backend.stored_playlists.get(name=name).tracks] + except LookupError as e: raise MpdAckError(e[0]) @handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') @@ -1362,9 +1362,9 @@ class MpdFrontend(object): Album, Artist, Track """ try: - return self.backend.stored_playlists.get_by_name(name).mpd_format( + return self.backend.stored_playlists.get(name=name).mpd_format( search_result=True) - except KeyError as e: + except LookupError as e: raise MpdAckError(e[0]) @handle_pattern(r'^listplaylists$') diff --git a/tests/mpd/frontend_test.py b/tests/mpd/frontend_test.py index ffb6da67..dbc0e511 100644 --- a/tests/mpd/frontend_test.py +++ b/tests/mpd/frontend_test.py @@ -824,7 +824,7 @@ class StoredPlaylistsHandlerTest(unittest.TestCase): def test_listplaylist_fails_if_no_playlist_is_found(self): result = self.h.handle_request(u'listplaylist "name"') - self.assert_(u'ACK Name "name" not found' in result) + self.assert_(u'ACK "name=name" match no playlists' in result) def test_listplaylistinfo(self): self.b.stored_playlists.playlists = [ @@ -837,7 +837,7 @@ class StoredPlaylistsHandlerTest(unittest.TestCase): def test_listplaylistinfo_fails_if_no_playlist_is_found(self): result = self.h.handle_request(u'listplaylistinfo "name"') - self.assert_(u'ACK Name "name" not found' in result) + self.assert_(u'ACK "name=name" match no playlists' in result) def test_listplaylists(self): last_modified = dt.datetime(2001, 3, 17, 13, 41, 17) From 6d46d77b7a33f831a0bb3986494fe61895d995ee Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Mar 2010 23:42:03 +0200 Subject: [PATCH 340/341] Update CPC.get() to match behaviour of SPC.get() --- mopidy/backends/__init__.py | 14 +++++++++----- tests/backends/get_test.py | 32 ++++++++++++++++++++------------ 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index 1a096c51..ce022bd1 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -92,7 +92,9 @@ class BaseCurrentPlaylistController(object): def get(self, **criteria): """ - Get track by given criterias. Raises :class:`KeyError` if not found. + Get track by given criterias from current playlist. + + Raises :exc:`LookupError` if a unique match is not found. Examples:: @@ -107,12 +109,14 @@ class BaseCurrentPlaylistController(object): matches = self._playlist.tracks for (key, value) in criteria.iteritems(): matches = filter(lambda t: getattr(t, key) == value, matches) - if matches: + if len(matches) == 1: return matches[0] + criteria_string = ', '.join( + ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) + if len(matches) == 0: + raise LookupError(u'"%s" match no tracks' % criteria_string) else: - criteria_string = ', '.join( - ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) - raise KeyError(u'Track matching "%s" not found' % criteria_string) + raise LookupError(u'"%s" match multiple tracks' % criteria_string) def load(self, playlist): """ diff --git a/tests/backends/get_test.py b/tests/backends/get_test.py index 5cf5f014..c2ed5fe9 100644 --- a/tests/backends/get_test.py +++ b/tests/backends/get_test.py @@ -14,18 +14,22 @@ class CurrentPlaylistGetTest(unittest.TestCase): self.c.playlist = Playlist(tracks=[Track(id=13), track, Track(id=17)]) self.assertEqual(track, self.c.get(id=1)) - def test_get_by_id_returns_first_of_multiple_matches(self): + def test_get_by_id_raises_error_if_multiple_matches(self): track = Track(id=1) self.c.playlist = Playlist(tracks=[Track(id=13), track, track]) - self.assertEqual(track, self.c.get(id=1)) + try: + self.c.get(id=1) + self.fail(u'Should raise LookupError if multiple matches') + except LookupError as e: + self.assertEqual(u'"id=1" match multiple tracks', e[0]) - def test_get_by_id_raises_keyerror_if_no_match(self): + def test_get_by_id_raises_error_if_no_match(self): self.c.playlist = Playlist(tracks=[Track(id=13), Track(id=17)]) try: self.c.get(id=1) - self.fail(u'Should raise KeyError if no match') - except KeyError: - pass + self.fail(u'Should raise LookupError if no match') + except LookupError as e: + self.assertEqual(u'"id=1" match no tracks', e[0]) def test_get_by_uri_returns_unique_match(self): track = Track(uri='a') @@ -33,18 +37,22 @@ class CurrentPlaylistGetTest(unittest.TestCase): tracks=[Track(uri='z'), track, Track(uri='y')]) self.assertEqual(track, self.c.get(uri='a')) - def test_get_by_uri_returns_first_of_multiple_matches(self): + def test_get_by_uri_raises_error_if_multiple_matches(self): track = Track(uri='a') self.c.playlist = Playlist(tracks=[Track(uri='z'), track, track]) - self.assertEqual(track, self.c.get(uri='a')) + try: + self.c.get(uri='a') + self.fail(u'Should raise LookupError if multiple matches') + except LookupError as e: + self.assertEqual(u'"uri=a" match multiple tracks', e[0]) - def test_get_by_uri_raises_keyerror_if_no_match(self): + def test_get_by_uri_raises_error_if_no_match(self): self.c.playlist = Playlist(tracks=[Track(uri='z'), Track(uri='y')]) try: self.c.get(uri='a') - self.fail(u'Should raise KeyError if no match') - except KeyError as e: - self.assertEqual(u'Track matching "uri=a" not found', e[0]) + self.fail(u'Should raise LookupError if no match') + except LookupError as e: + self.assertEqual(u'"uri=a" match no tracks', e[0]) def test_get_by_multiple_criteria_returns_elements_matching_all(self): track1 = Track(id=1, uri='a') From 332d917b5334ed1adc7eb8b2d1a1038521fdf071 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Mar 2010 23:44:17 +0200 Subject: [PATCH 341/341] Update frontend to match CPC.get() changes --- mopidy/mpd/frontend.py | 8 ++++---- tests/mpd/frontend_test.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py index 97ceb097..c2ac84ea 100644 --- a/mopidy/mpd/frontend.py +++ b/mopidy/mpd/frontend.py @@ -302,7 +302,7 @@ class MpdFrontend(object): try: track = self.backend.current_playlist.get(id=songid) return self.backend.current_playlist.remove(track) - except KeyError as e: + except LookupError as e: raise MpdAckError(e[0]) @handle_pattern(r'^clear$') @@ -390,7 +390,7 @@ class MpdFrontend(object): try: track = self.backend.current_playlist.get(uri=needle) return track.mpd_format() - except KeyError: + except LookupError: return None raise MpdNotImplemented # TODO @@ -409,7 +409,7 @@ class MpdFrontend(object): songid = int(songid) track = self.backend.current_playlist.get(id=songid) return track.mpd_format() - except KeyError as e: + except LookupError as e: raise MpdAckError(e[0]) else: return self.backend.current_playlist.playlist.mpd_format() @@ -834,7 +834,7 @@ class MpdFrontend(object): else: track = self.backend.current_playlist.get(id=songid) return self.backend.playback.play(track) - except KeyError as e: + except LookupError as e: raise MpdAckError(e[0]) @handle_pattern(r'^play "(?P\d+)"$') diff --git a/tests/mpd/frontend_test.py b/tests/mpd/frontend_test.py index dbc0e511..4fb829ba 100644 --- a/tests/mpd/frontend_test.py +++ b/tests/mpd/frontend_test.py @@ -476,7 +476,7 @@ class PlaybackControlHandlerTest(unittest.TestCase): def test_playid_which_does_not_exist(self): self.b.current_playlist.load(Playlist(tracks=[Track(id=0)])) result = self.h.handle_request(u'playid "1"') - self.assert_(u'ACK Track matching "id=1" not found' in result) + self.assert_(u'ACK "id=1" match no tracks' in result) def test_previous(self): result = self.h.handle_request(u'previous') @@ -598,7 +598,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEquals(self.b.current_playlist.playlist.length, 2) result = self.h.handle_request(u'deleteid "0"') self.assertEquals(self.b.current_playlist.playlist.length, 2) - self.assert_(u'ACK Track matching "id=0" not found' in result) + self.assert_(u'ACK "id=0" match no tracks' in result) def test_move_songpos(self): self.b.current_playlist.load(Playlist(tracks=[ @@ -700,7 +700,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.b.current_playlist.load(Playlist( tracks=[Track(name='a', id=33), Track(name='b', id=38)])) result = self.h.handle_request(u'playlistid "25"') - self.assert_(u'ACK Track matching "id=25" not found' in result) + self.assert_(u'ACK "id=25" match no tracks' in result) def test_playlistinfo_without_songpos_or_range(self): result = self.h.handle_request(u'playlistinfo')