diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 96476a5b1..b0c769790 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -177,6 +177,23 @@ class StringFieldQuery(FieldQuery): raise NotImplementedError() +class StringQuery(StringFieldQuery): + """A query that matches a whole string in a specific item field.""" + + def col_clause(self): + search = (self.pattern + .replace('\\', '\\\\') + .replace('%', '\\%') + .replace('_', '\\_')) + clause = self.field + " like ? escape '\\'" + subvals = [search] + return clause, subvals + + @classmethod + def string_match(cls, pattern, value): + return pattern.lower() == value.lower() + + class SubstringQuery(StringFieldQuery): """A query that matches a substring in a specific item field.""" diff --git a/beets/library.py b/beets/library.py index b913050a4..9442a3647 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1395,7 +1395,11 @@ def parse_query_parts(parts, model_cls): special path query detection. """ # Get query types and their prefix characters. - prefixes = {':': dbcore.query.RegexpQuery} + prefixes = { + ':': dbcore.query.RegexpQuery, + '~': dbcore.query.StringQuery, + '=': dbcore.query.MatchQuery, + } prefixes.update(plugins.queries()) # Special-case path-like queries, which are non-field queries diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 82e62af62..63bfea9b3 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -22,6 +22,7 @@ import subprocess import tempfile import shlex from string import Template +import logging from beets import ui, util, plugins, config from beets.plugins import BeetsPlugin @@ -514,23 +515,21 @@ class ConvertPlugin(BeetsPlugin): except subprocess.CalledProcessError: return - pretend = self.config['pretend'].get(bool) - quiet = self.config['quiet'].get(bool) + # Change the newly-imported database entry to point to the + # converted file. + source_path = item.path + item.path = dest + item.write() + item.read() # Load new audio information data. + item.store() - if not pretend: - # Change the newly-imported database entry to point to the - # converted file. - source_path = item.path - item.path = dest - item.write() - item.read() # Load new audio information data. - item.store() - - if self.config['delete_originals']: - if not quiet: - self._log.info('Removing original file {0}', - source_path) - util.remove(source_path, False) + if self.config['delete_originals']: + self._log.log( + logging.DEBUG if self.config['quiet'] else logging.INFO, + 'Removing original file {0}', + source_path, + ) + util.remove(source_path, False) def _cleanup(self, task, session): for path in task.old_paths: diff --git a/docs/changelog.rst b/docs/changelog.rst index 462567e10..d1a3b6205 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,7 @@ New features: * :doc:`/plugins/kodiupdate`: Now supports multiple kodi instances :bug:`4101` * Add the item fields ``bitrate_mode``, ``encoder_info`` and ``encoder_settings``. +* Add query prefixes ``=`` and ``~``. * :doc:`/reference/config`: Allow to configure which fields are used to find duplicates Bug fixes: @@ -32,8 +33,6 @@ Bug fixes: * Fix a regression in the previous release that caused a `TypeError` when moving files across filesystems. :bug:`4168` -* :doc:`/plugins/convert`: Files are no longer converted when running import in - ``--pretend`` mode. * :doc:`/plugins/convert`: Deleting the original files during conversion no longer logs output when the ``quiet`` flag is enabled. * :doc:`plugins/web`: Fix handling of "query" requests. Previously queries diff --git a/docs/reference/query.rst b/docs/reference/query.rst index 5c16f610b..75fac3015 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -93,14 +93,45 @@ backslashes are not part of beets' syntax; I'm just using the escaping functionality of my shell (bash or zsh, for instance) to pass ``the rebel`` as a single argument instead of two. +Exact Matches +------------- + +While ordinary queries perform *substring* matches, beets can also match whole +strings by adding either ``=`` (case-sensitive) or ``~`` (ignore case) after the +field name's colon and before the expression:: + + $ beet list artist:air + $ beet list artist:~air + $ beet list artist:=AIR + +The first query is a simple substring one that returns tracks by Air, AIR, and +Air Supply. The second query returns tracks by Air and AIR, since both are a +case-insensitive match for the entire expression, but does not return anything +by Air Supply. The third query, which requires a case-sensitive exact match, +returns tracks by AIR only. + +Exact matches may be performed on phrases as well:: + + $ beet list artist:~"dave matthews" + $ beet list artist:="Dave Matthews" + +Both of these queries return tracks by Dave Matthews, but not by Dave Matthews +Band. + +To search for exact matches across *all* fields, just prefix the expression with +a single ``=`` or ``~``:: + + $ beet list ~crash + $ beet list ="American Football" + .. _regex: Regular Expressions ------------------- -While ordinary keywords perform simple substring matches, beets also supports -regular expression matching for more advanced queries. To run a regex query, use -an additional ``:`` between the field name and the expression:: +In addition to simple substring and exact matches, beets also supports regular +expression matching for more advanced queries. To run a regex query, use an +additional ``:`` between the field name and the expression:: $ beet list "artist::Ann(a|ie)" diff --git a/test/test_convert.py b/test/test_convert.py index 493d4ecca..cd32e34b1 100644 --- a/test/test_convert.py +++ b/test/test_convert.py @@ -127,16 +127,6 @@ class ImportConvertTest(unittest.TestCase, TestHelper): 'Non-empty import directory {}' .format(util.displayable_path(path))) - def test_delete_originals_keeps_originals_when_pretend_enabled(self): - import_file_count = self.get_count_of_import_files() - - self.config['convert']['delete_originals'] = True - self.config['convert']['pretend'] = True - self.importer.run() - - self.assertEqual(self.get_count_of_import_files(), import_file_count, - 'Count of files differs after running import') - def get_count_of_import_files(self): import_file_count = 0 diff --git a/test/test_query.py b/test/test_query.py index 14f3f082a..0be4b7d7f 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -94,16 +94,19 @@ class DummyDataTestCase(_common.TestCase, AssertsMixin): items[0].album = 'baz' items[0].year = 2001 items[0].comp = True + items[0].genre = 'rock' items[1].title = 'baz qux' items[1].artist = 'two' items[1].album = 'baz' items[1].year = 2002 items[1].comp = True + items[1].genre = 'Rock' items[2].title = 'beets 4 eva' items[2].artist = 'three' items[2].album = 'foo' items[2].year = 2003 items[2].comp = False + items[2].genre = 'Hard Rock' for item in items: self.lib.add(item) self.album = self.lib.add_album(items[:2]) @@ -132,6 +135,22 @@ class GetTest(DummyDataTestCase): results = self.lib.items(q) self.assert_items_matched(results, ['baz qux']) + def test_get_one_keyed_exact(self): + q = 'genre:=rock' + results = self.lib.items(q) + self.assert_items_matched(results, ['foo bar']) + q = 'genre:=Rock' + results = self.lib.items(q) + self.assert_items_matched(results, ['baz qux']) + q = 'genre:="Hard Rock"' + results = self.lib.items(q) + self.assert_items_matched(results, ['beets 4 eva']) + + def test_get_one_keyed_exact_nocase(self): + q = 'genre:~"hard rock"' + results = self.lib.items(q) + self.assert_items_matched(results, ['beets 4 eva']) + def test_get_one_keyed_regexp(self): q = 'artist::t.+r' results = self.lib.items(q) @@ -142,6 +161,16 @@ class GetTest(DummyDataTestCase): results = self.lib.items(q) self.assert_items_matched(results, ['beets 4 eva']) + def test_get_one_unkeyed_exact(self): + q = '=rock' + results = self.lib.items(q) + self.assert_items_matched(results, ['foo bar']) + + def test_get_one_unkeyed_exact_nocase(self): + q = '~"hard rock"' + results = self.lib.items(q) + self.assert_items_matched(results, ['beets 4 eva']) + def test_get_one_unkeyed_regexp(self): q = ':x$' results = self.lib.items(q) @@ -159,6 +188,11 @@ class GetTest(DummyDataTestCase): # objects. self.assert_items_matched(results, []) + def test_get_no_matches_exact(self): + q = 'genre:="hard rock"' + results = self.lib.items(q) + self.assert_items_matched(results, []) + def test_term_case_insensitive(self): q = 'oNE' results = self.lib.items(q) @@ -182,6 +216,14 @@ class GetTest(DummyDataTestCase): results = self.lib.items(q) self.assert_items_matched(results, ['beets 4 eva']) + def test_keyed_matches_exact_nocase(self): + q = 'genre:~rock' + results = self.lib.items(q) + self.assert_items_matched(results, [ + 'foo bar', + 'baz qux', + ]) + def test_unkeyed_term_matches_multiple_columns(self): q = 'baz' results = self.lib.items(q) @@ -350,6 +392,16 @@ class MatchTest(_common.TestCase): q = dbcore.query.SubstringQuery('disc', '6') self.assertTrue(q.match(self.item)) + def test_exact_match_nocase_positive(self): + q = dbcore.query.StringQuery('genre', 'the genre') + self.assertTrue(q.match(self.item)) + q = dbcore.query.StringQuery('genre', 'THE GENRE') + self.assertTrue(q.match(self.item)) + + def test_exact_match_nocase_negative(self): + q = dbcore.query.StringQuery('genre', 'genre') + self.assertFalse(q.match(self.item)) + def test_year_match_positive(self): q = dbcore.query.NumericQuery('year', '1') self.assertTrue(q.match(self.item))