Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: MPD-0.21 Search/Find Expressions #52

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions mopidy_mpd/protocol/filter_expressions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
from mopidy_mpd import exceptions

class peekable:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code in this file almost looks like it was copy+pasted from somewhere else, and definitely doesn't match the code style conventions of the codebase it's being inserted into.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my implementation of peekable was modeled after the interface provided by more_itertools, but stripped down for the use case here. the rest of the code is original, but written outside mopidy first.

for a more general take, i'm writing a 'real' comment outside the review box.

""" an iterator that can be peeked one element into the future """
def __init__(self, it):
self._it = iter(it)
self._poked = []
def __iter__(self):
return self
def __next__(self):
if self._poked:
return self._poked.pop()
return next(self._it)
def __bool__(self):
try:
self.peek()
return True
except StopIteration:
return False
def peek(self):
if not self._poked:
self._poked = [next(self._it)]
return self._poked[0]

def takewhile(it, f):
def gen(it):
while it and f(it.peek()):
yield next(it)
return ''.join(gen(it))

def is_tagname(c):
return ( # A-Z, a-z or '-' or '_'
ord('A') <= ord(c) <= ord('Z') or
ord('a') <= ord(c) <= ord('z') or
c in '-_'
)

def is_operator(c):
return ( # A-Z, a-z or '!' or '=' or '~'
ord('A') <= ord(c) <= ord('Z') or
ord('a') <= ord(c) <= ord('z') or
c in '!=~'
)

def takeWord(it, alphabet=is_tagname):
value = takewhile(it, alphabet)
takewhile(it, str.isspace)
return value

def takeChar(it):
c = next(it)
takewhile(it, str.isspace)
return c

def takeQuoted(it):
def gen(it, quote):
while it and it.peek() != quote:
c = next(it)
if c == '\\':
c = next(it)
yield c

quote = next(it)
Assert(quote in '\'"', "Quoted string expected")
value = ''.join(gen(it, quote))
Assert(next(it) == quote, "Closing quote not found")
takewhile(it, str.isspace)
return value


def Assert(p, message):
if not p:
raise exceptions.MpdArgError(message)

class parenthesis:
def __init__(self, it):
self.it = it
def __enter__(self):
c = takeChar(self.it)
Assert(c == '(', "'(' expected")
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is None:
c = takeChar(self.it)
Assert(c == ')', "')' expected")

def parse_subexpression(it):
with parenthesis(it):
if it.peek() == '!': # (!EXPRESSION)
# Mopidy cannot handle '!=', so there's no point in handling this
raise exceptions.MpdArgError('non-matching not supported in Mopidy')

elif it.peek() == '(': # (EXPRESSION1 AND EXPRESSION2 ...)
subexpressions = [parse_subexpression(it)]
while it.peek() != ')':
Assert(takeWord(it).upper() == "AND", "'AND' expected")
subexpression = parse_subexpression(it)
subexpressions.extend(subexpression)
return subexpressions

else: # (TAG OP 'VALUE') or (SPECIAL 'VALUE')
filter_type = takeWord(it)
if filter_type == "":
raise exceptions.MpdArgError('Word expected')
elif filter_type in ("base", "modified-since"):
# (base 'VALUE'), (modified-since 'VALUE')
value = takeQuoted(it)
return [(filter_type, '==', value)]
else: # TAG, 'any', 'file', 'filename', 'AudioFormat'
operator = takeWord(it, is_operator).lower()
Assert(
operator not in ('!=', '!~', '!contains'),
"non-matching not supported in Mopidy"
)
Assert(
operator in ('==', '=~', 'contains'),
'invalid operator'
)
value = takeQuoted(it)
return [(filter_type, operator, value)]

