Merge pull request #640 from adamcik/feature/local-browse
Add local browsing
This commit is contained in:
commit
c6cff2794c
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
36
tests/local/json_test.py
Normal 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/'))
|
||||
@ -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):
|
||||
|
||||
@ -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')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user