Merge pull request #626 from jodal/feature/library-browse
Library browsing support in backend and core API and MPD lsinfo command
This commit is contained in:
commit
cb97def432
@ -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):
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<library>[^/]+)(?P<path>.*)', 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``.
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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$')
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user