def parse_filter_expression(expression):
it = peekable(expression)
try:
expression = parse_subexpression(it)
except StopIteration:
raise Assert(False, 'incomplete filter expression')
Assert(not it, 'Unparsed garbage after expression')
return expression
96 changes: 73 additions & 23 deletions mopidy_mpd/protocol/music_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from mopidy.models import Track
from mopidy_mpd import exceptions, protocol, translator
from mopidy_mpd.protocol.filter_expressions import parse_filter_expression

_SEARCH_MAPPING = {
"album": "album",
Expand Down Expand Up @@ -45,18 +46,27 @@

def _query_from_mpd_search_parameters(parameters, mapping):
query = {}
uris = []
parameters = list(parameters)
while parameters:
# TODO: does it matter that this is now case insensitive
field = mapping.get(parameters.pop(0).lower())
if not field:
raise exceptions.MpdArgError("incorrect arguments")
if not parameters:
raise ValueError
value = parameters.pop(0)
if value.strip():
query.setdefault(field, []).append(value)
return query
parameter = parameters.pop(0)
if parameter.startswith('('): # Filter Expression
expression = parse_filter_expression(parameter)
for field, _, value in expression:
if field == 'base':
uris.append(value)
else:
query.setdefault(field.lower(), []).append(value)
else: # Type and What pair
field = mapping.get(parameter.lower())
if not field:
raise exceptions.MpdArgError("incorrect arguments")
if not parameters:
raise ValueError
value = parameters.pop(0)
if value.strip():
query.setdefault(field, []).append(value)
return query, (uris or None)


def _get_field(field, search_results):
Expand Down Expand Up @@ -94,15 +104,21 @@ def count(context, *args):
Counts the number of songs and their total playtime in the db
matching ``TAG`` exactly.

``count {FILTER} [group {GROUPTYPE}]``

Count the number of songs and their total playtime in the database
matching ``FILTER``. The group keyword may be used to group the results
by a tag.

*GMPC:*

- use multiple tag-needle pairs to make more specific searches.
"""
try:
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
query, uris = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
raise exceptions.MpdArgError("incorrect arguments")
results = context.core.library.search(query=query, exact=True).get()
results = context.core.library.search(query=query, exact=True, uris=uris).get()
result_tracks = _get_tracks(results)
total_length = sum(t.length for t in result_tracks if t.length)
return [
Expand All @@ -123,6 +139,14 @@ def find(context, *args):
to search by full path (relative to database root), and ``any`` to
match against all available tags. ``WHAT`` is what to find.

``find {FILTER} [sort {TYPE}] [window {START:END}]``

Search the database for songs matching ``FILTER``. ``sort`` sorts the
result by the specified tag. The sort is descending if the tag is
prefixed with a minus (``-``). ``window`` can be used to query only a
portion of the real response. The parameter is two zero-based record
numbers; a start number and an end number.

*GMPC:*

- also uses ``find album "[ALBUM]" artist "[ARTIST]"`` to list album
Expand All @@ -138,11 +162,11 @@ def find(context, *args):
- uses "file" instead of "filename".
"""
try:
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
query, uris = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
return

results = context.core.library.search(query=query, exact=True).get()
results = context.core.library.search(query=query, exact=True, uris=uris).get()
result_tracks = []
if (
"artist" not in query
Expand All @@ -166,13 +190,18 @@ def findadd(context, *args):

Finds songs in the db that are exactly ``WHAT`` and adds them to
current playlist. Parameters have the same meaning as for ``find``.

``findadd {FILTER}``

Search the database for songs matching ``FILTER`` and add them to the
queue. Parameters have the same meaning as for ``find``.
"""
try:
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
query, uris = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
return

results = context.core.library.search(query=query, exact=True).get()
results = context.core.library.search(query=query, exact=True, uris=uris).get()

context.core.tracklist.add(
uris=[track.uri for track in _get_tracks(results)]
Expand All @@ -192,6 +221,12 @@ def list_(context, *args):
``ARTIST`` is an optional parameter when type is ``album``,
``date``, or ``genre``. This filters the result list by an artist.

``list {TYPE} {FILTER} [group {GROUPTYPE}]``

Lists unique tags values of the specified type. Additional arguments
may specify a filter. The group keyword may be used (repeatedly) to
group the results by one or more tags.

*Clarifications:*

The musicpd.org documentation for ``list`` is far from complete. The
Expand Down Expand Up @@ -274,7 +309,7 @@ def list_(context, *args):
query = {"artist": params}
else:
try:
query = _query_from_mpd_search_parameters(params, _SEARCH_MAPPING)
query, _ = _query_from_mpd_search_parameters(params, _SEARCH_MAPPING)
except exceptions.MpdArgError as exc:
exc.message = "Unknown filter type" # noqa B306: Our own exception
raise
Expand Down Expand Up @@ -420,6 +455,11 @@ def search(context, *args):
Searches for any song that contains ``WHAT``. Parameters have the same
meaning as for ``find``, except that search is not case sensitive.

``search {FILTER} [sort {TYPE}] [window {START:END}]``

Search the database for songs matching ``FILTER``. Parameters have the
same meaning as for ``find``, except that search is not case sensitive.

*GMPC:*

- uses the undocumented field ``any``.
Expand All @@ -437,10 +477,10 @@ def search(context, *args):
- uses "file" instead of "filename".
"""
try:
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
query, uris = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
return
results = context.core.library.search(query).get()
results = context.core.library.search(query, uris=uris).get()
artists = [_artist_as_track(a) for a in _get_artists(results)]
albums = [_album_as_track(a) for a in _get_albums(results)]
tracks = _get_tracks(results)
Expand All @@ -459,13 +499,18 @@ def searchadd(context, *args):

Parameters have the same meaning as for ``find``, except that search is
not case sensitive.

``searchadd {FILTER}``

Search the database for songs matching ``FILTER`` and add them to the
queue. Parameters have the same meaning as for ``search``.
"""
try:
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
query, uris = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
return

results = context.core.library.search(query).get()
results = context.core.library.search(query, uris=uris).get()

context.core.tracklist.add(
uris=[track.uri for track in _get_tracks(results)]
Expand All @@ -486,16 +531,21 @@ def searchaddpl(context, *args):

Parameters have the same meaning as for ``find``, except that search is
not case sensitive.

``searchaddpl {NAME} {FILTER}``

Search the database for songs matching ``FILTER`` and add them to the
playlist named ``NAME``.
"""
parameters = list(args)
if not parameters:
raise exceptions.MpdArgError("incorrect arguments")
playlist_name = parameters.pop(0)
try:
query = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING)
query, uris = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING)
except ValueError:
return
results = context.core.library.search(query).get()
results = context.core.library.search(query, uris=uris).get()

uri = context.lookup_playlist_uri_from_name(playlist_name)
playlist = uri is not None and context.core.playlists.lookup(uri).get()
Expand Down
9 changes: 6 additions & 3 deletions tests/protocol/test_music_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,26 @@

class QueryFromMpdSearchFormatTest(unittest.TestCase):
def test_dates_are_extracted(self):
result = music_db._query_from_mpd_search_parameters(
result, uris = music_db._query_from_mpd_search_parameters(
["Date", "1974-01-02", "Date", "1975"], music_db._SEARCH_MAPPING
)
assert result["date"][0] == "1974-01-02"
assert result["date"][1] == "1975"
assert uris is None

def test_empty_value_is_ignored(self):
result = music_db._query_from_mpd_search_parameters(
result, uris = music_db._query_from_mpd_search_parameters(
["Date", ""], music_db._SEARCH_MAPPING
)
assert result == {}
assert uris is None

def test_whitespace_value_is_ignored(self):
result = music_db._query_from_mpd_search_parameters(
result, uris = music_db._query_from_mpd_search_parameters(
["Date", " "], music_db._SEARCH_MAPPING
)
assert result == {}
assert uris is None

# TODO Test more mappings

Expand Down