Repository: beetbox/beets

Issue:
beets bpd crash
### Problem

beets bpd crashes when accessed with mpd client (while fetching albums for artist?).

```sh
$ beet -vv bpd
user configuration: /home/hrehfeld/.config/beets/config.yaml
data directory: /home/hrehfeld/.config/beets
plugin paths: 
Sending event: pluginload
library database: /home/hrehfeld/.local/beets.blb
library directory: /home/hrehfeld/music/2017
Sending event: library_opened
Building directory tree...
... done.
```

Now access with mpd client:
```sh
Traceback (most recent call last):
  File "/usr/lib/python3.7/site-packages/beetsplug/bpd/__init__.py", line 686, in run
    for data in results:
  File "/usr/lib/python3.7/site-packages/beetsplug/bpd/__init__.py", line 1060, in cmd_list
    rows = tx.query(statement, subvals)
  File "/usr/lib/python3.7/site-packages/beets/dbcore/db.py", line 709, in query
    cursor = self.db._connection().execute(statement, subvals)
sqlite3.OperationalError: near "ORDER": syntax error

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/bin/beet", line 11, in <module>
    load_entry_point('beets==1.4.7', 'console_scripts', 'beet')()
  File "/usr/lib/python3.7/site-packages/beets/ui/__init__.py", line 1256, in main
    _raw_main(args)
  File "/usr/lib/python3.7/site-packages/beets/ui/__init__.py", line 1243, in _raw_main
    subcommand.func(lib, suboptions, subargs)
  File "/usr/lib/python3.7/site-packages/beetsplug/bpd/__init__.py", line 1192, in func
    self.start_bpd(lib, host, int(port), password, volume, debug)
  File "/usr/lib/python3.7/site-packages/beetsplug/bpd/__init__.py", line 1168, in start_bpd
    server.run()
  File "/usr/lib/python3.7/site-packages/beetsplug/bpd/__init__.py", line 762, in run
    super(Server, self).run()
  File "/usr/lib/python3.7/site-packages/beetsplug/bpd/__init__.py", line 196, in run
    Connection.handler(self)))
  File "/usr/lib/python3.7/site-packages/beets/util/bluelet.py", line 380, in run
    exit_te.reraise()
  File "/usr/lib/python3.7/site-packages/beets/util/bluelet.py", line 204, in reraise
    six.reraise(self.exc_info[0], self.exc_info[1], self.exc_info[2])
  File "/usr/lib/python3.7/site-packages/six.py", line 693, in reraise
    raise value
  File "/usr/lib/python3.7/site-packages/beets/util/bluelet.py", line 266, in advance_thread
    next_event = coro.throw(*value)
  File "/usr/lib/python3.7/site-packages/beets/util/bluelet.py", line 626, in handler
    yield func(conn)
  File "/usr/lib/python3.7/site-packages/beets/util/bluelet.py", line 266, in advance_thread
    next_event = coro.throw(*value)
  File "/usr/lib/python3.7/site-packages/beetsplug/bpd/__init__.py", line 625, in run
    yield bluelet.call(self.do_command(Command(line)))
  File "/usr/lib/python3.7/site-packages/beets/util/bluelet.py", line 266, in advance_thread
    next_event = coro.throw(*value)
  File "/usr/lib/python3.7/site-packages/beetsplug/bpd/__init__.py", line 585, in do_command
    yield bluelet.call(command.run(self))
  File "/usr/lib/python3.7/site-packages/beets/util/bluelet.py", line 268, in advance_thread
    next_event = coro.send(value)
  File "/usr/lib/python3.7/site-packages/beetsplug/bpd/__init__.py", line 702, in run
    log.error('{}', traceback.format_exc(e))
  File "/usr/lib/python3.7/traceback.py", line 163, in format_exc
    return "".join(format_exception(*sys.exc_info(), limit=limit, chain=chain))
  File "/usr/lib/python3.7/traceback.py", line 117, in format_exception
    type(value), value, tb, limit=limit).format(chain=chain))
  File "/usr/lib/python3.7/traceback.py", line 496, in __init__
    capture_locals=capture_locals)
  File "/usr/lib/python3.7/traceback.py", line 331, in extract
    if limit >= 0:
TypeError: '>=' not supported between instances of 'OperationalError' and 'int'

```

### Setup

* OS: Linux 4.14.62-1-ARCH #1 SMP Fri Aug 17 05:43:55 UTC 2018 armv7l GNU/Linux
* Python version: Python 3.7.0
* beets version: 1.4.7
* sqlite3 --version: 3.24.0 2018-06-04 19:24:41 c7ee0833225bfd8c5ec2f9bf62b97c4e04d03bd9566366d5221ac8fb199a87ca
* Turning off plugins made problem go away (yes/no): n/a

My configuration (output of `beet config`) is:

