Merge pull request #640 from adamcik/feature/local-browse

Add local browsing
This commit is contained in:
Stein Magnus Jodal 2014-01-14 14:43:31 -08:00
commit c6cff2794c
9 changed files with 170 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<uri>[^"]*)"(\ "(?P<songpos>\d+)")*$')
def addid(context, uri, songpos=None):

View File

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

36
tests/local/json_test.py Normal file
View File

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

View File

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

View File

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