diff --git a/beets/library.py b/beets/library.py index 10cddc69d..18b739902 100644 --- a/beets/library.py +++ b/beets/library.py @@ -24,7 +24,6 @@ import unicodedata import time import re from unidecode import unidecode -import platform from beets import logging from beets.mediafile import MediaFile, MutagenError, UnreadableFileError @@ -61,9 +60,11 @@ class PathQuery(dbcore.FieldQuery): """ super(PathQuery, self).__init__(field, pattern, fast) - # By default, the case sensitivity depends on the platform. + # By default, the case sensitivity depends on the filesystem + # the library is located on. if case_sensitive is None: - case_sensitive = platform.system() != 'Windows' + case_sensitive = beets.util.is_filesystem_case_sensitive( + beets.config['directory'].get()) self.case_sensitive = case_sensitive # Use a normalized-case pattern for case-insensitive matches. diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 158a6c8e1..1f9279b25 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -16,6 +16,7 @@ from __future__ import (division, absolute_import, print_function, unicode_literals) +import ctypes import os import sys @@ -760,3 +761,52 @@ def interactive_open(targets, command=None): command += targets return os.execlp(*command) + + +def is_filesystem_case_sensitive(path): + """Checks if the filesystem at the given path is case sensitive. + If the path does not exist, a case sensitive file system is + assumed if the system is not windows. + + :param path: The path to check for case sensitivity. + :return: True if the file system is case sensitive, False else. + """ + if os.path.exists(path): + # Check if the path to the library exists in lower and upper case + if os.path.exists(path.lower()) and \ + os.path.exists(path.upper()): + # All the paths may exist on the file system. Check if they + # refer to different files + if platform.system() != 'Windows': + # os.path.samefile is only available on Unix systems for + # python < 3.0 + return not os.path.samefile(path.lower(), + path.upper()) + + # On windows we use GetLongPathNameW to determine the real path + # using the actual case. + def get_long_path_name(short_path): + if not isinstance(short_path, unicode): + short_path = unicode(short_path) + buf = ctypes.create_unicode_buffer(260) + get_long_path_name_w = ctypes.windll.kernel32.GetLongPathNameW + return_value = get_long_path_name_w(short_path, buf, 260) + if return_value == 0 or return_value > 260: + # An error occurred + return short_path + else: + long_path = buf.value + # GetLongPathNameW does not change the case of the drive + # letter. + if len(long_path) > 1 and long_path[1] == ':': + long_path = long_path[0].upper() + long_path[1:] + return long_path + + lower = get_long_path_name(path.lower()) + upper = get_long_path_name(path.upper()) + + return lower != upper + else: + return True + # By default, the case sensitivity depends on the platform. + return platform.system() != 'Windows' diff --git a/docs/changelog.rst b/docs/changelog.rst index 1aa89ef1e..825a8a36e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -46,6 +46,8 @@ Fixes: written to files. Thanks to :user:`jdetrey`. :bug:`1303` :bug:`1589` * :doc:`/plugins/replaygain`: Avoid a crash when the PyAudioTools backend encounters an error. :bug:`1592` +* The check whether the file system is case sensitive or not could lead to + wrong results. It is much more robust now. * Case-insensitive path queries might have returned nothing because of a wrong SQL query. * Fix a crash when a query contains a "+" or "-" alone in a component. diff --git a/docs/reference/query.rst b/docs/reference/query.rst index e2091bb14..e3ce68004 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -202,8 +202,8 @@ Note that this only matches items that are *already in your library*, so a path query won't necessarily find *all* the audio files in a directory---just the ones you've already added to your beets library. -Path queries are case-sensitive on most platforms but case-insensitive on -Windows. +Path queries are case-sensitive if the file system the library is located on +is case-sensitive, case-insensitive otherwise. .. _query-sort: diff --git a/test/test_query.py b/test/test_query.py index 04235906d..4dcf52a21 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -376,12 +376,17 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): self.i.store() self.lib.add_album([self.i]) - self.patcher = patch('beets.library.os.path.exists') - self.patcher.start().return_value = True + self.patcher_exists = patch('beets.library.os.path.exists') + self.patcher_exists.start().return_value = True + + self.patcher_samefile = patch('beets.library.os.path.samefile') + self.patcher_samefile.start().return_value = True def tearDown(self): super(PathQueryTest, self).tearDown() - self.patcher.stop() + + self.patcher_samefile.stop() + self.patcher_exists.stop() def test_path_exact_match(self): q = 'path:/a/b/c.mp3' @@ -503,7 +508,27 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): results = self.lib.items(makeq(case_sensitive=False)) self.assert_items_matched(results, ['path item', 'caps path']) - # test platform-aware default sensitivity + # Check for correct case sensitivity selection (this check + # only works for non-windows os) + with _common.system_mock('Darwin'): + # exists = True and samefile = True => Case insensitive + q = makeq() + self.assertEqual(q.case_sensitive, False) + + self.patcher_samefile.stop() + self.patcher_samefile.start().return_value = False + + # exists = True and samefile = False => Case sensitive + q = makeq() + self.assertEqual(q.case_sensitive, True) + + self.patcher_samefile.stop() + self.patcher_samefile.start().return_value = True + + # test platform-aware default sensitivity when the library + # path does not exist (exist = False) + self.patcher_exists.stop() + self.patcher_exists.start().return_value = False with _common.system_mock('Darwin'): q = makeq() self.assertEqual(q.case_sensitive, True) @@ -511,6 +536,8 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): with _common.system_mock('Windows'): q = makeq() self.assertEqual(q.case_sensitive, False) + self.patcher_exists.stop() + self.patcher_exists.start().return_value = True @patch('beets.library.os') def test_path_sep_detection(self, mock_os): @@ -526,7 +553,7 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): def test_path_detection(self): # cover existence test - self.patcher.stop() + self.patcher_exists.stop() is_path = beets.library.PathQuery.is_path_query try: @@ -546,7 +573,7 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): finally: os.chdir(cur_dir) finally: - self.patcher.start() + self.patcher_exists.start() class IntQueryTest(unittest.TestCase, TestHelper):