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 diff --git a/docs/api/backends.rst b/docs/api/backends.rst index e20578ea..56d6f059 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -2,293 +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:: 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:: 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:: 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/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:: diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index af3e1b23..8b6de67c 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -1,47 +1,315 @@ +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' + #: The current playlist controller. An instance of + #: :class:`BaseCurrentPlaylistController`. + current_playlist = None - def __init__(self, *args, **kwargs): - self._state = self.STOP - self._playlists = [] - self._x_current_playlist = Playlist() - self._current_playlist_version = 0 + #: The library controller. An instance of :class:`BaseLibraryController`. + library = None -# Backend state + #: 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. Is not reset before the MPD server is + #: restarted. + version = 0 + + def __init__(self, backend): + self.backend = backend + self.playlist = Playlist() + + @property + def playlist(self): + """The currently loaded :class:`mopidy.models.Playlist`.""" + return copy(self._playlist) + + @playlist.setter + def playlist(self, new_playlist): + self._playlist = new_playlist + self.version += 1 + + 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] + 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 + """ + 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): + """ + Replace the current playlist with the given playlist. + + :param playlist: playlist to load + :type playlist: :class:`mopidy.models.Playlist` + """ + self.playlist = playlist + + 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, 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): + """ + 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: :class:`mopidy.models.Playlist` + """ + 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 + + @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] + except IndexError: + return None + + @property + def playlist_position(self): + """The position in the current playlist.""" + if self.current_track is None: + return None + try: + return self.backend.current_playlist.playlist.tracks.index( + self.current_track) + except ValueError: + return None + + @property + def previous_track(self): + """The previous :class:`mopidy.models.Track` in the playlist.""" + if self.current_track is None: + return None + try: + return self.backend.current_playlist.playlist.tracks[ + self.playlist_position - 1] + except IndexError: + return None @property def state(self): + """ + The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or + :attr:`STOPPED`. + """ return self._state @state.setter 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 _play_time_elapsed(self): - if self.state == self.PLAY: + 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 - 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): @@ -55,184 +323,159 @@ 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): + """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: + self.play(self.backend.current_playlist.playlist.tracks[0]) + else: + self.stop() def next(self): - self.stop() - if self._next(): - self.state = self.PLAY + """Play the next track.""" + 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): - raise MpdNotImplemented + def _next(self, track): + return self._play(track) def pause(self): - if self.state == self.PLAY and self._pause(): - self.state = self.PAUSE + """Pause playback.""" + 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): + """ + 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() - 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 track is not None and self._play(track): + self.current_track = 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 + """Play the previous track.""" + 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): - raise MpdNotImplemented + def _previous(self, track): + return self._play(track) def resume(self): - if self.state == self.PAUSE and self._resume(): - self.state = self.PLAY + """If paused, resume playing the current track.""" + if self.state == self.PAUSED and self._resume(): + self.state = self.PLAYING def _resume(self): - raise MpdNotImplemented + 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): - if self.state != self.STOP and self._stop(): - self.state = self.STOP + """Stop playing.""" + 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 BaseStoredPlaylistsController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ - def playlist_changes_since(self, version='0'): - if int(version) < self._current_playlist_version: - return self._current_playlist.mpd_format() + def __init__(self, backend): + self.backend = backend + self._playlists = [] - 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) + @property + def playlists(self): + """List of :class:`mopidy.models.Playlist`.""" + return copy(self._playlists) -# Stored playlist methods + def create(self, name): + """ + Create a new playlist. - def playlists_list(self): - return [u'playlist: %s' % p.name for p in self._playlists] + :param name: name of the new playlist + :type name: string + :rtype: :class:`mopidy.models.Playlist` + """ + raise NotImplementedError -# Music database methods + def delete(self, playlist): + """ + Delete playlist. - def search(self, type, what): - return None + :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) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index a60e2aac..dc786d64 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -1,11 +1,12 @@ import datetime as dt import logging -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') @@ -13,37 +14,86 @@ logger = logging.getLogger(u'backends.despotify') 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() + def __init__(self): + 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 None: + return Playlist() + return self.backend.translate.to_mopidy_playlist(result.playlist) + + +class DespotifyPlaybackController(BasePlaybackController): + 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 _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.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: + 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=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 +101,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() diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 8ebf3ac2..93c8f218 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -1,25 +1,31 @@ -from mopidy.backends import BaseBackend +from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, + BasePlaybackController, BaseLibraryController, + BaseStoredPlaylistsController) +from mopidy.models import Playlist 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 Playlist() + +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 +33,7 @@ class DummyBackend(BaseBackend): def _resume(self): return True + +class DummyStoredPlaylistsController(BaseStoredPlaylistsController): + def search(self, query): + return [Playlist(name=query)] diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index d33875c9..b27333fd 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -1,14 +1,16 @@ -from copy import deepcopy import datetime as dt import logging import threading +import time from spotify import Link 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,106 +18,61 @@ 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() + + 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): + search_results = False - 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 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 - 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 _pause(self): # TODO return False - def _play(self): - if self._current_track is not None: - self._play_current_track() - return True - else: + def _play(self, track): + if self.state == self.PLAYING: + self.stop() + if track.uri is None: 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() - return True - - def _previous(self): - self._current_song_pos -= 1 - self._play_current_track() + self.backend.spotify.session.load( + Link.from_string(track.uri).as_track()) + self.backend.spotify.session.play(1) return True def _resume(self): @@ -123,16 +80,71 @@ class LibspotifyBackend(BaseBackend): return False def _stop(self): - self.spotify.session.play(0) + self.backend.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): + 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, + 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): + 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], + ) class LibspotifySessionManager(SpotifySessionManager, threading.Thread): @@ -141,6 +153,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 +172,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) @@ -175,10 +190,14 @@ 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() + def search(self, query, callback): + self.session.search(query, callback) diff --git a/mopidy/models.py b/mopidy/models.py index 39212c8e..86561bc3 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 = None - 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,64 +77,40 @@ 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): + def mpd_format(self, position=0, search_result=False): """ Format track for output to MPD client. @@ -149,17 +118,23 @@ class Track(object): :type position: integer :rtype: list of two-tuples """ - return [ - ('file', self.uri), - ('Time', self.length // 1000), + result = [ + ('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), - ('Pos', position), - ('Id', self.id), + ('Title', self.title or ''), + ('Album', self.album and self.album.name or ''), + ('Date', self.date or ''), ] + 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 +145,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 @@ -180,20 +155,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): @@ -205,15 +175,44 @@ class Playlist(object): """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. + 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: end = self.length tracks = [] - for track, position in zip(self.tracks, range(start, end)): - tracks.append(track.mpd_format(position)) + for track, position in zip(self.tracks[start:end], range(start, end)): + tracks.append(track.mpd_format(position, search_result)) 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) diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py index a331d2bd..ef7274f8 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+)*"$') @@ -189,11 +190,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[^"]*)"$') @@ -213,7 +218,15 @@ class MpdHandler(object): @register(r'^next$') def _next(self): - return self.backend.next() + 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): @@ -222,9 +235,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): @@ -232,15 +245,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): @@ -264,13 +287,23 @@ 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: + songpos = int(songpos) + 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 +315,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): @@ -290,7 +324,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): @@ -334,7 +368,8 @@ 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).mpd_format( + search_result=True) @register(r'^seek "(?P\d+)" "(?P\d+)"$') def _seek(self, songpos, seconds): @@ -368,42 +403,114 @@ 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$') 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): - result.append(('time', self.backend.status_time())) - result.append(('bitrate', self.backend.status_bitrate())) + 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._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()) + + def _status_time_elapsed(self): + return self.backend.playback.time_position + + def _status_time_total(self): + 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: + 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 @@ -418,4 +525,4 @@ class MpdHandler(object): @register(r'^urlhandlers$') def _urlhandlers(self): - return self.backend.url_handlers() + return self.backend.uri_handlers 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..29911dc7 --- /dev/null +++ b/tests/modelstest.py @@ -0,0 +1,200 @@ +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) + 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): + def test_uri(self): + 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): + def test_uri(self): + 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() + 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) 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') + +class PlaylistTest(unittest.TestCase): + def test_uri(self): + 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()] + 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) + + 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) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py index 653044db..a9a9d653 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' 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) @@ -130,37 +144,117 @@ 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): - self.b._current_playlist = Playlist(tracks=[Track()]) + 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): - self.b.state = self.b.PLAY + 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) (position, total) = result['time'].split(':') @@ -168,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): @@ -279,32 +390,55 @@ 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.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') + 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.PAUSE, self.b.state) + 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.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_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') @@ -321,7 +455,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): @@ -640,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):