diff --git a/docs/changes.rst b/docs/changes.rst index 2dd6d940..89707b6a 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,9 +5,33 @@ Changes This change log is used to track all major changes to Mopidy. -v0.11.0 (in development) +v0.12.0 (in development) ======================== +(in development) + +**Spotify** + +- Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`) + + +v0.11.0 (2012-12-24) +==================== + +In celebration of Mopidy's three year anniversary December 23, we're releasing +Mopidy 0.11. This release brings several improvements, most notably better +search which now includes matching artists and albums from Spotify in the +search results. + +**Settings** + +- The settings validator now complains if a setting which expects a tuple of + values (e.g. :attr:`mopidy.settings.BACKENDS`, + :attr:`mopidy.settings.FRONTENDS`) has a non-iterable value. This typically + happens because the setting value contains a single value and one has + forgotten to add a comma after the string, making the value a tuple. (Fixes: + :issue:`278`) + **Spotify backend** - Add :attr:`mopidy.settings.SPOTIFY_TIMEOUT` setting which allows you to @@ -61,14 +85,29 @@ v0.11.0 (in development) - Make ``seek`` and ``seekid`` not restart the current track before seeking in it. +- Include fake tracks representing albums and artists in the search results. + When these are added to the tracklist, they expand to either all tracks in + the album or all tracks by the artist. This makes it easy to play full albums + in proper order, which is a feature that have been frequently requested. + (Fixes: :issue:`67`, :issue:`148`) + **Internal changes** *Models:* - Specified that :attr:`mopidy.models.Playlist.last_modified` should be in UTC. +- Added :class:`mopidy.models.SearchResult` model to encapsulate search results + consisting of more than just tracks. + *Core API:* +- Change the following methods to return :class:`mopidy.models.SearchResult` + objects which can include both track results and other results: + + - :meth:`mopidy.core.LibraryController.find_exact` + - :meth:`mopidy.core.LibraryController.search` + - Change the following methods to accept either a dict with filters or kwargs. Previously they only accepted kwargs, which made them impossible to use from the Mopidy.js through JSON-RPC, which doesn't support kwargs. diff --git a/fabfile.py b/fabfile.py index 267bdc23..370c81be 100644 --- a/fabfile.py +++ b/fabfile.py @@ -1,14 +1,15 @@ from fabric.api import local -def test(): - local('nosetests tests/') +def test(path=None): + path = path or 'tests/' + local('nosetests ' + path) -def autotest(): +def autotest(path=None): while True: local('clear') - test() + test(path) local( 'inotifywait -q -e create -e modify -e delete ' '--exclude ".*\.(pyc|sw.)" -r mopidy/ tests/') diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 049db682..2e5aeeba 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -23,7 +23,7 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.10.0' +__version__ = '0.11.0' from mopidy import settings as default_settings_module diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 39180bbb..c6997b12 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -19,7 +19,7 @@ from __future__ import unicode_literals import pykka from mopidy.backends import base -from mopidy.models import Playlist +from mopidy.models import Playlist, SearchResult class DummyBackend(pykka.ThreadingActor, base.Backend): @@ -37,8 +37,8 @@ class DummyLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] - self.dummy_find_exact_result = [] - self.dummy_search_result = [] + self.dummy_find_exact_result = SearchResult() + self.dummy_search_result = SearchResult() def find_exact(self, **query): return self.dummy_find_exact_result diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 143c6d84..eb328ce2 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -4,7 +4,7 @@ import logging from mopidy import settings from mopidy.backends import base -from mopidy.models import Album +from mopidy.models import Album, SearchResult from .translator import parse_mpd_tag_cache @@ -70,7 +70,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return result_tracks + return SearchResult(uri='file:search', tracks=result_tracks) def search(self, **query): self._validate_query(query) @@ -107,7 +107,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return result_tracks + return SearchResult(uri='file:search', tracks=result_tracks) def _validate_query(self, query): for (_, values) in query.iteritems(): diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index db4c5d7e..a8a9bcd6 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -2,18 +2,21 @@ from __future__ import unicode_literals import logging import time +import urllib import pykka from spotify import Link, SpotifyError from mopidy import settings from mopidy.backends import base -from mopidy.models import Track +from mopidy.models import Track, SearchResult from . import translator logger = logging.getLogger('mopidy.backends.spotify') +TRACK_AVAILABLE = 1 + class SpotifyTrack(Track): """Proxy object for unloaded Spotify tracks.""" @@ -83,7 +86,10 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): track = Link.from_string(uri).as_track() self._wait_for_object_to_load(track) if track.is_loaded(): - return [SpotifyTrack(track=track)] + if track.availability() == TRACK_AVAILABLE: + return [SpotifyTrack(track=track)] + else: + return [] else: return [SpotifyTrack(uri=uri)] @@ -91,18 +97,24 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): album = Link.from_string(uri).as_album() album_browser = self.backend.spotify.session.browse_album(album) self._wait_for_object_to_load(album_browser) - return [SpotifyTrack(track=t) for t in album_browser] + return [ + SpotifyTrack(track=t) + for t in album_browser if t.availability() == TRACK_AVAILABLE] def _lookup_artist(self, uri): artist = Link.from_string(uri).as_artist() artist_browser = self.backend.spotify.session.browse_artist(artist) self._wait_for_object_to_load(artist_browser) - return [SpotifyTrack(track=t) for t in artist_browser] + return [ + SpotifyTrack(track=t) + for t in artist_browser if t.availability() == TRACK_AVAILABLE] def _lookup_playlist(self, uri): playlist = Link.from_string(uri).as_playlist() self._wait_for_object_to_load(playlist) - return [SpotifyTrack(track=t) for t in playlist] + return [ + SpotifyTrack(track=t) + for t in playlist if t.availability() == TRACK_AVAILABLE] def _wait_for_object_to_load( self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): @@ -123,12 +135,16 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): if not query: return self._get_all_tracks() - if 'uri' in query.keys(): - result = [] - for uri in query['uri']: - tracks = self.lookup(uri) - result += tracks - return result + uris = query.get('uri', []) + if uris: + tracks = [] + for uri in uris: + tracks += self.lookup(uri) + if len(uris) == 1: + uri = uris[0] + else: + uri = 'spotify:search' + return SearchResult(uri=uri, tracks=tracks) spotify_query = self._translate_search_query(query) logger.debug('Spotify search query: %s' % spotify_query) @@ -136,20 +152,24 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): future = pykka.ThreadingFuture() def callback(results, userdata=None): - # TODO Include results from results.albums(), etc. too - # TODO Consider launching a second search if results.total_tracks() - # is larger than len(results.tracks()) - tracks = [ - translator.to_mopidy_track(t) for t in results.tracks()] - future.set(tracks) + search_result = SearchResult( + uri='spotify:search:%s' % ( + urllib.quote(results.query().encode('utf-8'))), + albums=[ + translator.to_mopidy_album(a) for a in results.albums()], + artists=[ + translator.to_mopidy_artist(a) for a in results.artists()], + tracks=[ + translator.to_mopidy_track(t) for t in results.tracks()]) + future.set(search_result) if not self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT): logger.debug('Not connected: Spotify search cancelled') - return [] + return SearchResult(uri='spotify:search') self.backend.spotify.session.search( spotify_query, callback, - track_count=200, album_count=0, artist_count=0) + album_count=200, artist_count=200, track_count=200) try: return future.get(timeout=settings.SPOTIFY_TIMEOUT) @@ -157,7 +177,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): logger.debug( 'Timeout: Spotify search did not return in %ds', settings.SPOTIFY_TIMEOUT) - return [] + return SearchResult(uri='spotify:search') def _get_all_tracks(self): # Since we can't search for the entire Spotify library, we return @@ -165,7 +185,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): tracks = [] for playlist in self.backend.playlists.playlists: tracks += playlist.tracks - return tracks + return SearchResult(uri='spotify:search', tracks=tracks) def _translate_search_query(self, mopidy_query): spotify_query = [] diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 3c596a3a..39a1e99c 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -import itertools import urlparse import pykka @@ -37,13 +36,12 @@ class LibraryController(object): :param query: one or more queries to search for :type query: dict - :rtype: list of :class:`mopidy.models.Track` + :rtype: list of :class:`mopidy.models.SearchResult` """ query = query or kwargs futures = [ b.library.find_exact(**query) for b in self.backends.with_library] - results = pykka.get_all(futures) - return list(itertools.chain(*results)) + return pykka.get_all(futures) def lookup(self, uri): """ @@ -98,10 +96,9 @@ class LibraryController(object): :param query: one or more queries to search for :type query: dict - :rtype: list of :class:`mopidy.models.Track` + :rtype: list of :class:`mopidy.models.SearchResult` """ query = query or kwargs futures = [ b.library.search(**query) for b in self.backends.with_library] - results = pykka.get_all(futures) - return list(itertools.chain(*results)) + return pykka.get_all(futures) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 393561de..c457ee02 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,5 +1,9 @@ from __future__ import unicode_literals +import functools +import itertools + +from mopidy.models import Track from mopidy.frontends.mpd import translator from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists @@ -10,6 +14,31 @@ QUERY_RE = ( r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +def _get_field(field, search_results): + return list(itertools.chain(*[getattr(r, field) for r in search_results])) + + +_get_albums = functools.partial(_get_field, 'albums') +_get_artists = functools.partial(_get_field, 'artists') +_get_tracks = functools.partial(_get_field, 'tracks') + + +def _album_as_track(album): + return Track( + uri=album.uri, + name='Album: ' + album.name, + artists=album.artists, + album=album, + date=album.date) + + +def _artist_as_track(artist): + return Track( + uri=artist.uri, + name='Artist: ' + artist.name, + artists=[artist]) + + @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ @@ -55,8 +84,14 @@ def find(context, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.find_exact(**query).get() - return translator.tracks_to_mpd_format(result) + results = context.core.library.find_exact(**query).get() + result_tracks = [] + if 'artist' not in query: + result_tracks += [_artist_as_track(a) for a in _get_artists(results)] + if 'album' not in query: + result_tracks += [_album_as_track(a) for a in _get_albums(results)] + result_tracks += _get_tracks(results) + return translator.tracks_to_mpd_format(result_tracks) @handle_request(r'^findadd ' + QUERY_RE) @@ -73,8 +108,8 @@ def findadd(context, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.find_exact(**query).get() - context.core.tracklist.add(result) + results = context.core.library.find_exact(**query).get() + context.core.tracklist.add(_get_tracks(results)) @handle_request( @@ -179,8 +214,8 @@ def list_(context, field, mpd_query=None): def _list_artist(context, query): artists = set() - tracks = context.core.library.find_exact(**query).get() - for track in tracks: + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): for artist in track.artists: if artist.name: artists.add(('Artist', artist.name)) @@ -189,8 +224,8 @@ def _list_artist(context, query): def _list_album(context, query): albums = set() - tracks = context.core.library.find_exact(**query).get() - for track in tracks: + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): if track.album and track.album.name: albums.add(('Album', track.album.name)) return albums @@ -198,8 +233,8 @@ def _list_album(context, query): def _list_date(context, query): dates = set() - tracks = context.core.library.find_exact(**query).get() - for track in tracks: + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): if track.date: dates.add(('Date', track.date)) return dates @@ -297,8 +332,11 @@ def search(context, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.search(**query).get() - return translator.tracks_to_mpd_format(result) + results = context.core.library.search(**query).get() + artists = [_artist_as_track(a) for a in _get_artists(results)] + albums = [_album_as_track(a) for a in _get_albums(results)] + tracks = _get_tracks(results) + return translator.tracks_to_mpd_format(artists + albums + tracks) @handle_request(r'^searchadd ' + QUERY_RE) @@ -318,8 +356,8 @@ def searchadd(context, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.search(**query).get() - context.core.tracklist.add(result) + results = context.core.library.search(**query).get() + context.core.tracklist.add(_get_tracks(results)) @handle_request(r'^searchaddpl "(?P[^"]+)" ' + QUERY_RE) @@ -341,14 +379,14 @@ def searchaddpl(context, playlist_name, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.search(**query).get() + results = context.core.library.search(**query).get() playlists = context.core.playlists.filter(name=playlist_name).get() if playlists: playlist = playlists[0] else: playlist = context.core.playlists.create(playlist_name).get() - tracks = list(playlist.tracks) + result + tracks = list(playlist.tracks) + _get_tracks(results) playlist = playlist.copy(tracks=tracks) context.core.playlists.save(playlist) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index b8153dc9..8e08585f 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -329,8 +329,7 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - songpos = int(songpos) - if context.core.playback.tracklist_position.get() != songpos: + if context.core.playback.tracklist_position.get() != int(songpos): playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000).get() @@ -344,9 +343,8 @@ def seekid(context, tlid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ - tlid = int(tlid) tl_track = context.core.playback.current_tl_track.get() - if not tl_track or tl_track.tlid != tlid: + if not tl_track or tl_track.tlid != int(tlid): playid(context, tlid) context.core.playback.seek(int(seconds) * 1000).get() diff --git a/mopidy/models.py b/mopidy/models.py index e47ed3be..73209b6e 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -318,3 +318,34 @@ class Playlist(ImmutableObject): def length(self): """The number of tracks in the playlist. Read-only.""" return len(self.tracks) + + +class SearchResult(ImmutableObject): + """ + :param uri: search result URI + :type uri: string + :param tracks: matching tracks + :type tracks: list of :class:`Track` elements + :param artists: matching artists + :type artists: list of :class:`Artist` elements + :param albums: matching albums + :type albums: list of :class:`Album` elements + """ + + # The search result URI. Read-only. + uri = None + + # The tracks matching the search query. Read-only. + tracks = tuple() + + # The artists matching the search query. Read-only. + artists = tuple() + + # The albums matching the search query. Read-only. + albums = tuple() + + def __init__(self, *args, **kwargs): + self.__dict__['tracks'] = tuple(kwargs.pop('tracks', [])) + self.__dict__['artists'] = tuple(kwargs.pop('artists', [])) + self.__dict__['albums'] = tuple(kwargs.pop('albums', [])) + super(SearchResult, self).__init__(*args, **kwargs) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index fee5252d..6eb462ce 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -172,6 +172,10 @@ def validate_settings(defaults, settings): 'bin in OUTPUT.') elif setting in list_of_one_or_more: + if not hasattr(value, '__iter__'): + errors[setting] = ( + 'Must be a tuple. ' + "Remember the comma after single values: (u'value',)") if not value: errors[setting] = 'Must contain at least one value.' diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 57aec3c6..c75bec74 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -53,53 +53,53 @@ class LibraryControllerTest(object): def test_find_exact_no_hits(self): result = self.library.find_exact(track=['unknown track']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.find_exact(artist=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.find_exact(album=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) def test_find_exact_uri(self): track_1_uri = 'file://' + path_to_data_dir('uri1') result = self.library.find_exact(uri=track_1_uri) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) track_2_uri = 'file://' + path_to_data_dir('uri2') result = self.library.find_exact(uri=track_2_uri) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_track(self): result = self.library.find_exact(track=['track1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.find_exact(track=['track2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_artist(self): result = self.library.find_exact(artist=['artist1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.find_exact(artist=['artist2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_album(self): result = self.library.find_exact(album=['album1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.find_exact(album=['album2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_date(self): result = self.library.find_exact(date=['2001']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.find_exact(date=['2001-02-03']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.find_exact(date=['2002']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) @@ -117,70 +117,70 @@ class LibraryControllerTest(object): def test_search_no_hits(self): result = self.library.search(track=['unknown track']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(artist=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(album=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(uri=['unknown']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(any=['unknown']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) def test_search_uri(self): result = self.library.search(uri=['RI1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(uri=['RI2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_track(self): result = self.library.search(track=['Rack1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(track=['Rack2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_artist(self): result = self.library.search(artist=['Tist1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(artist=['Tist2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_album(self): result = self.library.search(album=['Bum1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(album=['Bum2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_date(self): result = self.library.search(date=['2001']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(date=['2001-02-03']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(date=['2001-02-04']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(date=['2002']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_any(self): result = self.library.search(any=['Tist1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['Rack1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['Bum1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['RI1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): test = lambda: self.library.search(wrong=['test']) diff --git a/tests/core/library_test.py b/tests/core/library_test.py index a2c358d7..32e618d2 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -4,7 +4,7 @@ import mock from mopidy.backends import base from mopidy.core import Core -from mopidy.models import Track +from mopidy.models import SearchResult, Track from tests import unittest @@ -75,59 +75,71 @@ class CoreLibraryTest(unittest.TestCase): def test_find_exact_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') - self.library1.find_exact().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.find_exact().get.return_value = result1 self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = [track2] + self.library2.find_exact().get.return_value = result2 self.library2.find_exact.reset_mock() result = self.core.library.find_exact(any=['a']) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, result) self.library1.find_exact.assert_called_once_with(any=['a']) self.library2.find_exact.assert_called_once_with(any=['a']) def test_find_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') - self.library1.find_exact().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.find_exact().get.return_value = result1 self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = [track2] + self.library2.find_exact().get.return_value = result2 self.library2.find_exact.reset_mock() result = self.core.library.find_exact(dict(any=['a'])) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, result) self.library1.find_exact.assert_called_once_with(any=['a']) self.library2.find_exact.assert_called_once_with(any=['a']) def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') - self.library1.search().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.search().get.return_value = result1 self.library1.search.reset_mock() - self.library2.search().get.return_value = [track2] + self.library2.search().get.return_value = result2 self.library2.search.reset_mock() result = self.core.library.search(any=['a']) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, result) self.library1.search.assert_called_once_with(any=['a']) self.library2.search.assert_called_once_with(any=['a']) def test_search_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') - self.library1.search().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.search().get.return_value = result1 self.library1.search.reset_mock() - self.library2.search().get.return_value = [track2] + self.library2.search().get.return_value = result2 self.library2.search.reset_mock() result = self.core.library.search(dict(any=['a'])) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, result) self.library1.search.assert_called_once_with(any=['a']) self.library2.search.assert_called_once_with(any=['a']) diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 5c887958..d16a636b 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from mopidy.models import Album, Artist, Track +from mopidy.models import Album, Artist, SearchResult, Track from tests.frontends.mpd import protocol @@ -13,9 +13,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_findadd(self): - self.backend.library.dummy_find_exact_result = [ - Track(uri='dummy:a', name='A'), - ] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual(self.core.tracklist.length.get(), 0) self.sendRequest('findadd "title" "A"') @@ -25,9 +24,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_searchadd(self): - self.backend.library.dummy_search_result = [ - Track(uri='dummy:a', name='A'), - ] + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual(self.core.tracklist.length.get(), 0) self.sendRequest('searchadd "title" "a"') @@ -43,9 +41,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): Track(uri='dummy:y', name='y'), ]) self.core.playlists.save(playlist) - self.backend.library.dummy_search_result = [ - Track(uri='dummy:a', name='A'), - ] + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) playlists = self.core.playlists.filter(name='my favs').get() self.assertEqual(len(playlists), 1) self.assertEqual(len(playlists[0].tracks), 2) @@ -61,9 +58,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_searchaddpl_creates_missing_playlist(self): - self.backend.library.dummy_search_result = [ - Track(uri='dummy:a', name='A'), - ] + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual( len(self.core.playlists.filter(name='my favs').get()), 0) @@ -119,6 +115,66 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): class MusicDatabaseFindTest(protocol.BaseTestCase): + def test_find_includes_fake_artist_and_album_tracks(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A', date='2001')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "any" "foo"') + + self.assertInResponse('file: dummy:artist:b') + self.assertInResponse('Title: Artist: B') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('Date: 2001') + + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + + def test_find_artist_does_not_include_fake_artist_tracks(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A', date='2001')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "artist" "foo"') + + self.assertNotInResponse('file: dummy:artist:b') + self.assertNotInResponse('Title: Artist: B') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('Date: 2001') + + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + + def test_find_artist_and_album_does_not_include_fake_tracks(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A', date='2001')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "artist" "foo" "album" "bar"') + + self.assertNotInResponse('file: dummy:artist:b') + self.assertNotInResponse('Title: Artist: B') + + self.assertNotInResponse('file: dummy:album:a') + self.assertNotInResponse('Title: Album: A') + self.assertNotInResponse('Date: 2001') + + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + def test_find_album(self): self.sendRequest('find "album" "what"') self.assertInResponse('OK') @@ -185,6 +241,17 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): class MusicDatabaseListTest(protocol.BaseTestCase): + def test_list(self): + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[ + Track(uri='dummy:a', name='A', artists=[ + Artist(name='A Artist')])]) + + self.sendRequest('list "artist" "artist" "foo"') + + self.assertInResponse('Artist: A Artist') + self.assertInResponse('OK') + def test_list_foo_returns_ack(self): self.sendRequest('list "foo"') self.assertEqualResponse('ACK [2@0] {list} incorrect arguments') @@ -242,8 +309,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_artist_should_not_return_artists_without_names(self): - self.backend.library.dummy_find_exact_result = [ - Track(artists=[Artist(name='')])] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(artists=[Artist(name='')])]) self.sendRequest('list "artist"') self.assertNotInResponse('Artist: ') @@ -301,8 +368,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_album_should_not_return_albums_without_names(self): - self.backend.library.dummy_find_exact_result = [ - Track(album=Album(name=''))] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(album=Album(name=''))]) self.sendRequest('list "album"') self.assertNotInResponse('Album: ') @@ -356,7 +423,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_date_should_not_return_blank_dates(self): - self.backend.library.dummy_find_exact_result = [Track(date='')] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(date='')]) self.sendRequest('list "date"') self.assertNotInResponse('Date: ') @@ -412,6 +480,23 @@ class MusicDatabaseListTest(protocol.BaseTestCase): class MusicDatabaseSearchTest(protocol.BaseTestCase): + def test_search(self): + self.backend.library.dummy_search_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('search "any" "foo"') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('file: dummy:artist:b') + self.assertInResponse('Title: Artist: B') + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + def test_search_album(self): self.sendRequest('search "album" "analbum"') self.assertInResponse('OK') diff --git a/tests/models_test.py b/tests/models_test.py index 1a4d869a..89d0b132 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -4,7 +4,7 @@ import datetime import json from mopidy.models import ( - Artist, Album, TlTrack, Track, Playlist, + Artist, Album, TlTrack, Track, Playlist, SearchResult, ModelJSONEncoder, model_json_decoder) from tests import unittest @@ -862,10 +862,56 @@ class PlaylistTest(unittest.TestCase): def test_ne(self): playlist1 = Playlist( - uri='uri1', name='name2', tracks=[Track(uri='uri1')], + uri='uri1', name='name1', tracks=[Track(uri='uri1')], last_modified=1) playlist2 = Playlist( uri='uri2', name='name2', tracks=[Track(uri='uri2')], last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) + + +class SearchResultTest(unittest.TestCase): + def test_uri(self): + uri = 'an_uri' + result = SearchResult(uri=uri) + self.assertEqual(result.uri, uri) + self.assertRaises(AttributeError, setattr, result, 'uri', None) + + def test_tracks(self): + tracks = [Track(), Track(), Track()] + result = SearchResult(tracks=tracks) + self.assertEqual(list(result.tracks), tracks) + self.assertRaises(AttributeError, setattr, result, 'tracks', None) + + def test_artists(self): + artists = [Artist(), Artist(), Artist()] + result = SearchResult(artists=artists) + self.assertEqual(list(result.artists), artists) + self.assertRaises(AttributeError, setattr, result, 'artists', None) + + def test_albums(self): + albums = [Album(), Album(), Album()] + result = SearchResult(albums=albums) + self.assertEqual(list(result.albums), albums) + self.assertRaises(AttributeError, setattr, result, 'albums', None) + + def test_invalid_kwarg(self): + test = lambda: SearchResult(foo='baz') + self.assertRaises(TypeError, test) + + def test_repr_without_results(self): + self.assertEquals( + "SearchResult(albums=[], artists=[], tracks=[], uri=u'uri')", + repr(SearchResult(uri='uri'))) + + def test_serialize_without_results(self): + self.assertDictEqual( + {'__model__': 'SearchResult', 'uri': 'uri'}, + SearchResult(uri='uri').serialize()) + + def test_to_json_and_back(self): + result1 = SearchResult(uri='uri') + serialized = json.dumps(result1, cls=ModelJSONEncoder) + result2 = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(result1, result2) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 0ecbb90f..1dcac1bb 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -87,6 +87,14 @@ class ValidateSettingsTest(unittest.TestCase): self.assertEqual( result['BACKENDS'], 'Must contain at least one value.') + def test_noniterable_multivalue_setting_returns_error(self): + result = setting_utils.validate_settings( + self.defaults, {'FRONTENDS': ('this is not a tuple')}) + self.assertEqual( + result['FRONTENDS'], + 'Must be a tuple. ' + "Remember the comma after single values: (u'value',)") + class SettingsProxyTest(unittest.TestCase): def setUp(self): diff --git a/tests/version_test.py b/tests/version_test.py index 271f004a..f353f201 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -32,5 +32,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.7.3'), SV('0.8.0')) self.assertLess(SV('0.8.0'), SV('0.8.1')) self.assertLess(SV('0.8.1'), SV('0.9.0')) - self.assertLess(SV('0.9.0'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.10.1')) + self.assertLess(SV('0.9.0'), SV('0.10.0')) + self.assertLess(SV('0.10.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.11.1'))