Repository: beetbox/beets

Issue:
Path queries are case-insensitive
I have two albums with the same name except for 1 difference: 

/media/bb/audio/jazz/Sinatra/In the Wee Wee Hours
/media/bb/adio/jazz/Sinatra/In The Wee Wee Hours

the difference is just a lowercase/uppercase (t/T)

when using the command
beet rm /media/bb/audio:jazz/Sinatra/In the Wee Wee Hours (or The Wee Wee)
beet asks me if I want to delete both of the albums
I checked and even with 
beet ls /media/bb/audio:jazz/Sinatra/In the Wee Wee Hours (or The Wee Wee)
I get both the albums.

Should beet not differentiate between lower/uppercase?



Discussion:
Thank you for the report. We should make these case sensitive, either by avoiding the SQLite `LIKE` operator altogether or by enabling [the case_sensitive_like pragma](https://www.sqlite.org/pragma.html#pragma_case_sensitive_like). If we do the latter, though, I'm not sure where the best place to run this code is—it likely should _not_ go in the `dbcore` module.

Given that paths on Windows are case-insensitive, what happens to the problem? Is just 1 directory used? I think that platform has to be taken into consideration for the solution, since the current behaviour may be the right one there.

So the solution would be the case_sentive_like pragma on unix platforms. Where if not in `dbcore`? In `library`, as a side effect of the presence of a `PathQuery`?

Hmm, good question. I would love to avoid a global switch altogether and use something local to the query, which should make things more intelligible and more testable, but I don't currently see a way to do that.

A solution could be using the [instr() function](http://www.sqlite.org/lang_corefunc.html#instr) instead of `LIKE` on UNIX platforms, and keeping `LIKE` for Windows.


Resolving patch:
diff --git a/beets/library.py b/beets/library.py
--- a/beets/library.py
+++ b/beets/library.py
@@ -24,6 +24,7 @@
 import time
 import re
 from unidecode import unidecode
+import platform
 
 from beets import logging
 from beets.mediafile import MediaFile, MutagenError, UnreadableFileError
@@ -42,30 +43,50 @@
 # Library-specific query types.
 
 class PathQuery(dbcore.FieldQuery):
-    """A query that matches all items under a given path."""
+    """A query that matches all items under a given path.
+
+    On Windows paths are case-insensitive by default, contrarly to UNIX
+    platforms.
+    """
 
     escape_re = re.compile(r'[\\_%]')
     escape_char = b'\\'
 
-    def __init__(self, field, pattern, fast=True):
+    _is_windows = platform.system() == 'Windows'
+
+    def __init__(self, field, pattern, fast=True, case_sensitive=None):
         super(PathQuery, self).__init__(field, pattern, fast)
 
+        if case_sensitive is None:
+            # setting this value as the default one would make it un-patchable
+            # and therefore un-testable
+            case_sensitive = not self._is_windows
+        if not case_sensitive:
+            pattern = pattern.lower()
+        self.case_sensitive = case_sensitive
+
         # Match the path as a single file.
         self.file_path = util.bytestring_path(util.normpath(pattern))
         # As a directory (prefix).
         self.dir_path = util.bytestring_path(os.path.join(self.file_path, b''))
 
     def match(self, item):
-        return (item.path == self.file_path) or \
-            item.path.startswith(self.dir_path)
+        path = item.path if self.case_sensitive else item.path.lower()
+        return (path == self.file_path) or path.startswith(self.dir_path)
 
     def col_clause(self):
+        file_blob = buffer(self.file_path)
+
+        if self.case_sensitive:
+            dir_blob = buffer(self.dir_path)
+            return '({0} = ?) || (substr({0}, 1, ?) = ?)'.format(self.field), \
+                   (file_blob, len(dir_blob), dir_blob)
+
         escape = lambda m: self.escape_char + m.group(0)
         dir_pattern = self.escape_re.sub(escape, self.dir_path)
-        dir_pattern = buffer(dir_pattern + b'%')
-        file_blob = buffer(self.file_path)
+        dir_blob = buffer(dir_pattern + b'%')
         return '({0} = ?) || ({0} LIKE ? ESCAPE ?)'.format(self.field), \
-               (file_blob, dir_pattern, self.escape_char)
+               (file_blob, dir_blob, self.escape_char)
 
 
 # Library-specific field types.
@@ -1092,11 +1113,12 @@ def parse_query_string(s, model_cls):
 
     The string is split into components using shell-like syntax.
     """
+    assert isinstance(s, unicode), "Query is not unicode: {0!r}".format(s)
+
     # A bug in Python < 2.7.3 prevents correct shlex splitting of
     # Unicode strings.
     # http://bugs.python.org/issue6988
-    if isinstance(s, unicode):
-        s = s.encode('utf8')
+    s = s.encode('utf8')
     try:
         parts = [p.decode('utf8') for p in shlex.split(s)]
     except ValueError as exc:


Test patch:
diff --git a/test/test_library.py b/test/test_library.py
--- a/test/test_library.py
+++ b/test/test_library.py
@@ -1195,6 +1195,10 @@ def test_parse_invalid_query_string(self):
         self.assertIsInstance(raised.exception,
                               beets.dbcore.query.ParsingError)
 
+    def test_parse_bytes(self):
+        with self.assertRaises(AssertionError):
+            beets.library.parse_query_string(b"query", None)
+
 
 def suite():
     return unittest.TestLoader().loadTestsFromName(__name__)
diff --git a/test/test_query.py b/test/test_query.py
--- a/test/test_query.py
+++ b/test/test_query.py
@@ -17,6 +17,9 @@
 from __future__ import (division, absolute_import, print_function,
                         unicode_literals)
 
+from functools import partial
+from mock import patch
+
 from test import _common
 from test._common import unittest
 from test import helper
@@ -461,6 +464,26 @@ def test_escape_backslash(self):
         results = self.lib.albums(q)
         self.assert_albums_matched(results, ['album with backslash'])
 
+    def test_case_sensitivity(self):
+        self.add_album(path='/A/B/C2.mp3', title='caps path')
+
+        makeq = partial(beets.library.PathQuery, 'path', '/A/B')
+
+        results = self.lib.items(makeq(case_sensitive=True))
+        self.assert_items_matched(results, ['caps path'])
+
+        results = self.lib.items(makeq(case_sensitive=False))
+        self.assert_items_matched(results, ['path item', 'caps path'])
+
+        # test platform-aware default sensitivity
+        with patch('beets.library.PathQuery._is_windows', False):
+            q = makeq()
+            self.assertEqual(q.case_sensitive, True)
+
+        with patch('beets.library.PathQuery._is_windows', True):
+            q = makeq()
+            self.assertEqual(q.case_sensitive, False)
+
 
 class IntQueryTest(unittest.TestCase, TestHelper):