Repository: beetbox/beets

Issue:
Bad AC/DC support
Hi,
Queries with A/CDC suck, probably because of a path is detected.

```
$ time ./beet ls -a "albumartist:AC/DC"
real    0m0.550s
user    0m0.450s
sys 0m0.083s
```

```
$ time ./beet ls -a "AC/DC"
Traceback (most recent call last):
  File "./beet", line 20, in <module>
    beets.ui.main()
  File "/home/bru/code/beets/beets/ui/__init__.py", line 1081, in main
    _raw_main(args)
  File "/home/bru/code/beets/beets/ui/__init__.py", line 1071, in _raw_main
    subcommand.func(lib, suboptions, subargs)
  File "/home/bru/code/beets/beets/ui/commands.py", line 989, in list_func
    list_items(lib, decargs(args), opts.album)
  File "/home/bru/code/beets/beets/ui/commands.py", line 981, in list_items
    for album in lib.albums(query):
  File "/home/bru/code/beets/beets/dbcore/db.py", line 525, in _get_objects
    if not self.query or self.query.match(obj):
  File "/home/bru/code/beets/beets/dbcore/query.py", line 429, in match
    return all([q.match(item) for q in self.subqueries])
  File "/home/bru/code/beets/beets/library.py", line 79, in match
    path = item.path if self.case_sensitive else item.path.lower()
  File "/home/bru/code/beets/beets/dbcore/db.py", line 316, in __getattr__
    return self[key]
  File "/home/bru/code/beets/beets/dbcore/db.py", line 224, in __getitem__
    return getters[key](self)
  File "/home/bru/code/beets/beets/library.py", line 973, in item_dir
    raise ValueError('empty album')
ValueError: empty album

real    0m20.293s
user    0m8.410s
sys 0m11.097s
```

Subsequent times are < 1 sec.

```
$ ./beet ls "AC/DC"
$ ./beet ls Jailbreak
AC/DC - Gold Album 1974-2000 - Jailbreak
$ ./beet ls -p Jailbreak
/home/bru/Musique/AC_DC/Gold Album 1974-2000/03 Jailbreak.mp3
```



Discussion:
Hmm… yes, this is getting detected as a path. But I think the crash you're seeing is another artifact of that aforementioned situation from #1357 where a prior crash led to an empty album (which should never exist) in your database. All the more reason to sort out that atomicity issue…

You're right. I had, for an unknown reason, multiple empty albums "Billy Breathes" by Phish.

The "beet ls -a" request is still really slow: 30 seconds!! I have 2 questions:
- do you think that matching "AC/DC" as a path is desirable? Shouldn't beets first test whether the path exists, it if does then path-match otherwise classic match?
- is that slowness expected? Seems like beets fetches all albums and for each fetches an item. What makes it so slow?

> do you think that matching "AC/DC" as a path is desirable? Shouldn't beets first test whether the path exists, it if does then path-match otherwise classic match?

Sure! Testing for existence first seems like a good policy. We'll need to clearly update that part of the docs, of course.

> is that slowness expected? Seems like beets fetches all albums and for each fetches an item. What makes it so slow?

Yeah, that does seem suspiciously slow. Beets has fast queries and slow queries, but I don't see what about this would make it a slow query. Or maybe there's something else unexpected going on—maybe profiling would be in order?


Resolving patch:
diff --git a/beets/library.py b/beets/library.py
--- a/beets/library.py
+++ b/beets/library.py
@@ -75,6 +75,17 @@ def __init__(self, field, pattern, fast=True, case_sensitive=None):
         # As a directory (prefix).
         self.dir_path = util.bytestring_path(os.path.join(self.file_path, b''))
 
+    @classmethod
+    def is_path_query(cls, query_part):
+        """Try to guess whether a unicode query part is a path query.
+
+        Condition: separator precedes colon and the file exists.
+        """
+        colon = query_part.find(':')
+        if colon != -1:
+            query_part = query_part[:colon]
+        return os.sep in query_part and os.path.exists(query_part)
+
     def match(self, item):
         path = item.path if self.case_sensitive else item.path.lower()
         return (path == self.file_path) or path.startswith(self.dir_path)
@@ -1097,8 +1108,7 @@ def parse_query_parts(parts, model_cls):
     path_parts = []
     non_path_parts = []
     for s in parts:
-        if s.find(os.sep, 0, s.find(':')) != -1:
-            # Separator precedes colon.
+        if PathQuery.is_path_query(s):
             path_parts.append(s)
         else:
             non_path_parts.append(s)


Test patch:
diff --git a/test/test_query.py b/test/test_query.py
--- a/test/test_query.py
+++ b/test/test_query.py
@@ -18,6 +18,8 @@
                         unicode_literals)
 
 from functools import partial
+from mock import patch
+import os
 
 from test import _common
 from test._common import unittest
@@ -374,6 +376,13 @@ def setUp(self):
         self.i.store()
         self.lib.add_album([self.i])
 
+        self.patcher = patch('beets.library.os.path.exists')
+        self.patcher.start().return_value = True
+
+    def tearDown(self):
+        super(PathQueryTest, self).tearDown()
+        self.patcher.stop()
+
     def test_path_exact_match(self):
         q = 'path:/a/b/c.mp3'
         results = self.lib.items(q)
@@ -503,6 +512,42 @@ def test_case_sensitivity(self):
             q = makeq()
             self.assertEqual(q.case_sensitive, False)
 
+    @patch('beets.library.os')
+    def test_path_sep_detection(self, mock_os):
+        mock_os.sep = '/'
+        is_path = beets.library.PathQuery.is_path_query
+        self.assertTrue(is_path('/foo/bar'))
+        self.assertTrue(is_path('foo/bar'))
+        self.assertTrue(is_path('foo/'))
+        self.assertFalse(is_path('foo'))
+        self.assertTrue(is_path('foo/:bar'))
+        self.assertFalse(is_path('foo:bar/'))
+        self.assertFalse(is_path('foo:/bar'))
+
+    def test_path_detection(self):
+        # cover existence test
+        self.patcher.stop()
+        is_path = beets.library.PathQuery.is_path_query
+
+        try:
+            self.touch(b'foo/bar')
+            # test absolute
+            self.assertTrue(is_path(os.path.join(self.temp_dir, b'foo/bar')))
+            self.assertTrue(is_path(os.path.join(self.temp_dir, b'foo')))
+            self.assertFalse(is_path(b'foo/bar'))
+
+            cur_dir = os.getcwd()
+            try:
+                os.chdir(self.temp_dir)
+                self.assertTrue(is_path(b'foo/'))
+                self.assertTrue(is_path(b'foo/bar'))
+                self.assertTrue(is_path(b'foo/bar:tagada'))
+                self.assertFalse(is_path(b'bar'))
+            finally:
+                os.chdir(cur_dir)
+        finally:
+            self.patcher.start()
+
 
 class IntQueryTest(unittest.TestCase, TestHelper):