Merge branch 'develop' into feature/mpd-search-by-albumartist

Conflicts:
	mopidy/frontends/mpd/protocol/music_db.py
	mopidy/frontends/mpd/translator.py
This commit is contained in:
Stein Magnus Jodal 2013-10-27 21:30:58 +01:00
commit 9064fc02fd
8 changed files with 387 additions and 15 deletions

View File

@ -27,9 +27,14 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
self._media_dir, self._tag_cache_file)
tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir)
uris_to_remove = set(self._uri_mapping)
for track in tracks:
self._uri_mapping[track.uri] = track
uris_to_remove.discard(track.uri)
for uri in uris_to_remove:
del self._uri_mapping[uri]
logger.info(
'Loaded %d local tracks from %s using %s',
@ -55,7 +60,10 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
values = [values]
# FIXME this is bound to be slow for large libraries
for value in values:
q = value.strip()
if field == 'track_no':
q = value
else:
q = value.strip()
uri_filter = lambda t: q == t.uri
track_filter = lambda t: q == t.name
@ -65,13 +73,16 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
albumartist_filter = lambda t: any([
q == a.name
for a in getattr(t.album, 'artists', [])])
track_no_filter = lambda t: q == t.track_no
date_filter = lambda t: q == t.date
any_filter = lambda t: (
uri_filter(t) or
track_filter(t) or
album_filter(t) or
artist_filter(t) or
albumartist_filter(t) or
uri_filter(t))
track_no_filter(t) or
date_filter(t))
if field == 'uri':
result_tracks = filter(uri_filter, result_tracks)
@ -83,6 +94,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
result_tracks = filter(artist_filter, result_tracks)
elif field == 'albumartist':
result_tracks = filter(albumartist_filter, result_tracks)
elif field == 'track_no':
result_tracks = filter(track_no_filter, result_tracks)
elif field == 'date':
result_tracks = filter(date_filter, result_tracks)
elif field == 'any':
@ -105,7 +118,10 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
values = [values]
# FIXME this is bound to be slow for large libraries
for value in values:
q = value.strip().lower()
if field == 'track_no':
q = value
else:
q = value.strip().lower()
uri_filter = lambda t: q in t.uri.lower()
track_filter = lambda t: q in t.name.lower()
@ -116,13 +132,16 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
albumartist_filter = lambda t: any([
q in a.name.lower()
for a in getattr(t.album, 'artists', [])])
track_no_filter = lambda t: q == t.track_no
date_filter = lambda t: t.date and t.date.startswith(q)
any_filter = lambda t: (
uri_filter(t) or
track_filter(t) or
album_filter(t) or
artist_filter(t) or
albumartist_filter(t) or
uri_filter(t))
track_no_filter(t) or
date_filter(t))
if field == 'uri':
result_tracks = filter(uri_filter, result_tracks)
@ -134,6 +153,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
result_tracks = filter(artist_filter, result_tracks)
elif field == 'albumartist':
result_tracks = filter(albumartist_filter, result_tracks)
elif field == 'track_no':
result_tracks = filter(track_no_filter, result_tracks)
elif field == 'date':
result_tracks = filter(date_filter, result_tracks)
elif field == 'any':

View File

