Skip to content

Commit

Permalink
Merge branch 'master' into duplicate
Browse files Browse the repository at this point in the history
# Conflicts:
#	docs/changelog.rst
  • Loading branch information
jcassette committed Jan 30, 2022
2 parents bcf2e15 + 10338c2 commit bf9bf48
Show file tree
Hide file tree
Showing 7 changed files with 125 additions and 33 deletions.
17 changes: 17 additions & 0 deletions beets/dbcore/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,23 @@ def string_match(cls, pattern, value):
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."""

Expand Down
6 changes: 5 additions & 1 deletion beets/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 16 additions & 17 deletions beetsplug/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import tempfile
import shlex
from string import Template
import logging

from beets import ui, util, plugins, config
from beets.plugins import BeetsPlugin
Expand Down Expand Up @@ -514,23 +515,21 @@ def convert_on_import(self, lib, item):
except subprocess.CalledProcessError:
return

pretend = self.config['pretend'].get(bool)
quiet = self.config['quiet'].get(bool)

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)
# 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']:
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:
Expand Down
3 changes: 1 addition & 2 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
37 changes: 34 additions & 3 deletions docs/reference/query.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)"

Expand Down
10 changes: 0 additions & 10 deletions test/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,6 @@ def test_delete_originals(self):
'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

Expand Down
52 changes: 52 additions & 0 deletions test/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,19 @@ def setUp(self):
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])
Expand Down Expand Up @@ -132,6 +135,22 @@ def test_get_one_keyed_term(self):
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)
Expand All @@ -142,6 +161,16 @@ def test_get_one_unkeyed_term(self):
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)
Expand All @@ -159,6 +188,11 @@ def test_invalid_key(self):
# 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)
Expand All @@ -182,6 +216,14 @@ def test_key_case_insensitive(self):
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)
Expand Down Expand Up @@ -350,6 +392,16 @@ def test_substring_match_non_string_value(self):
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))
Expand Down

0 comments on commit bf9bf48

Please sign in to comment.