diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 6b980f06..5a8a23bb 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -50,9 +50,27 @@ class BaseLibraryProvider(object): pykka_traversable = True + root_directory_name = None + """ + Name of the library's root directory in Mopidy's virtual file system. + + *MUST be set by any class that implements :meth:`browse`.* + """ + def __init__(self, backend): self.backend = backend + def browse(self, path): + """ + See :meth:`mopidy.core.LibraryController.browse`. + + If you implement this method, make sure to also set + :attr:`root_directory_name`. + + *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/backends/dummy.py b/mopidy/backends/dummy.py index 65477ea2..b3be0889 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -38,12 +38,18 @@ class DummyBackend(pykka.ThreadingActor, base.Backend): class DummyLibraryProvider(base.BaseLibraryProvider): + root_directory_name = 'dummy' + def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] + self.dummy_browse_result = [] self.dummy_find_exact_result = SearchResult() self.dummy_search_result = SearchResult() + def browse(self, path): + return self.dummy_browse_result + 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 c83bec20..2d0478ab 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -10,6 +10,8 @@ logger = logging.getLogger(__name__) class LocalLibraryProvider(base.BaseLibraryProvider): """Proxy library that delegates work to our active local library.""" + root_directory_name = 'local' + def __init__(self, backend, library): super(LocalLibraryProvider, self).__init__(backend) self._library = library diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index dba8d76d..26350f16 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -88,6 +88,7 @@ class Backends(list): super(Backends, self).__init__(backends) self.with_library = collections.OrderedDict() + self.with_browsable_library = collections.OrderedDict() self.with_playback = collections.OrderedDict() self.with_playlists = collections.OrderedDict() @@ -112,3 +113,8 @@ class Backends(list): self.with_playback[scheme] = backend if has_playlists: self.with_playlists[scheme] = backend + + if has_library: + root_dir_name = backend.library.root_directory_name.get() + if root_dir_name is not None: + self.with_browsable_library[root_dir_name] = backend diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 2e73e0db..ac1aad14 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,10 +1,13 @@ from __future__ import unicode_literals import collections +import re import urlparse import pykka +from mopidy.models import Ref + class LibraryController(object): pykka_traversable = True @@ -29,6 +32,63 @@ class LibraryController(object): (b, None) for b in self.backends.with_library.values()]) return backends_to_uris + def browse(self, path): + """ + Browse directories and tracks at the given ``path``. + + ``path`` is a string that always starts with "/". It points to a + directory in Mopidy's virtual file system. + + Returns a list of :class:`mopidy.models.Ref` objects for the + directories and tracks at the given ``path``. + + The :class:`~mopidy.models.Ref` objects representing tracks keeps the + track's original URI. A matching pair of objects can look like this:: + + Track(uri='dummy:/foo.mp3', name='foo', artists=..., album=...) + Ref.track(uri='dummy:/foo.mp3', name='foo') + + The :class:`~mopidy.models.Ref` objects representing directories has + plain paths, not including any URI schema. For example, the dummy + library's ``/bar`` directory is returned like this:: + + Ref.directory(uri='/dummy/bar', name='bar') + + Note to backend implementors: The ``/dummy`` part of the URI is added + by Mopidy core, not the individual backends. + + :param path: path to browse + :type path: string + :rtype: list of :class:`mopidy.models.Ref` + """ + if not path.startswith('/'): + return [] + + if path == '/': + return [ + Ref.directory(uri='/%s' % name, name=name) + for name in self.backends.with_browsable_library.keys()] + + groups = re.match('/(?P[^/]+)(?P.*)', path).groupdict() + library_name = groups['library'] + backend_path = groups['path'] + if not backend_path.startswith('/'): + backend_path = '/%s' % backend_path + + backend = self.backends.with_browsable_library.get(library_name, None) + if not backend: + return [] + + refs = backend.library.browse(backend_path).get() + result = [] + for ref in refs: + if ref.type == Ref.DIRECTORY: + result.append( + ref.copy(uri='/%s%s' % (library_name, ref.uri))) + else: + result.append(ref) + return result + 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 0e40a8f6..53083eb7 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -160,6 +160,51 @@ class Ref(ImmutableObject): #: "directory". Read-only. type = None + #: Constant used for comparision with the :attr:`type` field. + ALBUM = 'album' + + #: Constant used for comparision with the :attr:`type` field. + ARTIST = 'artist' + + #: Constant used for comparision with the :attr:`type` field. + DIRECTORY = 'directory' + + #: Constant used for comparision with the :attr:`type` field. + PLAYLIST = 'playlist' + + #: Constant used for comparision with the :attr:`type` field. + TRACK = 'track' + + @classmethod + def album(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`ALBUM`.""" + kwargs['type'] = Ref.ALBUM + return cls(**kwargs) + + @classmethod + def artist(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`ARTIST`.""" + kwargs['type'] = Ref.ARTIST + return cls(**kwargs) + + @classmethod + def directory(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`DIRECTORY`.""" + kwargs['type'] = Ref.DIRECTORY + return cls(**kwargs) + + @classmethod + def playlist(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`PLAYLIST`.""" + kwargs['type'] = Ref.PLAYLIST + return cls(**kwargs) + + @classmethod + def track(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`TRACK`.""" + kwargs['type'] = Ref.TRACK + return cls(**kwargs) + class Artist(ImmutableObject): """ diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 6e7e3956..b31d295b 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -4,7 +4,7 @@ import functools import itertools import re -from mopidy.models import Track +from mopidy.models import Ref, Track from mopidy.mpd import translator from mopidy.mpd.exceptions import MpdArgError, MpdNotImplemented from mopidy.mpd.protocol import handle_request, stored_playlists @@ -452,9 +452,23 @@ def lsinfo(context, uri=None): directories located at the root level, for both ``lsinfo``, ``lsinfo ""``, and ``lsinfo "/"``. """ + result = [] if uri is None or uri == '/' or uri == '': - return stored_playlists.listplaylists(context) - raise MpdNotImplemented # TODO + result.extend(stored_playlists.listplaylists(context)) + uri = '/' + if not uri.startswith('/'): + uri = '/%s' % uri + for ref in context.core.library.browse(uri).get(): + if ref.type == Ref.DIRECTORY: + assert ref.uri.startswith('/'), ( + 'Directory URIs must start with /: %r' % ref) + result.append(('directory', ref.uri[1:])) + elif ref.type == Ref.TRACK: + # TODO Lookup tracks in batch for better performance + tracks = context.core.library.lookup(ref.uri).get() + if tracks: + result.extend(translator.track_to_mpd_format(tracks[0])) + return result @handle_request(r'rescan$') diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 4ca5abf0..b9292f1f 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -114,6 +114,10 @@ class LocalLibraryProviderTest(unittest.TestCase): finally: shutil.rmtree(tmpdir) + @unittest.SkipTest + def test_browse(self): + pass # TODO + def test_lookup(self): tracks = self.library.lookup(self.tracks[0].uri) self.assertEqual(tracks, self.tracks[0:1]) diff --git a/tests/core/library_test.py b/tests/core/library_test.py index f4028d2f..44c5e3f1 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -5,7 +5,7 @@ import unittest from mopidy.backends import base from mopidy.core import Core -from mopidy.models import SearchResult, Track +from mopidy.models import Ref, SearchResult, Track class CoreLibraryTest(unittest.TestCase): @@ -13,11 +13,13 @@ class CoreLibraryTest(unittest.TestCase): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.library1 = mock.Mock(spec=base.BaseLibraryProvider) + self.library1.root_directory_name.get.return_value = 'dummy1' self.backend1.library = self.library1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] self.library2 = mock.Mock(spec=base.BaseLibraryProvider) + self.library2.root_directory_name.get.return_value = 'dummy2' self.backend2.library = self.library2 # A backend without the optional library provider @@ -28,6 +30,71 @@ class CoreLibraryTest(unittest.TestCase): self.core = Core(audio=None, backends=[ self.backend1, self.backend2, self.backend3]) + def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): + result = self.core.library.browse('/') + + self.assertEqual(result, [ + Ref.directory(uri='/dummy1', name='dummy1'), + Ref.directory(uri='/dummy2', name='dummy2'), + ]) + self.assertFalse(self.library1.browse.called) + self.assertFalse(self.library2.browse.called) + self.assertFalse(self.backend3.library.browse.called) + + def test_browse_empty_string_returns_nothing(self): + result = self.core.library.browse('') + + self.assertEqual(result, []) + self.assertFalse(self.library1.browse.called) + self.assertFalse(self.library2.browse.called) + + def test_browse_dummy1_selects_dummy1_backend(self): + self.library1.browse().get.return_value = [ + Ref.directory(uri='/foo/bar', name='bar'), + Ref.track(uri='dummy1:/foo/baz.mp3', name='Baz'), + ] + self.library1.browse.reset_mock() + + self.core.library.browse('/dummy1/foo') + + self.assertEqual(self.library1.browse.call_count, 1) + self.assertEqual(self.library2.browse.call_count, 0) + self.library1.browse.assert_called_with('/foo') + + def test_browse_dummy2_selects_dummy2_backend(self): + self.library2.browse().get.return_value = [ + Ref.directory(uri='/bar/quux', name='quux'), + Ref.track(uri='dummy2:/foo/baz.mp3', name='Baz'), + ] + self.library2.browse.reset_mock() + + self.core.library.browse('/dummy2/bar') + + self.assertEqual(self.library1.browse.call_count, 0) + self.assertEqual(self.library2.browse.call_count, 1) + self.library2.browse.assert_called_with('/bar') + + def test_browse_dummy3_returns_nothing(self): + result = self.core.library.browse('/dummy3') + + self.assertEqual(result, []) + self.assertEqual(self.library1.browse.call_count, 0) + self.assertEqual(self.library2.browse.call_count, 0) + + def test_browse_dir_returns_subdirs_and_tracks(self): + self.library1.browse().get.return_value = [ + Ref.directory(uri='/foo/bar', name='bar'), + Ref.track(uri='dummy1:/foo/baz.mp3', name='Baz'), + ] + self.library1.browse.reset_mock() + + result = self.core.library.browse('/dummy1/foo') + + self.assertEqual(result, [ + Ref.directory(uri='/dummy1/foo/bar', name='bar'), + Ref.track(uri='dummy1:/foo/baz.mp3', name='Baz'), + ]) + def test_lookup_selects_dummy1_backend(self): self.core.library.lookup('dummy1:a') diff --git a/tests/models_test.py b/tests/models_test.py index 50faf89e..02cba8f4 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -87,6 +87,43 @@ class RefTest(unittest.TestCase): ref2 = json.loads(serialized, object_hook=model_json_decoder) self.assertEqual(ref1, ref2) + def test_type_constants(self): + self.assertEqual(Ref.ALBUM, 'album') + self.assertEqual(Ref.ARTIST, 'artist') + self.assertEqual(Ref.DIRECTORY, 'directory') + self.assertEqual(Ref.PLAYLIST, 'playlist') + self.assertEqual(Ref.TRACK, 'track') + + def test_album_constructor(self): + ref = Ref.album(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.ALBUM) + + def test_artist_constructor(self): + ref = Ref.artist(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.ARTIST) + + def test_directory_constructor(self): + ref = Ref.directory(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.DIRECTORY) + + def test_playlist_constructor(self): + ref = Ref.playlist(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.PLAYLIST) + + def test_track_constructor(self): + ref = Ref.track(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.TRACK) + class ArtistTest(unittest.TestCase): def test_uri(self): diff --git a/tests/mpd/protocol/music_db_test.py b/tests/mpd/protocol/music_db_test.py index 09db53ae..25feab27 100644 --- a/tests/mpd/protocol/music_db_test.py +++ b/tests/mpd/protocol/music_db_test.py @@ -1,9 +1,10 @@ from __future__ import unicode_literals +import datetime import unittest from mopidy.mpd.protocol import music_db -from mopidy.models import Album, Artist, SearchResult, Track +from mopidy.models import Album, Artist, Playlist, Ref, SearchResult, Track from tests.mpd import protocol @@ -137,20 +138,76 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.sendRequest('listallinfo "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {} Not implemented') - def test_lsinfo_without_path_returns_same_as_listplaylists(self): - lsinfo_response = self.sendRequest('lsinfo') - listplaylists_response = self.sendRequest('listplaylists') - self.assertEqual(lsinfo_response, listplaylists_response) + def test_lsinfo_without_path_returns_same_as_for_root(self): + last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + self.backend.playlists.playlists = [ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] - def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): - lsinfo_response = self.sendRequest('lsinfo ""') - listplaylists_response = self.sendRequest('listplaylists') - self.assertEqual(lsinfo_response, listplaylists_response) + response1 = self.sendRequest('lsinfo') + response2 = self.sendRequest('lsinfo "/"') + self.assertEqual(response1, response2) - def test_lsinfo_for_root_returns_same_as_listplaylists(self): - lsinfo_response = self.sendRequest('lsinfo "/"') - listplaylists_response = self.sendRequest('listplaylists') - self.assertEqual(lsinfo_response, listplaylists_response) + def test_lsinfo_with_empty_path_returns_same_as_for_root(self): + last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + self.backend.playlists.playlists = [ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + + response1 = self.sendRequest('lsinfo ""') + response2 = self.sendRequest('lsinfo "/"') + self.assertEqual(response1, response2) + + def test_lsinfo_for_root_includes_playlists(self): + last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + self.backend.playlists.playlists = [ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + + self.sendRequest('lsinfo "/"') + self.assertInResponse('playlist: a') + # Date without microseconds and with time zone information + self.assertInResponse('Last-Modified: 2001-03-17T13:41:17Z') + self.assertInResponse('OK') + + def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self): + self.backend.library.dummy_browse_result = [ + Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='/foo', name='foo'), + ] + + self.sendRequest('lsinfo "/"') + self.assertInResponse('directory: dummy') + self.assertInResponse('OK') + + def test_lsinfo_for_dir_with_and_without_leading_slash_is_the_same(self): + self.backend.library.dummy_browse_result = [ + Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='/foo', name='foo'), + ] + + response1 = self.sendRequest('lsinfo "dummy"') + response2 = self.sendRequest('lsinfo "/dummy"') + self.assertEqual(response1, response2) + + def test_lsinfo_for_dir_includes_tracks(self): + self.backend.library.dummy_library = [ + Track(uri='dummy:/a', name='a'), + ] + self.backend.library.dummy_browse_result = [ + Ref.track(uri='dummy:/a', name='a'), + ] + + self.sendRequest('lsinfo "/dummy"') + self.assertInResponse('file: dummy:/a') + self.assertInResponse('Title: a') + self.assertInResponse('OK') + + def test_lsinfo_for_dir_includes_subdirs(self): + self.backend.library.dummy_browse_result = [ + Ref.directory(uri='/foo', name='foo'), + ] + + self.sendRequest('lsinfo "/dummy"') + self.assertInResponse('directory: dummy/foo') + self.assertInResponse('OK') def test_update_without_uri(self): self.sendRequest('update')