@ -2,8 +2,10 @@ from __future__ import unicode_literals
import ConfigParser as configparser
import io
import itertools
import logging
import os.path
import re
from mopidy.config import keyring
from mopidy.config.schemas import * # noqa
@ -145,6 +147,53 @@ def _format(config, comments, schemas, display):
return b'\n'.join(output)
def _preprocess(config_string):
"""Convert a raw config into a form that preserves comments etc."""
results = ['[__COMMENTS__]']
counter = itertools.count(0)
section_re = re.compile(r'^(\[[^\]]+\])\s*(.+)$')
blank_line_re = re.compile(r'^\s*$')
comment_re = re.compile(r'^(#|;)')
inline_comment_re = re.compile(r' ;')
def newlines(match):
return '__BLANK%d__ =' % next(counter)
def comments(match):
if match.group(1) == '#':
return '__HASH%d__ =' % next(counter)
elif match.group(1) == ';':
return '__SEMICOLON%d__ =' % next(counter)
def inlinecomments(match):
return '\n__INLINE%d__ =' % next(counter)
def sections(match):
return '%s\n__SECTION%d__ = %s' % (
match.group(1), next(counter), match.group(2))
for line in config_string.splitlines():
line = blank_line_re.sub(newlines, line)
line = section_re.sub(sections, line)
line = comment_re.sub(comments, line)
line = inline_comment_re.sub(inlinecomments, line)
results.append(line)
return '\n'.join(results)
def _postprocess(config_string):
"""Converts a preprocessed config back to original form."""
flags = re.IGNORECASE | re.MULTILINE
result = re.sub(r'^\[__COMMENTS__\](\n|$)', '', config_string, flags=flags)
result = re.sub(r'\n__INLINE\d+__ =(.*)$', ' ;\g<1>', result, flags=flags)
result = re.sub(r'^__HASH\d+__ =(.*)$', '#\g<1>', result, flags=flags)
result = re.sub(r'^__SEMICOLON\d+__ =(.*)$', ';\g<1>', result, flags=flags)
result = re.sub(r'\n__SECTION\d+__ =(.*)$', '\g<1>', result, flags=flags)
result = re.sub(r'^__BLANK\d+__ =$', '', result, flags=flags)
return result
class Proxy(collections.Mapping):
def __init__(self, data):
self._data = data

View File

