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:
Thomas Adamcik 2014-01-09 13:20:40 -08:00
commit cb97def432
11 changed files with 333 additions and 17 deletions

View File

@ -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):
"""

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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``.

View File

@ -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):
"""

View File

@ -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$')

View File

@ -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])

View File

@ -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')

View File

@ -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):

View File

@ -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')