diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 00000000..69f3b5b7 --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,255 @@ +Changelog +========= + +v2.3.1 (2016-02-14) +------------------- + +Bug fix release. + +- Require Mopidy < 2 as Mopidy 2.0 breaks the audio API with the upgrade to + GStreamer 1. + +- Use the new Spotify Web API for search. Searching through libspotify has been + discontinued and is not working anymore. (Fixes: #89) + +- Note that search through the Spotify Web API doesn't return artists or date + for albums. This also means that ``library.get_distinct()`` when given type + ``albumartist`` or ``date`` and a query only returns an empty set. + +- Emit a warning if config value ``spotify/search_album_count``, + ``spotify/search_artist_count``, or ``spotify/search_track_count`` is greater + than 50, and use 50 instead. 50 is the maximum value that the Spotify Web API + allows. The maximum in the config schema is not changed to not break existing + configs. + +v2.3.0 (2016-02-06) +------------------- + +Feature release. + +- Ignore all audio data deliveries from libspotify when when a seek is in + progress. This ensures that we don't deliver audio data from before the seek + with timestamps from after the seek. + +- Ignore duplicate end of track callbacks. + +- Don't increase the audio buffer timestamp if the buffer is rejected by + Mopidy. This caused audio buffers delivered after one or more rejected audio + buffers to have too high timestamps. + +- When changing tracks, block until Mopidy completes the appsrc URI change. + Not blocking here might break gapless playback. + +- Lookup of a playlist you're not subscribed to will now properly load all of + the playlist's tracks. (Fixes: #81, PR: #82) + +- Workaround teardown race outputing lots of short stack traces on Mopidy + shutdown. (See #73 for details) + +v2.2.0 (2015-11-15) +------------------- + +Feature release. + +- As Spotify now exposes your "Starred" playlist as a regular playlist, we no + longer get your "Starred" playlist through the dedicated "Starred" API. This + avoids your "Starred" playlist being returned twice. Lookup of + ``spotify:user::starred`` URIs works as before. (Fixes: #71) + +- Interpret album year "0" as unknown. (Fixes: #72) + +v2.1.0 (2015-09-22) +------------------- + +Feature release. + +- Require Requests >= 2.0. + +- Support using a proxy when connecting to Spotify. This was forgotten in the + 2.0 reimplementation. (Fixes: #68) + +- Support using a proxy when accessing Spotify's web API to get cover and + artist imagery. + +v2.0.1 (2015-08-23) +------------------- + +Bug fix release. + +- Filter out ``None`` from ``library.get_distinct()`` return values. (Fixes: + #63) + +v2.0.0 (2015-08-11) +------------------- + +Rewrite using pyspotify 2. Should have feature parity with Mopidy-Spotify 1. + +**Config** + +- Add ``spotify/volume_normalization`` config. (Fixes: #13) + +- Add ``spotify/allow_network`` config which can be used to force + Mopidy-Spotify to stay offline. This is mostly useful for testing during + development. + +- Add ``spotify/allow_playlists`` config which can be used to disable all + access to playlists on the Spotify account. Useful where Mopidy is shared by + multiple users. (Fixes: #25) + +- Make maximum number of returned results configurable through + ``spotify/search_album_count``, ``spotify/search_artist_count``, and + ``spotify/search_track_count``. + +- Add ``spotify/private_session`` config. + +- Change ``spotify/toplist_countries`` default value to blank, which is now + interpreted as all supported countries instead of no countries. + +- Removed ``spotify/cache_dir`` and ``spotify/settings_dir`` config values. We + now use a "spotify" directory in the ``core/cache_dir`` and + ``core/data_dir`` directories defined in Mopidy's configuration. + +- Add ``spotify/allow_cache`` config value to make it possible to disable + caching. + +**Browse** + +- Add browsing of top albums and top artists, in additon to top tracks. + +- Add browsing by current user's country, in addition to personal, global and + per-country browsing. + +- Add browsing of artists, which includes the artist's top tracks and albums. + +- Update list of countries Spotify is available in and provides toplists for. + +**Lookup** + +- Adding an artist by URI will now first find all albums by the artist and + then all tracks in the albums. This way, the returned tracks are grouped by + album and they are sorted by track number. (Fixes: #7) + +- When adding an artist by URI, all albums that are marked as "compilations" + or where the album artist is "Various Artists" are now ignored. (Fixes: #5) + +**Library** + +- The library provider method ``get_distinct()`` is now supported. When called + without a query, the tracks in the user's playlists is used as the data + source. When called with a query, a Spotify search is used as the data + source. This addition makes the library view in some notable MPD clients, + like ncmpcpp, become quite fast and usable with Spotify. (Fixes: #50) + +**Playback** + +- If another Spotify client starts playback with the same account, we get a + "play token lost" event. Previously, Mopidy-Spotify would unconditionally + pause Mopidy playback if this happened. Now, we only pause playback if we're + currently playing music from Spotify. (Fixes: #1) + +v1.4.0 (2015-05-19) +------------------- + +- Update to not use deprecated Mopidy audio APIs. + +- Use strings and not ints for the model's date field. This is required for + compatibility with the model validation added in Mopidy 1.1. (Fixes: #52) + +- Fix error causing the image of every 50th URI in a ``library.get_images()`` + call to not be looked up and returned. + +- Fix handling of empty search queries. This was still using the removed + ``playlists.playlists`` to fetch all your tracks. + +- Update the ``SpotifyTrack`` proxy model to work with Mopidy 1.1 model + changes. + +- Updated to work with the renaming of ``mopidy.utils`` to ``mopidy.internal`` + in Mopidy 1.1. + +v1.3.0 (2015-03-25) +------------------- + +- Require Mopidy >= 1.0. + +- Update to work with new playback API in Mopidy 1.0. + +- Update to work with new playlists API in Mopidy 1.0. + +- Update to work with new search API in Mopidy 1.0. + +- Add ``library.get_images()`` support for cover art. + +v1.2.0 (2014-07-21) +------------------- + +- Add support for browsing playlists and albums. Needed to allow music + discovery extensions expose these in a clean way. + +- Fix loss of audio when resuming from paused, when caused by another Spotify + client starting playback. (Fixes: #2, PR: #19) + +v1.1.3 (2014-02-18) +------------------- + +- Switch to new backend API locations, required by the upcoming Mopidy 0.19 + release. + +v1.1.2 (2014-02-18) +------------------- + +- Wait for track to be loaded before playing it. This fixes playback of tracks + looked up directly by URI, and not through a playlist or search. (Fixes: + mopidy/mopidy#675) + +v1.1.1 (2014-02-16) +------------------- + +- Change requirement on pyspotify from ``>= 1.9, < 2`` to ``>= 1.9, < 1.999``, + so that it is parsed correctly and pyspotify 1.x is installed instead of 2.x. + +v1.1.0 (2014-01-20) +------------------- + +- Require Mopidy >= 0.18. + +- Change ``library.lookup()`` to return tracks even if they are unplayable. + There's no harm in letting them be added to the tracklist, as Mopidy will + simply skip to the next track when failing to play the track. (Fixes: + mopidy/mopidy#606) + +- Added basic library browsing support that exposes user, global and country + toplists. + +v1.0.3 (2013-12-15) +------------------- + +- Change search field ``track`` to ``track_name`` for compatibility with + Mopidy 0.17. (Fixes: mopidy/mopidy#610) + +v1.0.2 (2013-11-19) +------------------- + +- Add ``spotify/settings_dir`` config value so that libspotify settings can be + stored to another location than the libspotify cache. This also allows + ``spotify/cache_dir`` to be unset, since settings are now using it's own + config value. + +- Make the ``spotify/cache_dir`` config value optional, so that it can be set + to an empty string to disable caching. + +v1.0.1 (2013-10-28) +------------------- + +- Support searches from Mopidy that are using the ``albumartist`` field type, + added in Mopidy 0.16. + +- Ignore the ``track_no`` field in search queries, added in Mopidy 0.16. + +- Abort Spotify searches immediately if the search query is empty instead of + waiting for the 10s timeout before returning an empty search result. + +v1.0.0 (2013-10-08) +------------------- + +- Moved extension out of the main Mopidy project. diff --git a/MANIFEST.in b/MANIFEST.in index 201ce455..7f7f76d3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,8 @@ +include *.rst include *.txt include .travis.yml include LICENSE include MANIFEST.in -include README.rst include mopidy_spotify/ext.conf include mopidy_spotify/spotify_appkey.key include tox.ini diff --git a/README.rst b/README.rst index 9c2d8ee6..b4d83acf 100644 --- a/README.rst +++ b/README.rst @@ -111,13 +111,13 @@ The following configuration values are available: Defaults to ``true``. - ``spotify/search_album_count``: Maximum number of albums returned in search - results. Number between 0 and 200. Defaults to 20. + results. Number between 0 and 50. Defaults to 20. - ``spotify/search_artist_count``: Maximum number of artists returned in search - results. Number between 0 and 200. Defaults to 10. + results. Number between 0 and 50. Defaults to 10. - ``spotify/search_track_count``: Maximum number of tracks returned in search - results. Number between 0 and 200. Defaults to 50. + results. Number between 0 and 50. Defaults to 50. - ``spotify/toplist_countries``: Comma separated list of two letter ISO country codes to get toplists for. Defaults to blank, which is interpreted as all @@ -143,239 +143,3 @@ Credits - Original author: `Stein Magnus Jodal `__ - Current maintainer: `Stein Magnus Jodal `__ - `Contributors `_ - - -Changelog -========= - -v2.3.0 (2016-02-06) -------------------- - -Feature release. - -- Ignore all audio data deliveries from libspotify when when a seek is in - progress. This ensures that we don't deliver audio data from before the seek - with timestamps from after the seek. - -- Ignore duplicate end of track callbacks. - -- Don't increase the audio buffer timestamp if the buffer is rejected by - Mopidy. This caused audio buffers delivered after one or more rejected audio - buffers to have too high timestamps. - -- When changing tracks, block until Mopidy completes the appsrc URI change. - Not blocking here might break gapless playback. - -- Lookup of a playlist you're not subscribed to will now properly load all of - the playlist's tracks. (Fixes: #81, PR: #82) - -- Workaround teardown race outputing lots of short stack traces on Mopidy - shutdown. (See #73 for details) - -v2.2.0 (2015-11-15) -------------------- - -Feature release. - -- As Spotify now exposes your "Starred" playlist as a regular playlist, we no - longer get your "Starred" playlist through the dedicated "Starred" API. This - avoids your "Starred" playlist being returned twice. Lookup of - ``spotify:user::starred`` URIs works as before. (Fixes: #71) - -- Interpret album year "0" as unknown. (Fixes: #72) - -v2.1.0 (2015-09-22) -------------------- - -Feature release. - -- Require Requests >= 2.0. - -- Support using a proxy when connecting to Spotify. This was forgotten in the - 2.0 reimplementation. (Fixes: #68) - -- Support using a proxy when accessing Spotify's web API to get cover and - artist imagery. - -v2.0.1 (2015-08-23) -------------------- - -Bug fix release. - -- Filter out ``None`` from ``library.get_distinct()`` return values. (Fixes: - #63) - -v2.0.0 (2015-08-11) -------------------- - -Rewrite using pyspotify 2. Should have feature parity with Mopidy-Spotify 1. - -**Config** - -- Add ``spotify/volume_normalization`` config. (Fixes: #13) - -- Add ``spotify/allow_network`` config which can be used to force - Mopidy-Spotify to stay offline. This is mostly useful for testing during - development. - -- Add ``spotify/allow_playlists`` config which can be used to disable all - access to playlists on the Spotify account. Useful where Mopidy is shared by - multiple users. (Fixes: #25) - -- Make maximum number of returned results configurable through - ``spotify/search_album_count``, ``spotify/search_artist_count``, and - ``spotify/search_track_count``. - -- Add ``spotify/private_session`` config. - -- Change ``spotify/toplist_countries`` default value to blank, which is now - interpreted as all supported countries instead of no countries. - -- Removed ``spotify/cache_dir`` and ``spotify/settings_dir`` config values. We - now use a "spotify" directory in the ``core/cache_dir`` and - ``core/data_dir`` directories defined in Mopidy's configuration. - -- Add ``spotify/allow_cache`` config value to make it possible to disable - caching. - -**Browse** - -- Add browsing of top albums and top artists, in additon to top tracks. - -- Add browsing by current user's country, in addition to personal, global and - per-country browsing. - -- Add browsing of artists, which includes the artist's top tracks and albums. - -- Update list of countries Spotify is available in and provides toplists for. - -**Lookup** - -- Adding an artist by URI will now first find all albums by the artist and - then all tracks in the albums. This way, the returned tracks are grouped by - album and they are sorted by track number. (Fixes: #7) - -- When adding an artist by URI, all albums that are marked as "compilations" - or where the album artist is "Various Artists" are now ignored. (Fixes: #5) - -**Library** - -- The library provider method ``get_distinct()`` is now supported. When called - without a query, the tracks in the user's playlists is used as the data - source. When called with a query, a Spotify search is used as the data - source. This addition makes the library view in some notable MPD clients, - like ncmpcpp, become quite fast and usable with Spotify. (Fixes: #50) - -**Playback** - -- If another Spotify client starts playback with the same account, we get a - "play token lost" event. Previously, Mopidy-Spotify would unconditionally - pause Mopidy playback if this happened. Now, we only pause playback if we're - currently playing music from Spotify. (Fixes: #1) - -v1.4.0 (2015-05-19) -------------------- - -- Update to not use deprecated Mopidy audio APIs. - -- Use strings and not ints for the model's date field. This is required for - compatibility with the model validation added in Mopidy 1.1. (Fixes: #52) - -- Fix error causing the image of every 50th URI in a ``library.get_images()`` - call to not be looked up and returned. - -- Fix handling of empty search queries. This was still using the removed - ``playlists.playlists`` to fetch all your tracks. - -- Update the ``SpotifyTrack`` proxy model to work with Mopidy 1.1 model - changes. - -- Updated to work with the renaming of ``mopidy.utils`` to ``mopidy.internal`` - in Mopidy 1.1. - -v1.3.0 (2015-03-25) -------------------- - -- Require Mopidy >= 1.0. - -- Update to work with new playback API in Mopidy 1.0. - -- Update to work with new playlists API in Mopidy 1.0. - -- Update to work with new search API in Mopidy 1.0. - -- Add ``library.get_images()`` support for cover art. - -v1.2.0 (2014-07-21) -------------------- - -- Add support for browsing playlists and albums. Needed to allow music - discovery extensions expose these in a clean way. - -- Fix loss of audio when resuming from paused, when caused by another Spotify - client starting playback. (Fixes: #2, PR: #19) - -v1.1.3 (2014-02-18) -------------------- - -- Switch to new backend API locations, required by the upcoming Mopidy 0.19 - release. - -v1.1.2 (2014-02-18) -------------------- - -- Wait for track to be loaded before playing it. This fixes playback of tracks - looked up directly by URI, and not through a playlist or search. (Fixes: - mopidy/mopidy#675) - -v1.1.1 (2014-02-16) -------------------- - -- Change requirement on pyspotify from ``>= 1.9, < 2`` to ``>= 1.9, < 1.999``, - so that it is parsed correctly and pyspotify 1.x is installed instead of 2.x. - -v1.1.0 (2014-01-20) -------------------- - -- Require Mopidy >= 0.18. - -- Change ``library.lookup()`` to return tracks even if they are unplayable. - There's no harm in letting them be added to the tracklist, as Mopidy will - simply skip to the next track when failing to play the track. (Fixes: - mopidy/mopidy#606) - -- Added basic library browsing support that exposes user, global and country - toplists. - -v1.0.3 (2013-12-15) -------------------- - -- Change search field ``track`` to ``track_name`` for compatibility with - Mopidy 0.17. (Fixes: mopidy/mopidy#610) - -v1.0.2 (2013-11-19) -------------------- - -- Add ``spotify/settings_dir`` config value so that libspotify settings can be - stored to another location than the libspotify cache. This also allows - ``spotify/cache_dir`` to be unset, since settings are now using it's own - config value. - -- Make the ``spotify/cache_dir`` config value optional, so that it can be set - to an empty string to disable caching. - -v1.0.1 (2013-10-28) -------------------- - -- Support searches from Mopidy that are using the ``albumartist`` field type, - added in Mopidy 0.16. - -- Ignore the ``track_no`` field in search queries, added in Mopidy 0.16. - -- Abort Spotify searches immediately if the search query is empty instead of - waiting for the 10s timeout before returning an empty search result. - -v1.0.0 (2013-10-08) -------------------- - -- Moved extension out of the main Mopidy project. diff --git a/mopidy_spotify/__init__.py b/mopidy_spotify/__init__.py index 4a5ddc52..bce19cb5 100644 --- a/mopidy_spotify/__init__.py +++ b/mopidy_spotify/__init__.py @@ -5,7 +5,7 @@ from mopidy import config, ext -__version__ = '2.3.0' +__version__ = '2.3.1' class Extension(ext.Extension): diff --git a/mopidy_spotify/distinct.py b/mopidy_spotify/distinct.py index 0e1f9921..a2e0f17f 100644 --- a/mopidy_spotify/distinct.py +++ b/mopidy_spotify/distinct.py @@ -4,40 +4,41 @@ import spotify -from mopidy_spotify import translator +from mopidy_spotify import search logger = logging.getLogger(__name__) -def get_distinct(config, session, field, query=None): +def get_distinct(config, session, requests_session, field, query=None): # To make the returned data as interesting as possible, we limit # ourselves to data extracted from the user's playlists when no search # query is included. - sp_query = translator.sp_search_query(query) if query else None - if field == 'artist': - result = _get_distinct_artists(config, session, sp_query) + result = _get_distinct_artists( + config, session, requests_session, query) elif field == 'albumartist': - result = _get_distinct_albumartists(config, session, sp_query) + result = _get_distinct_albumartists( + config, session, requests_session, query) elif field == 'album': - result = _get_distinct_albums(config, session, sp_query) + result = _get_distinct_albums( + config, session, requests_session, query) elif field == 'date': - result = _get_distinct_dates(config, session, sp_query) + result = _get_distinct_dates( + config, session, requests_session, query) else: result = set() return result - {None} -def _get_distinct_artists(config, session, sp_query): - logger.debug('Getting distinct artists: %s', sp_query) - if sp_query: - sp_search = _get_sp_search(config, session, sp_query, artist=True) - if sp_search is None: - return set() - return {artist.name for artist in sp_search.artists} +def _get_distinct_artists(config, session, requests_session, query): + logger.debug('Getting distinct artists: %s', query) + if query: + search_result = _get_search( + config, session, requests_session, query, artist=True) + return {artist.name for artist in search_result.artists} else: return { artist.name @@ -45,17 +46,17 @@ def _get_distinct_artists(config, session, sp_query): for artist in track.artists} -def _get_distinct_albumartists(config, session, sp_query): +def _get_distinct_albumartists(config, session, requests_session, query): logger.debug( - 'Getting distinct albumartists: %s', sp_query) - if sp_query: - sp_search = _get_sp_search(config, session, sp_query, album=True) - if sp_search is None: - return set() + 'Getting distinct albumartists: %s', query) + if query: + search_result = _get_search( + config, session, requests_session, query, album=True) return { - album.artist.name - for album in sp_search.albums - if album.artist} + artist.name + for album in search_result.albums + for artist in album.artists + if album.artists} else: return { track.album.artist.name @@ -63,13 +64,12 @@ def _get_distinct_albumartists(config, session, sp_query): if track.album and track.album.artist} -def _get_distinct_albums(config, session, sp_query): - logger.debug('Getting distinct albums: %s', sp_query) - if sp_query: - sp_search = _get_sp_search(config, session, sp_query, album=True) - if sp_search is None: - return set() - return {album.name for album in sp_search.albums} +def _get_distinct_albums(config, session, requests_session, query): + logger.debug('Getting distinct albums: %s', query) + if query: + search_result = _get_search( + config, session, requests_session, query, album=True) + return {album.name for album in search_result.albums} else: return { track.album.name @@ -77,16 +77,15 @@ def _get_distinct_albums(config, session, sp_query): if track.album} -def _get_distinct_dates(config, session, sp_query): - logger.debug('Getting distinct album years: %s', sp_query) - if sp_query: - sp_search = _get_sp_search(config, session, sp_query, album=True) - if sp_search is None: - return set() +def _get_distinct_dates(config, session, requests_session, query): + logger.debug('Getting distinct album years: %s', query) + if query: + search_result = _get_search( + config, session, requests_session, query, album=True) return { - '%s' % album.year - for album in sp_search.albums - if album.year not in (None, 0)} + album.date + for album in search_result.albums + if album.date not in (None, '0')} else: return { '%s' % track.album.year @@ -94,20 +93,20 @@ def _get_distinct_dates(config, session, sp_query): if track.album and track.album.year not in (None, 0)} -def _get_sp_search( - config, session, sp_query, album=False, artist=False, track=False): +def _get_search( + config, session, requests_session, query, + album=False, artist=False, track=False): - if session.connection.state is not spotify.ConnectionState.LOGGED_IN: - logger.info('Spotify search aborted: Spotify is offline') - return None + types = [] + if album: + types.append('album') + if artist: + types.append('artist') + if track: + types.append('track') - sp_search = session.search( - sp_query, - album_count=config['search_album_count'] if album else 0, - artist_count=config['search_artist_count'] if artist else 0, - track_count=config['search_track_count'] if track else 0) - sp_search.load() - return sp_search + return search.search( + config, session, requests_session, query, types=types) def _get_playlist_tracks(config, session): diff --git a/mopidy_spotify/library.py b/mopidy_spotify/library.py index 3e95580b..1739c0bd 100644 --- a/mopidy_spotify/library.py +++ b/mopidy_spotify/library.py @@ -28,7 +28,8 @@ def browse(self, uri): def get_distinct(self, field, query=None): return distinct.get_distinct( - self._config, self._backend._session, field, query) + self._config, self._backend._session, self._requests_session, + field, query) def get_images(self, uris): return images.get_images(self._requests_session, uris) @@ -38,5 +39,5 @@ def lookup(self, uri): def search(self, query=None, uris=None, exact=False): return search.search( - self._config, self._backend._session, + self._config, self._backend._session, self._requests_session, query, uris, exact) diff --git a/mopidy_spotify/search.py b/mopidy_spotify/search.py index d4f732eb..5a1b0408 100644 --- a/mopidy_spotify/search.py +++ b/mopidy_spotify/search.py @@ -5,15 +5,21 @@ from mopidy import models +import requests + import spotify from mopidy_spotify import lookup, translator +_API_BASE_URI = 'https://api.spotify.com/v1/search' +_SEARCH_TYPES = ['album', 'artist', 'track'] + logger = logging.getLogger(__name__) -def search(config, session, query=None, uris=None, exact=False): +def search(config, session, requests_session, + query=None, uris=None, exact=False, types=_SEARCH_TYPES): # TODO Respect `uris` argument # TODO Support `exact` search @@ -36,19 +42,48 @@ def search(config, session, query=None, uris=None, exact=False): logger.info('Spotify search aborted: Spotify is offline') return models.SearchResult(uri=uri) - sp_search = session.search( - sp_query, - album_count=config['search_album_count'], - artist_count=config['search_artist_count'], - track_count=config['search_track_count']) - sp_search.load() + search_count = max( + config['search_album_count'], + config['search_artist_count'], + config['search_track_count']) + + if search_count > 50: + logger.warn( + 'Spotify currently allows maximum 50 search results of each type. ' + 'Please set the config values spotify/search_album_count, ' + 'spotify/search_artist_count and spotify/search_track_count ' + 'to at most 50.') + search_count = 50 + + try: + response = requests_session.get(_API_BASE_URI, params={ + 'q': sp_query, + 'limit': search_count, + 'type': ','.join(types)}) + except requests.RequestException as exc: + logger.debug('Fetching %s failed: %s', uri, exc) + return models.SearchResult(uri=uri) + + try: + result = response.json() + except ValueError as exc: + logger.debug('JSON decoding failed for %s: %s', uri, exc) + return models.SearchResult(uri=uri) albums = [ - translator.to_album(sp_album) for sp_album in sp_search.albums] + translator.web_to_album(web_album) for web_album in + result['albums']['items'][:config['search_album_count']] + ] if 'albums' in result else [] + artists = [ - translator.to_artist(sp_artist) for sp_artist in sp_search.artists] + translator.web_to_artist(web_artist) for web_artist in + result['artists']['items'][:config['search_artist_count']] + ] if 'artists' in result else [] + tracks = [ - translator.to_track(sp_track) for sp_track in sp_search.tracks] + translator.web_to_track(web_track) for web_track in + result['tracks']['items'][:config['search_track_count']] + ] if 'tracks' in result else [] return models.SearchResult( uri=uri, albums=albums, artists=artists, tracks=tracks) diff --git a/mopidy_spotify/translator.py b/mopidy_spotify/translator.py index f0ef8361..cc8db8fc 100644 --- a/mopidy_spotify/translator.py +++ b/mopidy_spotify/translator.py @@ -232,3 +232,26 @@ def _transform_year(date): logger.debug( 'Excluded year from search query: ' 'Cannot parse date "%s"', date) + + +def web_to_artist(web_artist): + return models.Artist(uri=web_artist['uri'], name=web_artist['name']) + + +def web_to_album(web_album): + return models.Album(uri=web_album['uri'], name=web_album['name']) + + +def web_to_track(web_track): + artists = [ + web_to_artist(web_artist) for web_artist in web_track['artists']] + album = web_to_album(web_track['album']) + + return models.Track( + uri=web_track['uri'], + name=web_track['name'], + artists=artists, + album=album, + length=web_track['duration_ms'], + disc_no=web_track['disc_number'], + track_no=web_track['track_number']) diff --git a/setup.py b/setup.py index a1b26f9d..c02b8d8b 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ def get_version(filename): zip_safe=False, include_package_data=True, install_requires=[ - 'Mopidy >= 1.1', + 'Mopidy >= 1.1, < 2', 'Pykka >= 1.1', 'pyspotify >= 2.0.5', 'requests >= 2.0', diff --git a/tests/conftest.py b/tests/conftest.py index 0719ce51..31688ce5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ import mock -from mopidy import backend as backend_api +from mopidy import backend as backend_api, models import pytest @@ -272,13 +272,80 @@ def sp_playlist_container_mock(): @pytest.fixture -def sp_search_mock(sp_album_mock, sp_artist_mock, sp_track_mock): - sp_search = mock.Mock(spec=spotify.Search) - sp_search.is_loaded = True - sp_search.albums = [sp_album_mock] - sp_search.artists = [sp_artist_mock] - sp_search.tracks = [sp_track_mock, sp_track_mock] - return sp_search +def web_search_mock( + web_album_mock, web_artist_mock, web_track_mock): + return { + 'albums': { + 'items': [web_album_mock] + }, + 'artists': { + 'items': [web_artist_mock] + }, + 'tracks': { + 'items': [web_track_mock, web_track_mock] + } + } + + +@pytest.fixture +def web_search_mock_large( + web_album_mock, web_artist_mock, web_track_mock): + return { + 'albums': { + 'items': [web_album_mock] * 10 + }, + 'artists': { + 'items': [web_artist_mock] * 10 + }, + 'tracks': { + 'items': [web_track_mock] * 10 + } + } + + +@pytest.fixture +def web_artist_mock(): + return { + 'name': 'ABBA', + 'uri': 'spotify:artist:abba' + } + + +@pytest.fixture +def web_album_mock(): + return { + 'name': 'DEF 456', + 'uri': 'spotify:album:def' + } + + +@pytest.fixture +def web_track_mock(web_artist_mock, web_album_mock): + return { + 'album': web_album_mock, + 'artists': [web_artist_mock], + 'disc_number': 1, + 'duration_ms': 174300, + 'name': 'ABC 123', + 'track_number': 7, + 'uri': 'spotify:track:abc', + } + + +@pytest.fixture +def mopidy_artist_mock(): + return models.Artist( + name='ABBA', + uri='spotify:artist:abba') + + +@pytest.fixture +def mopidy_album_mock(mopidy_artist_mock): + return models.Album( + artists=[mopidy_artist_mock], + date='2001', + name='DEF 456', + uri='spotify:album:def') @pytest.fixture diff --git a/tests/test_distinct.py b/tests/test_distinct.py index bd64e6d0..7aa452c9 100644 --- a/tests/test_distinct.py +++ b/tests/test_distinct.py @@ -1,8 +1,12 @@ from __future__ import unicode_literals +import mock + +from mopidy import models + import pytest -import spotify +from mopidy_spotify import distinct, search @pytest.fixture @@ -19,18 +23,14 @@ def session_mock_with_playlists( return session_mock -@pytest.fixture -def session_mock_with_search( - session_mock, - sp_album_mock, sp_unloaded_album_mock, - sp_artist_mock, sp_unloaded_artist_mock): - - session_mock.connection.state = spotify.ConnectionState.LOGGED_IN - session_mock.search.return_value.albums = [ - sp_album_mock, sp_unloaded_album_mock] - session_mock.search.return_value.artists = [ - sp_artist_mock, sp_unloaded_artist_mock] - return session_mock +@pytest.yield_fixture +def search_mock(mopidy_album_mock, mopidy_artist_mock): + patcher = mock.patch.object(distinct, 'search', spec=search) + search_mock = patcher.start() + search_mock.search.return_value = models.SearchResult( + albums=[mopidy_album_mock], artists=[mopidy_artist_mock]) + yield search_mock + patcher.stop() @pytest.mark.parametrize('field', [ @@ -69,49 +69,36 @@ def test_get_distinct_without_query_returns_nothing_when_playlists_disabled( assert provider.get_distinct(field) == set() -@pytest.mark.parametrize('field,query,expected,search_args,search_kwargs', [ +@pytest.mark.parametrize('field,query,expected,types', [ ( 'artist', {'album': ['Foo']}, {'ABBA'}, - ('album:"Foo"',), - dict(album_count=0, artist_count=10, track_count=0), + ['artist'], ), ( 'albumartist', {'album': ['Foo']}, {'ABBA'}, - ('album:"Foo"',), - dict(album_count=20, artist_count=0, track_count=0), + ['album'], ), ( 'album', {'artist': ['Bar']}, {'DEF 456'}, - ('artist:"Bar"',), - dict(album_count=20, artist_count=0, track_count=0), + ['album'], ), ( 'date', {'artist': ['Bar']}, {'2001'}, - ('artist:"Bar"',), - dict(album_count=20, artist_count=0, track_count=0), + ['album'], ), ]) def test_get_distinct_with_query( - session_mock_with_search, provider, - field, query, expected, search_args, search_kwargs): + search_mock, provider, config, session_mock, + field, query, expected, types): assert provider.get_distinct(field, query) == expected - session_mock_with_search.search.assert_called_once_with( - *search_args, **search_kwargs) - - -def test_get_distinct_with_query_when_offline( - session_mock_with_search, provider): - - session_mock_with_search.connection.state = spotify.ConnectionState.OFFLINE - - assert provider.get_distinct('artist', {'album': ['Foo']}) == set() - assert session_mock_with_search.search.return_value.load.call_count == 0 + search_mock.search.assert_called_once_with( + mock.ANY, mock.ANY, mock.ANY, query, types=types) diff --git a/tests/test_search.py b/tests/test_search.py index e34131e3..9da95929 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,9 +1,20 @@ from __future__ import unicode_literals +import json + +import re + from mopidy import models +import requests + +import responses + import spotify +import mopidy_spotify +from mopidy_spotify import search + def test_search_with_no_query_returns_nothing(provider, caplog): result = provider.search() @@ -65,15 +76,26 @@ def test_search_when_offline_returns_nothing(session_mock, provider, caplog): assert len(result.tracks) == 0 +@responses.activate def test_search_returns_albums_and_artists_and_tracks( - session_mock, sp_search_mock, provider, caplog): - session_mock.search.return_value = sp_search_mock + web_search_mock, provider, caplog): + responses.add( + responses.GET, 'https://api.spotify.com/v1/search', + body=json.dumps(web_search_mock)) result = provider.search({'any': ['ABBA']}) - session_mock.search.assert_called_once_with( - '"ABBA"', album_count=20, artist_count=10, track_count=50) - sp_search_mock.load.assert_called_once_with() + assert len(responses.calls) == 1 + + uri_parts = sorted(re.split('[?&]', responses.calls[0].request.url)) + assert (uri_parts == [ + 'https://api.spotify.com/v1/search', + 'limit=50', + 'q=%22ABBA%22', + 'type=album%2Cartist%2Ctrack']) + + assert responses.calls[0].request.headers['User-Agent'].startswith( + 'Mopidy-Spotify/%s' % mopidy_spotify.__version__) assert 'Searching Spotify for: "ABBA"' in caplog.text() @@ -90,6 +112,137 @@ def test_search_returns_albums_and_artists_and_tracks( assert result.tracks[0].uri == 'spotify:track:abc' +@responses.activate +def test_search_limits_number_of_results( + web_search_mock_large, provider, config): + config['spotify']['search_album_count'] = 4 + config['spotify']['search_artist_count'] = 5 + config['spotify']['search_track_count'] = 6 + + responses.add( + responses.GET, 'https://api.spotify.com/v1/search', + body=json.dumps(web_search_mock_large)) + + result = provider.search({'any': ['ABBA']}) + + assert len(result.albums) == 4 + assert len(result.artists) == 5 + assert len(result.tracks) == 6 + + +@responses.activate +def test_sets_api_limit_to_album_count_when_max( + web_search_mock_large, provider, config): + config['spotify']['search_album_count'] = 6 + config['spotify']['search_artist_count'] = 2 + config['spotify']['search_track_count'] = 2 + + responses.add( + responses.GET, 'https://api.spotify.com/v1/search', + body=json.dumps(web_search_mock_large)) + + result = provider.search({'any': ['ABBA']}) + + assert len(responses.calls) == 1 + + uri_parts = sorted(re.split('[?&]', responses.calls[0].request.url)) + assert (uri_parts == [ + 'https://api.spotify.com/v1/search', + 'limit=6', + 'q=%22ABBA%22', + 'type=album%2Cartist%2Ctrack']) + + assert len(result.albums) == 6 + + +@responses.activate +def test_sets_api_limit_to_artist_count_when_max( + web_search_mock_large, provider, config): + config['spotify']['search_album_count'] = 2 + config['spotify']['search_artist_count'] = 6 + config['spotify']['search_track_count'] = 2 + + responses.add( + responses.GET, 'https://api.spotify.com/v1/search', + body=json.dumps(web_search_mock_large)) + + result = provider.search({'any': ['ABBA']}) + + assert len(responses.calls) == 1 + + uri_parts = sorted(re.split('[?&]', responses.calls[0].request.url)) + assert (uri_parts == [ + 'https://api.spotify.com/v1/search', + 'limit=6', + 'q=%22ABBA%22', + 'type=album%2Cartist%2Ctrack']) + + assert len(result.artists) == 6 + + +@responses.activate +def test_sets_api_limit_to_track_count_when_max( + web_search_mock_large, provider, config): + config['spotify']['search_album_count'] = 2 + config['spotify']['search_artist_count'] = 2 + config['spotify']['search_track_count'] = 6 + + responses.add( + responses.GET, 'https://api.spotify.com/v1/search', + body=json.dumps(web_search_mock_large)) + + result = provider.search({'any': ['ABBA']}) + + assert len(responses.calls) == 1 + + uri_parts = sorted(re.split('[?&]', responses.calls[0].request.url)) + assert (uri_parts == [ + 'https://api.spotify.com/v1/search', + 'limit=6', + 'q=%22ABBA%22', + 'type=album%2Cartist%2Ctrack']) + + assert len(result.tracks) == 6 + + +@responses.activate +def test_sets_types_parameter( + web_search_mock_large, provider, config, session_mock): + responses.add( + responses.GET, 'https://api.spotify.com/v1/search', + body=json.dumps(web_search_mock_large)) + + search.search( + config['spotify'], session_mock, requests.Session(), + {'any': ['ABBA']}, types=['album', 'artist']) + + assert len(responses.calls) == 1 + + uri_parts = sorted(re.split('[?&]', responses.calls[0].request.url)) + assert (uri_parts == [ + 'https://api.spotify.com/v1/search', + 'limit=50', + 'q=%22ABBA%22', + 'type=album%2Cartist']) + + +@responses.activate +def test_handles_empty_response( + web_search_mock_large, provider): + responses.add( + responses.GET, 'https://api.spotify.com/v1/search', + body={}) + + result = provider.search({'any': ['ABBA']}) + + assert isinstance(result, models.SearchResult) + assert result.uri == 'spotify:search:%22ABBA%22' + + assert len(result.albums) == 0 + assert len(result.artists) == 0 + assert len(result.tracks) == 0 + + def test_exact_is_ignored(session_mock, sp_track_mock, provider): session_mock.get_link.return_value = sp_track_mock.link diff --git a/tests/test_translator.py b/tests/test_translator.py index 523b3047..e0b972bc 100644 --- a/tests/test_translator.py +++ b/tests/test_translator.py @@ -428,3 +428,38 @@ def test_anything_can_be_combined(self): assert 'album:"Greatest Hits"' in query assert 'track:"Dancing Queen"' in query assert 'year:1970' in query + + +class TestWebToArtist(object): + + def test_successful_translation(self, web_artist_mock): + artist = translator.web_to_artist(web_artist_mock) + + assert artist.uri == 'spotify:artist:abba' + assert artist.name == 'ABBA' + + +class TestWebToAlbum(object): + + def test_successful_translation(self, web_album_mock): + album = translator.web_to_album(web_album_mock) + + assert album.uri == 'spotify:album:def' + assert album.name == 'DEF 456' + + +class TestWebToTrack(object): + + def test_successful_translation(self, web_track_mock): + track = translator.web_to_track(web_track_mock) + + assert track.uri == 'spotify:track:abc' + assert track.name == 'ABC 123' + assert list(track.artists) == [ + models.Artist(uri='spotify:artist:abba', name='ABBA')] + assert track.album == models.Album( + uri='spotify:album:def', + name='DEF 456') + assert track.track_no == 7 + assert track.disc_no == 1 + assert track.length == 174300