diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 0e25ec4f..cea21b10 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -83,8 +83,8 @@ class LibraryController(object): result = [] for ref in refs: if ref.type == Ref.DIRECTORY: - result.append( - ref.copy(uri='/%s%s' % (library_name, ref.uri))) + uri = '/'.join(['', library_name, ref.uri.lstrip('/')]) + result.append(ref.copy(uri=uri)) else: result.append(ref) return result diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 5a46283d..8b4a8b1f 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -64,6 +64,15 @@ class Library(object): def __init__(self, config): self._config = config + def browse(self, path): + """ + Browse directories and tracks at the given path. + + :param string path: path to browse or None for root. + :rtype: List of :class:`~mopidy.models.Ref` tracks and directories. + """ + raise NotImplementedError + def load(self): """ (Re)load any tracks stored in memory, if any, otherwise just return diff --git a/mopidy/local/json.py b/mopidy/local/json.py index f81d6915..9a7d02f8 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -1,14 +1,17 @@ from __future__ import absolute_import, unicode_literals +import collections import gzip import json import logging import os +import re +import sys import tempfile import mopidy from mopidy import local, models -from mopidy.local import search +from mopidy.local import search, translator logger = logging.getLogger(__name__) @@ -41,19 +44,67 @@ def write_library(json_file, data): os.remove(tmp.name) +class _BrowseCache(object): + encoding = sys.getfilesystemencoding() + splitpath_re = re.compile(r'([^/]+)') + + def __init__(self, uris): + """Create a dictionary tree for quick browsing. + + {'foo': {'bar': {None: [ref1, ref2]}, + 'baz': {}, + None: [ref3]}} + """ + self._root = collections.OrderedDict() + + for uri in uris: + path = translator.local_track_uri_to_path(uri, b'/') + parts = self.splitpath_re.findall(path.decode(self.encoding)) + filename = parts.pop() + node = self._root + for part in parts: + node = node.setdefault(part, collections.OrderedDict()) + ref = models.Ref.track(uri=uri, name=filename) + node.setdefault(None, []).append(ref) + + def lookup(self, path): + results = [] + node = self._root + + for part in self.splitpath_re.findall(path): + node = node.get(part, {}) + + for key, value in node.items(): + if key is not None: + uri = os.path.join(path, key) + results.append(models.Ref.directory(uri=uri, name=key)) + + # Get tracks afterwards to ensure ordering. + results.extend(node.get(None, [])) + + return results + + class JsonLibrary(local.Library): name = b'json' def __init__(self, config): self._tracks = {} + self._browse_cache = None self._media_dir = config['local']['media_dir'] self._json_file = os.path.join( config['local']['data_dir'], b'library.json.gz') + def browse(self, path): + if not self._browse_cache: + return [] + return self._browse_cache.lookup(path) + def load(self): logger.debug('Loading json library from %s', self._json_file) library = load_library(self._json_file) self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) + self._browse_cache = _BrowseCache(sorted(self._tracks)) return len(self._tracks) def lookup(self, uri): diff --git a/mopidy/local/library.py b/mopidy/local/library.py index 13d46979..dc068457 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -17,6 +17,11 @@ class LocalLibraryProvider(backend.LibraryProvider): self._library = library self.refresh() + def browse(self, path): + if not self._library: + return [] + return self._library.browse(path) + def refresh(self, uri=None): if not self._library: return 0 diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index b4e22a61..ab799fb4 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -20,12 +20,34 @@ def add(context, uri): - ``add ""`` should add all tracks in the library to the current playlist. """ - if not uri: + if not uri.strip('/'): return + tl_tracks = context.core.tracklist.add(uri=uri).get() - if not tl_tracks: + if tl_tracks: + return + + if not uri.startswith('/'): + uri = '/%s' % uri + + browse_futures = [context.core.library.browse(uri)] + lookup_futures = [] + while browse_futures: + for ref in browse_futures.pop().get(): + if ref.type == ref.DIRECTORY: + browse_futures.append(context.core.library.browse(ref.uri)) + else: + lookup_futures.append(context.core.library.lookup(ref.uri)) + + tracks = [] + for future in lookup_futures: + tracks.extend(future.get()) + + if not tracks: raise MpdNoExistError('directory or file not found', command='add') + context.core.tracklist.add(tracks=tracks) + @handle_request(r'addid\ "(?P[^"]*)"(\ "(?P\d+)")*$') def addid(context, uri, songpos=None): diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 0b8e3858..258340b9 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -33,12 +33,12 @@ class DummyLibraryProvider(backend.LibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] - self.dummy_browse_result = [] + self.dummy_browse_result = {} self.dummy_find_exact_result = SearchResult() self.dummy_search_result = SearchResult() def browse(self, path): - return self.dummy_browse_result + return self.dummy_browse_result.get(path, []) def find_exact(self, **query): return self.dummy_find_exact_result diff --git a/tests/local/json_test.py b/tests/local/json_test.py new file mode 100644 index 00000000..af606c05 --- /dev/null +++ b/tests/local/json_test.py @@ -0,0 +1,36 @@ +from __future__ import unicode_literals + +import unittest + +from mopidy.local import json +from mopidy.models import Ref + + +class BrowseCacheTest(unittest.TestCase): + def setUp(self): + self.uris = [b'local:track:foo/bar/song1', + b'local:track:foo/bar/song2', + b'local:track:foo/song3'] + self.cache = json._BrowseCache(self.uris) + + def test_lookup_root(self): + expected = [Ref.directory(uri='/foo', name='foo')] + self.assertEqual(expected, self.cache.lookup('/')) + + def test_lookup_foo(self): + expected = [Ref.directory(uri='/foo/bar', name='bar'), + Ref.track(uri=self.uris[2], name='song3')] + self.assertEqual(expected, self.cache.lookup('/foo')) + + def test_lookup_foo_bar(self): + expected = [Ref.track(uri=self.uris[0], name='song1'), + Ref.track(uri=self.uris[1], name='song2')] + self.assertEqual(expected, self.cache.lookup('/foo/bar')) + + def test_lookup_foo_baz(self): + self.assertEqual([], self.cache.lookup('/foo/baz')) + + def test_lookup_normalize_slashes(self): + expected = [Ref.track(uri=self.uris[0], name='song1'), + Ref.track(uri=self.uris[1], name='song2')] + self.assertEqual(expected, self.cache.lookup('/foo//bar/')) diff --git a/tests/mpd/protocol/current_playlist_test.py b/tests/mpd/protocol/current_playlist_test.py index f94ec6a0..34221fcd 100644 --- a/tests/mpd/protocol/current_playlist_test.py +++ b/tests/mpd/protocol/current_playlist_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from mopidy.models import Track +from mopidy.models import Ref, Track from tests.mpd import protocol @@ -24,9 +24,36 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse( 'ACK [50@0] {add} directory or file not found') - def test_add_with_empty_uri_should_add_all_known_tracks_and_ok(self): + def test_add_with_empty_uri_should_not_add_anything_and_ok(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('add ""') - # TODO check that we add all tracks (we currently don't) + self.assertEqual(len(self.core.tracklist.tracks.get()), 0) + self.assertInResponse('OK') + + def test_add_with_library_should_recurse(self): + tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/b', name='b')] + + self.backend.library.dummy_library = tracks + self.backend.library.dummy_browse_result = { + '/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='/foo')], + '/foo': [Ref.track(uri='dummy:/b', name='b')]} + + self.sendRequest('add "/dummy"') + self.assertEqual(self.core.tracklist.tracks.get(), tracks) + self.assertInResponse('OK') + + def test_add_root_should_not_add_anything_and_ok(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('add "/"') + self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') def test_addid_without_songpos(self): diff --git a/tests/mpd/protocol/music_db_test.py b/tests/mpd/protocol/music_db_test.py index 25feab27..163ccf88 100644 --- a/tests/mpd/protocol/music_db_test.py +++ b/tests/mpd/protocol/music_db_test.py @@ -168,20 +168,18 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 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.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'), - ] + 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"') @@ -191,9 +189,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): 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.backend.library.dummy_browse_result = { + '/': [Ref.track(uri='dummy:/a', name='a')]} self.sendRequest('lsinfo "/dummy"') self.assertInResponse('file: dummy:/a') @@ -201,9 +198,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_lsinfo_for_dir_includes_subdirs(self): - self.backend.library.dummy_browse_result = [ - Ref.directory(uri='/foo', name='foo'), - ] + self.backend.library.dummy_browse_result = { + '/': [Ref.directory(uri='/foo', name='foo')]} self.sendRequest('lsinfo "/dummy"') self.assertInResponse('directory: dummy/foo')