```yaml
original_date: yes
directory: /home/hrehfeld/music/2017
library: /home/hrehfeld/.local/beets.blb
ignore: INCOMPLETE~*
ignore_hidden: yes
threaded: yes

plugins: bpd

import:
    log: ~/beets_import.log
    incremental: yes
    move: yes
    languages: [en, de, fr]
    resume: yes
    detail: yes
    searchlimit: 30
    duplicate_action: keep

match:
    strong_rec_thresh: 0.1
    preferred:
        countries: [US, GB|UK, DE]
        media: [CD, Digital Media|File]
        original_year: yes
    max_rec:
        missing_tracks: low
        unmatched_tracks: medium

replace:
    '[\\/]': _
    ^\.: _
    '[\x00-\x1f]': _
    '[<>:"\?\*\|]': _
    \.$: _
    \s+$: ''
    '[,!'']': ''
max_filename_length: 100

paths:
    default: '%bucket{$year}/%bucket{%asciify{$albumartist}}/%asciify{$albumartist} - $year - %asciify{$album}%aunique{}/%asciify{$albumartist} - $track - %asciify{$title}'
lastgenre:
    fallback: unspecified
    canonical: ''
replaygain:
    backend: gstreamer
bucket:
    bucket_alpha: [A-F, G-M, N-Z]
    bucket_year:
    - '1920'
    - '1950'
    - '1960'
    - '1970'
    - '1980'
    - '1990'
    - '2000'
    - '2010'
copyartifacts:
    extensions: .cue .log .jpg .png
    print_ignored: yes
bpd:
    port: 6601
    password: REDACTED
    volume: 100
    host: ''
types:
    rating: int
web:
    host: 127.0.0.1


```



Discussion:
Hmm; that looks pretty bad. Are you able to work out what command the client sent to trigger this crash?
Hrm, I think it only happens with mpdroid. It seems to spam requests. Will test more sometime soon.
I can reproduce this (with MALP on Android). The problem happens when you're on the list of artists and you select an artist. The client wants to grab a list of albums from that artist. It issues the command:

```
list album "alt-J"
```
which is weird because it doesn't seem to match the documentation of the protocol which is `list {TYPE} {FILTER}` where `{FILTER}` is a set of key value pairs. That is, I would have expected the command to instead be

```
list album artist "alt-J"
```
and indeed our existing implementation is fine with that command.

In any case with the "wrong" command from the client, it causes bpd to construct the query `SELECT DISTINCT album FROM items WHERE  ORDER BY album` with an empty `WHERE` causing the syntax error noted above. It never made any attempt to check that there was an even number of arguments provided.

I suppose that this means MPD was implementing a behaviour that was a bit different to the documentation of its protocol. Maybe it assumes `artist` if no tag name is specified for the filter. It's a bit hard to test because the current version of MPD I have supports a newer version of the protocol where the query syntax has changed. I'll have to play around and maybe just guess at what should have happened based on how the client behaves.

Resolving patch:
diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py
--- a/beetsplug/bpd/__init__.py
+++ b/beetsplug/bpd/__init__.py
@@ -1404,16 +1404,31 @@ def cmd_list(self, conn, show_tag, *kv):
         filtered by matching match_tag to match_term.
         """
         show_tag_canon, show_key = self._tagtype_lookup(show_tag)
+        if len(kv) == 1:
+            if show_tag_canon == 'Album':
+                # If no tag was given, assume artist. This is because MPD
+                # supports a short version of this command for fetching the
+                # albums belonging to a particular artist, and some clients
+                # rely on this behaviour (e.g. MPDroid, M.A.L.P.).
+                kv = ('Artist', kv[0])
+            else:
+                raise BPDError(ERROR_ARG, u'should be "Album" for 3 arguments')
+        elif len(kv) % 2 != 0:
+            raise BPDError(ERROR_ARG, u'Incorrect number of filter arguments')
         query = self._metadata_query(dbcore.query.MatchQuery, None, kv)
 
         clause, subvals = query.clause()
         statement = 'SELECT DISTINCT ' + show_key + \
                     ' FROM items WHERE ' + clause + \
                     ' ORDER BY ' + show_key
+        self._log.debug(statement)
         with self.lib.transaction() as tx:
             rows = tx.query(statement, subvals)
 
         for row in rows:
+            if not row[0]:
+                # Skip any empty values of the field.
+                continue
             yield show_tag_canon + u': ' + six.text_type(row[0])
 
     def cmd_count(self, conn, tag, value):


Test patch:
diff --git a/test/test_player.py b/test/test_player.py
--- a/test/test_player.py
+++ b/test/test_player.py
@@ -774,11 +774,21 @@ def test_cmd_list(self):
         with self.run_bpd() as client:
             responses = client.send_commands(
                     ('list', 'album'),
-                    ('list', 'track'))
-        self._assert_ok(*responses)
+                    ('list', 'track'),
+                    ('list', 'album', 'artist', 'Artist Name', 'track'))
+        self._assert_failed(responses, bpd.ERROR_ARG, pos=2)
         self.assertEqual('Album Title', responses[0].data['Album'])
         self.assertEqual(['1', '2'], responses[1].data['Track'])
 
+    def test_cmd_list_three_arg_form(self):
+        with self.run_bpd() as client:
+            responses = client.send_commands(
+                    ('list', 'album', 'artist', 'Artist Name'),
+                    ('list', 'album', 'Artist Name'),
+                    ('list', 'track', 'Artist Name'))
+        self._assert_failed(responses, bpd.ERROR_ARG, pos=2)
+        self.assertEqual(responses[0].data, responses[1].data)
+
     def test_cmd_lsinfo(self):
         with self.run_bpd() as client:
             response1 = client.send_command('lsinfo')