diff --git a/docs/changelog.rst b/docs/changelog.rst index 01476404..86566e99 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -25,6 +25,9 @@ v0.20.0 (UNRELEASED) :meth:`mopidy.core.Playback.stop`. It was a leaky internal abstraction, which was never intended to be used externally. +- Add :meth:`mopidy.core.LibraryController.get_images` for looking up images + for any URI backends know about. (Fixes :issue:`973`) + **Commands** - Make the ``mopidy`` command print a friendly error message if the diff --git a/mopidy/backend/__init__.py b/mopidy/backend/__init__.py index 45268f9f..3dc3a28c 100644 --- a/mopidy/backend/__init__.py +++ b/mopidy/backend/__init__.py @@ -92,6 +92,14 @@ class LibraryProvider(object): """ return [] + def get_images(self, uris): + """ + See :meth:`mopidy.core.LibraryController.get_images`. + + *MAY be implemented by subclass.* + """ + return {} + # TODO: replace with search(query, exact=True, ...) def find_exact(self, query=None, uris=None): """ diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 2ada23d4..822836a6 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -72,6 +72,30 @@ class LibraryController(object): return [] return backend.library.browse(uri).get() + def get_images(self, uris): + """Lookup the images for the given URIs + + Backends can use this to return image URIs for any URI they know about + be it tracks, albums, playlists... The lookup result is a dictionary + mapping the provided URIs to lists of images. + + Unknown URIs or URIs the corresponding backend couldn't find anything + for will simply return an empty list for that URI. + + :param list uris: list of URIs to find images for + :rtype: {uri: tuple of :class:`mopidy.models.Image`} + """ + futures = [ + backend.library.get_images(backend_uris) + for (backend, backend_uris) + in self._get_backends_to_uris(uris).items() if backend_uris] + + results = {uri: tuple() for uri in uris} + for r in pykka.get_all(futures): + for uri, images in r.items(): + results[uri] += tuple(images) + return results + def find_exact(self, query=None, uris=None, **kwargs): """ Search the library for tracks where ``field`` is ``values``. diff --git a/mopidy/models.py b/mopidy/models.py index 1818edfd..4d6ed27d 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -212,6 +212,23 @@ class Ref(ImmutableObject): return cls(**kwargs) +class Image(ImmutableObject): + """ + :param string uri: URI of the image + :param int width: Optional width of image or :class:`None` + :param int height: Optional height of image or :class:`None` + """ + + #: The image URI. Read-only. + uri = None + + #: Optional width of the image or :class:`None`. Read-only. + width = None + + #: Optional height of the image or :class:`None`. Read-only. + height = None + + class Artist(ImmutableObject): """ :param uri: artist URI diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 9bd3b244..ccf1b349 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -5,7 +5,7 @@ import unittest import mock from mopidy import backend, core -from mopidy.models import Ref, SearchResult, Track +from mopidy.models import Image, Ref, SearchResult, Track class CoreLibraryTest(unittest.TestCase): @@ -14,6 +14,8 @@ class CoreLibraryTest(unittest.TestCase): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.library1 = mock.Mock(spec=backend.LibraryProvider) + self.library1.get_images().get.return_value = {} + self.library1.get_images.reset_mock() self.library1.root_directory.get.return_value = dummy1_root self.backend1.library = self.library1 @@ -21,6 +23,8 @@ class CoreLibraryTest(unittest.TestCase): self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2', 'du2'] self.library2 = mock.Mock(spec=backend.LibraryProvider) + self.library2.get_images().get.return_value = {} + self.library2.get_images.reset_mock() self.library2.root_directory.get.return_value = dummy2_root self.backend2.library = self.library2 @@ -33,6 +37,50 @@ class CoreLibraryTest(unittest.TestCase): self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) + def test_get_images_returns_empty_dict_for_no_uris(self): + self.assertEqual({}, self.core.library.get_images([])) + + def test_get_images_returns_empty_result_for_unknown_uri(self): + result = self.core.library.get_images(['dummy4:track']) + self.assertEqual({'dummy4:track': tuple()}, result) + + def test_get_images_returns_empty_result_for_library_less_uri(self): + result = self.core.library.get_images(['dummy3:track']) + self.assertEqual({'dummy3:track': tuple()}, result) + + def test_get_images_maps_uri_to_backend(self): + self.core.library.get_images(['dummy1:track']) + self.library1.get_images.assert_called_once_with(['dummy1:track']) + self.library2.get_images.assert_not_called() + + def test_get_images_maps_uri_to_backends(self): + self.core.library.get_images(['dummy1:track', 'dummy2:track']) + self.library1.get_images.assert_called_once_with(['dummy1:track']) + self.library2.get_images.assert_called_once_with(['dummy2:track']) + + def test_get_images_returns_images(self): + self.library1.get_images().get.return_value = { + 'dummy1:track': [Image(uri='uri')]} + self.library1.get_images.reset_mock() + + result = self.core.library.get_images(['dummy1:track']) + self.assertEqual({'dummy1:track': (Image(uri='uri'),)}, result) + + def test_get_images_merges_results(self): + self.library1.get_images().get.return_value = { + 'dummy1:track': [Image(uri='uri1')]} + self.library1.get_images.reset_mock() + self.library2.get_images().get.return_value = { + 'dummy2:track': [Image(uri='uri2')]} + self.library2.get_images.reset_mock() + + result = self.core.library.get_images( + ['dummy1:track', 'dummy2:track', 'dummy3:track', 'dummy4:track']) + expected = {'dummy1:track': (Image(uri='uri1'),), + 'dummy2:track': (Image(uri='uri2'),), + 'dummy3:track': tuple(), 'dummy4:track': tuple()} + self.assertEqual(expected, result) + def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): result = self.core.library.browse(None) diff --git a/tests/test_models.py b/tests/test_models.py index ed1586da..e7aec877 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,8 +4,8 @@ import json import unittest from mopidy.models import ( - Album, Artist, ModelJSONEncoder, Playlist, Ref, SearchResult, TlTrack, - Track, model_json_decoder) + Album, Artist, Image, ModelJSONEncoder, Playlist, Ref, SearchResult, + TlTrack, Track, model_json_decoder) class GenericCopyTest(unittest.TestCase): @@ -74,7 +74,7 @@ class RefTest(unittest.TestCase): def test_invalid_kwarg(self): with self.assertRaises(TypeError): - SearchResult(foo='baz') + Ref(foo='baz') def test_repr_without_results(self): self.assertEquals( @@ -130,6 +130,31 @@ class RefTest(unittest.TestCase): self.assertEqual(ref.type, Ref.TRACK) +class ImageTest(unittest.TestCase): + def test_uri(self): + uri = 'an_uri' + image = Image(uri=uri) + self.assertEqual(image.uri, uri) + with self.assertRaises(AttributeError): + image.uri = None + + def test_width(self): + image = Image(width=100) + self.assertEqual(image.width, 100) + with self.assertRaises(AttributeError): + image.width = None + + def test_height(self): + image = Image(height=100) + self.assertEqual(image.height, 100) + with self.assertRaises(AttributeError): + image.height = None + + def test_invalid_kwarg(self): + with self.assertRaises(TypeError): + Image(foo='baz') + + class ArtistTest(unittest.TestCase): def test_uri(self): uri = 'an_uri'