@ -5,13 +5,13 @@ import itertools
from mopidy.models import Track
from mopidy.frontends.mpd import translator
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented
from mopidy.frontends.mpd.protocol import handle_request, stored_playlists
QUERY_RE = (
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Aa]lbumartist|[Dd]ate|[Ff]ile|'
r'[Ff]ilename|[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$')
r'[Ff]ilename|[Tt]itle|[Tt]rack|[Aa]ny)"? "[^"]*"\s?)+)$')
def _get_field(field, search_results):
@ -54,7 +54,16 @@ def count(context, mpd_query):
- does not add quotes around the tag argument.
- use multiple tag-needle pairs to make more specific searches.
"""
return [('songs', 0), ('playtime', 0)] # TODO
try:
query = translator.query_from_mpd_search_format(mpd_query)
except ValueError:
raise MpdArgError('incorrect arguments', command='count')
results = context.core.library.find_exact(**query).get()
result_tracks = _get_tracks(results)
return [
('songs', len(result_tracks)),
('playtime', sum(track.length for track in result_tracks) / 1000),
]
@handle_request(r'^find ' + QUERY_RE)

View File

@ -193,6 +193,7 @@ MPD_SEARCH_QUERY_RE = re.compile(r"""
| [Ff]ile
| [Ff]ilename
| [Tt]itle
| [Tt]rack
| [Aa]ny
)
"? # End of optional quote around the field type
@ -211,6 +212,7 @@ MPD_SEARCH_QUERY_PART_RE = re.compile(r"""
| [Ff]ile
| [Ff]ilename
| [Tt]itle
| [Tt]rack
| [Aa]ny
))
"? # End of optional quote around the field type
@ -234,6 +236,8 @@ def query_from_mpd_search_format(mpd_query):
field = m.groupdict()['field'].lower()
if field == 'title':
field = 'track'
elif field == 'track':
field = 'track_no'
elif field in ('file', 'filename'):
field = 'uri'
what = m.groupdict()['what']

View File

@ -1,5 +1,6 @@
from __future__ import unicode_literals
import tempfile
import unittest
import pykka
@ -11,6 +12,8 @@ from mopidy.models import Track, Album, Artist
from tests import path_to_data_dir
# TODO: update tests to only use backend, not core. we need a seperate
# core test that does this integration test.
class LocalLibraryProviderTest(unittest.TestCase):
artists = [
Artist(name='artist1'),
@ -29,15 +32,15 @@ class LocalLibraryProviderTest(unittest.TestCase):
Track(
uri='local:track:path1', name='track1',
artists=[artists[0]], album=albums[0],
date='2001-02-03', length=4000),
date='2001-02-03', length=4000, track_no=1),
Track(
uri='local:track:path2', name='track2',
artists=[artists[1]], album=albums[1],
date='2002', length=4000),
date='2002', length=4000, track_no=2),
Track(
uri='local:track:path3', name='track3',
artists=[artists[3]], album=albums[2],
date='2003', length=4000),
date='2003', length=4000, track_no=3),
]
config = {
@ -49,7 +52,6 @@ class LocalLibraryProviderTest(unittest.TestCase):
}
def setUp(self):
self.backend = actor.LocalBackend.start(
config=self.config, audio=None).proxy()
self.core = core.Core(backends=[self.backend])
@ -65,9 +67,31 @@ class LocalLibraryProviderTest(unittest.TestCase):
def test_refresh_uri(self):
pass
@unittest.SkipTest
def test_refresh_missing_uri(self):
pass
# Verifies that https://github.com/mopidy/mopidy/issues/500
# has been fixed.
tag_cache = tempfile.NamedTemporaryFile()
with open(self.config['local']['tag_cache_file']) as fh:
tag_cache.write(fh.read())
tag_cache.flush()
config = {'local': self.config['local'].copy()}
config['local']['tag_cache_file'] = tag_cache.name
backend = actor.LocalBackend(config=config, audio=None)
# Sanity check that value is in tag cache
result = backend.library.lookup(self.tracks[0].uri)
self.assertEqual(result, self.tracks[0:1])
# Clear tag cache and refresh
tag_cache.seek(0)
tag_cache.truncate()
backend.library.refresh()
# Now it should be gone.
result = backend.library.lookup(self.tracks[0].uri)
self.assertEqual(result, [])
def test_lookup(self):
tracks = self.library.lookup(self.tracks[0].uri)
@ -87,6 +111,18 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(album=['unknown artist'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(date=['1990'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(track_no=[9])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(uri=['fake uri'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(any=['unknown any'])
self.assertEqual(list(result[0].tracks), [])
def test_find_exact_uri(self):
track_1_uri = 'local:track:path1'
result = self.library.find_exact(uri=track_1_uri)
@ -130,6 +166,13 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(albumartist=['artist3'])
self.assertEqual(list(result[0].tracks), [self.tracks[2]])
def test_find_exact_track_no(self):
result = self.library.find_exact(track_no=[1])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
result = self.library.find_exact(track_no=[2])
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_find_exact_date(self):
result = self.library.find_exact(date=['2001'])
self.assertEqual(list(result[0].tracks), [])
@ -145,10 +188,16 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(any=['artist1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
result = self.library.find_exact(any=['artist2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
# Matches on track
result = self.library.find_exact(any=['track1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
result = self.library.find_exact(any=['track2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
# Matches on track album
result = self.library.find_exact(any=['album1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -157,6 +206,10 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(any=['artist3'])
self.assertEqual(list(result[0].tracks), self.tracks[2:3])
# Matches on track year
result = self.library.find_exact(any=['2002'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
# Matches on URI
result = self.library.find_exact(any=['local:track:path1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -175,6 +228,15 @@ class LocalLibraryProviderTest(unittest.TestCase):
test = lambda: self.library.find_exact(album=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(track_no=[])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(date=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(any=[''])
self.assertRaises(LookupError, test)
def test_search_no_hits(self):
result = self.library.search(track=['unknown track'])
self.assertEqual(list(result[0].tracks), [])
@ -185,10 +247,16 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(album=['unknown artist'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(uri=['unknown'])
result = self.library.search(track_no=[9])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(any=['unknown'])
result = self.library.search(date=['unknown date'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(uri=['unknown uri'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(any=['unknown anything'])
self.assertEqual(list(result[0].tracks), [])
def test_search_uri(self):
@ -245,6 +313,13 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(date=['2002'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_search_track_no(self):
result = self.library.search(track_no=[1])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
result = self.library.search(track_no=[2])
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_search_any(self):
# Matches on track artist
result = self.library.search(any=['Tist1'])
@ -254,6 +329,9 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(any=['Rack1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
result = self.library.search(any=['Rack2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
# Matches on track album
result = self.library.search(any=['Bum1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -280,6 +358,9 @@ class LocalLibraryProviderTest(unittest.TestCase):
test = lambda: self.library.search(album=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(date=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(uri=[''])
self.assertRaises(LookupError, test)

View File

@ -106,3 +106,162 @@ class ValidateTest(unittest.TestCase):
self.assertEqual({'foo': {'bar': 'bad'}}, errors)
# TODO: add more tests
INPUT_CONFIG = """# comments before first section should work
[section] anything goes ; after the [] block it seems.
; this is a valid comment
this-should-equal-baz = baz ; as this is a comment
this-should-equal-everything = baz # as this is not a comment
# this is also a comment ; and the next line should be a blank comment.
;
# foo # = should all be treated as a comment."""
PROCESSED_CONFIG = """[__COMMENTS__]
__HASH0__ = comments before first section should work
__BLANK1__ =
[section]
__SECTION2__ = anything goes
__INLINE3__ = after the [] block it seems.
__SEMICOLON4__ = this is a valid comment
this-should-equal-baz = baz
__INLINE5__ = as this is a comment
this-should-equal-everything = baz # as this is not a comment
__BLANK6__ =
__HASH7__ = this is also a comment
__INLINE8__ = and the next line should be a blank comment.
__SEMICOLON9__ =
__HASH10__ = foo # = should all be treated as a comment."""
class PreProcessorTest(unittest.TestCase):
maxDiff = None # Show entire diff.
def test_empty_config(self):
result = config._preprocess('')
self.assertEqual(result, '[__COMMENTS__]')
def test_plain_section(self):
result = config._preprocess('[section]\nfoo = bar')
self.assertEqual(result, '[__COMMENTS__]\n'
'[section]\n'
'foo = bar')
def test_initial_comments(self):
result = config._preprocess('; foobar')
self.assertEqual(result, '[__COMMENTS__]\n'
'__SEMICOLON0__ = foobar')
result = config._preprocess('# foobar')
self.assertEqual(result, '[__COMMENTS__]\n'
'__HASH0__ = foobar')
result = config._preprocess('; foo\n# bar')
self.assertEqual(result, '[__COMMENTS__]\n'
'__SEMICOLON0__ = foo\n'
'__HASH1__ = bar')
def test_initial_comment_inline_handling(self):
result = config._preprocess('; foo ; bar ; baz')
self.assertEqual(result, '[__COMMENTS__]\n'
'__SEMICOLON0__ = foo\n'
'__INLINE1__ = bar\n'
'__INLINE2__ = baz')
def test_inline_semicolon_comment(self):
result = config._preprocess('[section]\nfoo = bar ; baz')
self.assertEqual(result, '[__COMMENTS__]\n'
'[section]\n'
'foo = bar\n'
'__INLINE0__ = baz')
def test_no_inline_hash_comment(self):
result = config._preprocess('[section]\nfoo = bar # baz')
self.assertEqual(result, '[__COMMENTS__]\n'
'[section]\n'
'foo = bar # baz')
def test_section_extra_text(self):
result = config._preprocess('[section] foobar')
self.assertEqual(result, '[__COMMENTS__]\n'
'[section]\n'
'__SECTION0__ = foobar')
def test_section_extra_text_inline_semicolon(self):
result = config._preprocess('[section] foobar ; baz')
self.assertEqual(result, '[__COMMENTS__]\n'
'[section]\n'
'__SECTION0__ = foobar\n'
'__INLINE1__ = baz')
def test_conversion(self):
"""Tests all of the above cases at once."""
result = config._preprocess(INPUT_CONFIG)
self.assertEqual(result, PROCESSED_CONFIG)
class PostProcessorTest(unittest.TestCase):
maxDiff = None # Show entire diff.
def test_empty_config(self):
result = config._postprocess('[__COMMENTS__]')
self.assertEqual(result, '')
def test_plain_section(self):
result = config._postprocess('[__COMMENTS__]\n'
'[section]\n'
'foo = bar')
self.assertEqual(result, '[section]\nfoo = bar')
def test_initial_comments(self):
result = config._postprocess('[__COMMENTS__]\n'
'__SEMICOLON0__ = foobar')
self.assertEqual(result, '; foobar')
result = config._postprocess('[__COMMENTS__]\n'
'__HASH0__ = foobar')
self.assertEqual(result, '# foobar')
result = config._postprocess('[__COMMENTS__]\n'
'__SEMICOLON0__ = foo\n'
'__HASH1__ = bar')
self.assertEqual(result, '; foo\n# bar')
def test_initial_comment_inline_handling(self):
result = config._postprocess('[__COMMENTS__]\n'
'__SEMICOLON0__ = foo\n'
'__INLINE1__ = bar\n'
'__INLINE2__ = baz')
self.assertEqual(result, '; foo ; bar ; baz')
def test_inline_semicolon_comment(self):
result = config._postprocess('[__COMMENTS__]\n'
'[section]\n'
'foo = bar\n'
'__INLINE0__ = baz')
self.assertEqual(result, '[section]\nfoo = bar ; baz')
def test_no_inline_hash_comment(self):
result = config._preprocess('[section]\nfoo = bar # baz')
self.assertEqual(result, '[__COMMENTS__]\n'
'[section]\n'
'foo = bar # baz')
def test_section_extra_text(self):
result = config._postprocess('[__COMMENTS__]\n'
'[section]\n'
'__SECTION0__ = foobar')
self.assertEqual(result, '[section] foobar')
def test_section_extra_text_inline_semicolon(self):
result = config._postprocess('[__COMMENTS__]\n'
'[section]\n'
'__SECTION0__ = foobar\n'
'__INLINE1__ = baz')
self.assertEqual(result, '[section] foobar ; baz')
def test_conversion(self):
result = config._postprocess(PROCESSED_CONFIG)
self.assertEqual(result, INPUT_CONFIG)

View File

@ -9,6 +9,7 @@ Artist: artist1
Title: track1
Album: album1
Date: 2001-02-03
Track: 1
Time: 4
key: key2
file: /path2
@ -16,6 +17,7 @@ Artist: artist2
Title: track2
Album: album2
Date: 2002
Track: 2
Time: 4
key: key3
file: /path3
@ -24,5 +26,6 @@ AlbumArtist: artist3
Title: track3
Album: album3
Date: 2003
Track: 3
Time: 4
songList end

View File

@ -24,6 +24,28 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase):
self.assertInResponse('playtime: 0')
self.assertInResponse('OK')
def test_count_correct_length(self):
# Count the lone track
self.backend.library.dummy_find_exact_result = SearchResult(
tracks=[
Track(uri='dummy:a', name="foo", date="2001", length=4000),
])
self.sendRequest('count "title" "foo"')
self.assertInResponse('songs: 1')
self.assertInResponse('playtime: 4')
self.assertInResponse('OK')
# Count multiple tracks
self.backend.library.dummy_find_exact_result = SearchResult(
tracks=[
Track(uri='dummy:b', date="2001", length=50000),
Track(uri='dummy:c', date="2001", length=600000),
])
self.sendRequest('count "date" "2001"')
self.assertInResponse('songs: 2')
self.assertInResponse('playtime: 650')
self.assertInResponse('OK')
def test_findadd(self):
self.backend.library.dummy_find_exact_result = SearchResult(
tracks=[Track(uri='dummy:a', name='A')])
@ -263,6 +285,18 @@ class MusicDatabaseFindTest(protocol.BaseTestCase):
self.sendRequest('find title "what"')
self.assertInResponse('OK')
def test_find_track_no(self):
self.sendRequest('find "track" "10"')
self.assertInResponse('OK')
def test_find_track_no_without_quotes(self):
self.sendRequest('find track "10"')
self.assertInResponse('OK')
def test_find_track_no_without_filter_value(self):
self.sendRequest('find "track" ""')
self.assertInResponse('OK')
def test_find_date(self):
self.sendRequest('find "date" "2002-01-01"')
self.assertInResponse('OK')
@ -633,6 +667,18 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase):
self.sendRequest('search "any" ""')
self.assertInResponse('OK')
def test_search_track_no(self):
self.sendRequest('search "track" "10"')
self.assertInResponse('OK')
def test_search_track_no_without_quotes(self):
self.sendRequest('search track "10"')
self.assertInResponse('OK')
def test_search_track_no_without_filter_value(self):
self.sendRequest('search "track" ""')
self.assertInResponse('OK')
def test_search_date(self):
self.sendRequest('search "date" "2002-01-01"')
self.assertInResponse('OK')