mirror of
https://github.com/beetbox/beets.git
synced 2026-01-17 05:34:23 +01:00
Merge branch 'master' of https://github.com/sampsyo/beets
This commit is contained in:
commit
9c0fa57f34
23 changed files with 515 additions and 375 deletions
|
|
@ -33,7 +33,7 @@ shockingly simple if you know a little Python.
|
|||
|
||||
.. _plugins: http://beets.readthedocs.org/page/plugins/
|
||||
.. _MPD: http://mpd.wikia.com/
|
||||
.. _MusicBrainz music collection: http://musicbrainz.org/show/collection/
|
||||
.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/
|
||||
.. _writing your own plugin:
|
||||
http://beets.readthedocs.org/page/plugins/#writing-plugins
|
||||
.. _HTML5 Audio:
|
||||
|
|
|
|||
|
|
@ -45,13 +45,8 @@ class MusicBrainzAPIError(util.HumanReadableException):
|
|||
log = logging.getLogger('beets')
|
||||
|
||||
RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups',
|
||||
'labels', 'artist-credits']
|
||||
TRACK_INCLUDES = ['artists']
|
||||
|
||||
# Only versions >= 0.3 of python-musicbrainz-ngs support artist aliases.
|
||||
if musicbrainzngs.musicbrainz._version >= '0.3':
|
||||
RELEASE_INCLUDES.append('aliases')
|
||||
TRACK_INCLUDES.append('aliases')
|
||||
'labels', 'artist-credits', 'aliases']
|
||||
TRACK_INCLUDES = ['artists', 'aliases']
|
||||
|
||||
def configure():
|
||||
"""Set up the python-musicbrainz-ngs module according to settings
|
||||
|
|
|
|||
351
beets/library.py
351
beets/library.py
|
|
@ -175,21 +175,6 @@ def _orelse(exp1, exp2):
|
|||
'WHEN "" THEN {1} '
|
||||
'ELSE {0} END)').format(exp1, exp2)
|
||||
|
||||
# An SQLite function for regular expression matching.
|
||||
def _regexp(expr, val):
|
||||
"""Return a boolean indicating whether the regular expression `expr`
|
||||
matches `val`.
|
||||
"""
|
||||
if expr is None:
|
||||
return False
|
||||
val = util.as_string(val)
|
||||
try:
|
||||
res = re.search(expr, val)
|
||||
except re.error:
|
||||
# Invalid regular expression.
|
||||
return False
|
||||
return res is not None
|
||||
|
||||
# Path element formatting for templating.
|
||||
def format_for_path(value, key=None, pathmod=None):
|
||||
"""Sanitize the value for inclusion in a path: replace separators
|
||||
|
|
@ -469,12 +454,51 @@ class Query(object):
|
|||
|
||||
class FieldQuery(Query):
|
||||
"""An abstract query that searches in a specific field for a
|
||||
pattern.
|
||||
pattern. Subclasses must provide a `value_match` class method, which
|
||||
determines whether a certain pattern string matches a certain value
|
||||
string. Subclasses also need to provide `clause` to implement the
|
||||
same matching functionality in SQLite.
|
||||
"""
|
||||
def __init__(self, field, pattern):
|
||||
self.field = field
|
||||
self.pattern = pattern
|
||||
|
||||
@classmethod
|
||||
def value_match(cls, pattern, value):
|
||||
"""Determine whether the value matches the pattern. Both
|
||||
arguments are strings.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def _raw_value_match(cls, pattern, value):
|
||||
"""Determine whether the value matches the pattern. The value
|
||||
may have any type.
|
||||
"""
|
||||
return cls.value_match(pattern, util.as_string(value))
|
||||
|
||||
def match(self, item):
|
||||
return self._raw_value_match(self.pattern, getattr(item, self.field))
|
||||
|
||||
class RegisteredFieldQuery(FieldQuery):
|
||||
"""A FieldQuery that uses a registered SQLite callback function.
|
||||
Before it can be used to execute queries, the `register` method must
|
||||
be called.
|
||||
"""
|
||||
def clause(self):
|
||||
# Invoke the registered SQLite function.
|
||||
clause = "{name}(?, {field})".format(name=self.__class__.__name__,
|
||||
field=self.field)
|
||||
return clause, [self.pattern]
|
||||
|
||||
@classmethod
|
||||
def register(cls, conn):
|
||||
"""Register this query's matching function with the SQLite
|
||||
connection. This method should only be invoked when the query
|
||||
type chooses not to override `clause`.
|
||||
"""
|
||||
conn.create_function(cls.__name__, 2, cls._raw_value_match)
|
||||
|
||||
class MatchQuery(FieldQuery):
|
||||
"""A query that looks for exact matches in an item field."""
|
||||
def clause(self):
|
||||
|
|
@ -483,8 +507,11 @@ class MatchQuery(FieldQuery):
|
|||
pattern = buffer(pattern)
|
||||
return self.field + " = ?", [pattern]
|
||||
|
||||
def match(self, item):
|
||||
return self.pattern == getattr(item, self.field)
|
||||
# We override the "raw" version here as a special case because we
|
||||
# want to compare objects before conversion.
|
||||
@classmethod
|
||||
def _raw_value_match(cls, pattern, value):
|
||||
return pattern == value
|
||||
|
||||
class SubstringQuery(FieldQuery):
|
||||
"""A query that matches a substring in a specific item field."""
|
||||
|
|
@ -495,24 +522,22 @@ class SubstringQuery(FieldQuery):
|
|||
subvals = [search]
|
||||
return clause, subvals
|
||||
|
||||
def match(self, item):
|
||||
value = util.as_string(getattr(item, self.field))
|
||||
return self.pattern.lower() in value.lower()
|
||||
@classmethod
|
||||
def value_match(cls, pattern, value):
|
||||
return pattern.lower() in value.lower()
|
||||
|
||||
class RegexpQuery(FieldQuery):
|
||||
"""A query that matches a regular expression in a specific item field."""
|
||||
def __init__(self, field, pattern):
|
||||
super(RegexpQuery, self).__init__(field, pattern)
|
||||
self.regexp = re.compile(pattern)
|
||||
|
||||
def clause(self):
|
||||
clause = self.field + " REGEXP ?"
|
||||
subvals = [self.pattern]
|
||||
return clause, subvals
|
||||
|
||||
def match(self, item):
|
||||
value = util.as_string(getattr(item, self.field))
|
||||
return self.regexp.search(value) is not None
|
||||
class RegexpQuery(RegisteredFieldQuery):
|
||||
"""A query that matches a regular expression in a specific item
|
||||
field.
|
||||
"""
|
||||
@classmethod
|
||||
def value_match(cls, pattern, value):
|
||||
try:
|
||||
res = re.search(pattern, value)
|
||||
except re.error:
|
||||
# Invalid regular expression.
|
||||
return False
|
||||
return res is not None
|
||||
|
||||
class BooleanQuery(MatchQuery):
|
||||
"""Matches a boolean field. Pattern should either be a boolean or a
|
||||
|
|
@ -564,103 +589,25 @@ class CollectionQuery(Query):
|
|||
clause = (' ' + joiner + ' ').join(clause_parts)
|
||||
return clause, subvals
|
||||
|
||||
# Regular expression for _parse_query_part, below.
|
||||
_pq_regex = re.compile(
|
||||
# Non-capturing optional segment for the keyword.
|
||||
r'(?:'
|
||||
r'(\S+?)' # The field key.
|
||||
r'(?<!\\):' # Unescaped :
|
||||
r')?'
|
||||
|
||||
r'((?<!\\):?)' # Unescaped : indicating a regex.
|
||||
r'(.+)', # The term itself.
|
||||
|
||||
re.I # Case-insensitive.
|
||||
)
|
||||
@classmethod
|
||||
def _parse_query_part(cls, part):
|
||||
"""Takes a query in the form of a key/value pair separated by a
|
||||
colon. An additional colon before the value indicates that the
|
||||
value is a regular expression. Returns tuple (key, term,
|
||||
is_regexp) where key is None if the search term has no key and
|
||||
is_regexp indicates whether term is a regular expression or an
|
||||
ordinary substring match.
|
||||
|
||||
For instance,
|
||||
parse_query('stapler') == (None, 'stapler', false)
|
||||
parse_query('color:red') == ('color', 'red', false)
|
||||
parse_query(':^Quiet') == (None, '^Quiet', true)
|
||||
parse_query('color::b..e') == ('color', 'b..e', true)
|
||||
|
||||
Colons may be 'escaped' with a backslash to disable the keying
|
||||
behavior.
|
||||
"""
|
||||
part = part.strip()
|
||||
match = cls._pq_regex.match(part)
|
||||
if match:
|
||||
return (
|
||||
match.group(1), # Key.
|
||||
match.group(3).replace(r'\:', ':'), # Term.
|
||||
match.group(2) == ':', # Regular expression.
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_strings(cls, query_parts, default_fields=None,
|
||||
all_keys=ITEM_KEYS):
|
||||
def from_strings(cls, query_parts, default_fields, all_keys):
|
||||
"""Creates a query from a list of strings in the format used by
|
||||
_parse_query_part. If default_fields are specified, they are the
|
||||
parse_query_part. If default_fields are specified, they are the
|
||||
fields to be searched by unqualified search terms. Otherwise,
|
||||
all fields are searched for those terms.
|
||||
"""
|
||||
subqueries = []
|
||||
for part in query_parts:
|
||||
res = cls._parse_query_part(part)
|
||||
if not res:
|
||||
continue
|
||||
key, pattern, is_regexp = res
|
||||
|
||||
# No key specified.
|
||||
if key is None:
|
||||
if os.sep in pattern and 'path' in all_keys:
|
||||
# This looks like a path.
|
||||
subqueries.append(PathQuery(pattern))
|
||||
else:
|
||||
# Match any field.
|
||||
if is_regexp:
|
||||
subq = AnyRegexpQuery(pattern, default_fields)
|
||||
else:
|
||||
subq = AnySubstringQuery(pattern, default_fields)
|
||||
subqueries.append(subq)
|
||||
|
||||
# A boolean field.
|
||||
elif key.lower() == 'comp':
|
||||
subqueries.append(BooleanQuery(key.lower(), pattern))
|
||||
|
||||
# Path field.
|
||||
elif key.lower() == 'path' and 'path' in all_keys:
|
||||
subqueries.append(PathQuery(pattern))
|
||||
|
||||
# Other (recognized) field.
|
||||
elif key.lower() in all_keys:
|
||||
if is_regexp:
|
||||
subqueries.append(RegexpQuery(key.lower(), pattern))
|
||||
else:
|
||||
subqueries.append(SubstringQuery(key.lower(), pattern))
|
||||
|
||||
# Singleton query (not a real field).
|
||||
elif key.lower() == 'singleton':
|
||||
subqueries.append(SingletonQuery(util.str2bool(pattern)))
|
||||
|
||||
# Unrecognized field.
|
||||
else:
|
||||
log.warn(u'no such field in query: {0}'.format(key))
|
||||
|
||||
subq = construct_query_part(part, default_fields, all_keys)
|
||||
if subq:
|
||||
subqueries.append(subq)
|
||||
if not subqueries: # No terms in query.
|
||||
subqueries = [TrueQuery()]
|
||||
return cls(subqueries)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, query, default_fields=None, all_keys=ITEM_KEYS):
|
||||
def from_string(cls, query, default_fields=ITEM_DEFAULT_FIELDS,
|
||||
all_keys=ITEM_KEYS):
|
||||
"""Creates a query based on a single string. The string is split
|
||||
into query parts using shell-style syntax.
|
||||
"""
|
||||
|
|
@ -670,71 +617,32 @@ class CollectionQuery(Query):
|
|||
if isinstance(query, unicode):
|
||||
query = query.encode('utf8')
|
||||
parts = [s.decode('utf8') for s in shlex.split(query)]
|
||||
return cls.from_strings(parts, default_fields=default_fields,
|
||||
all_keys=all_keys)
|
||||
return cls.from_strings(parts, default_fields, all_keys)
|
||||
|
||||
class AnySubstringQuery(CollectionQuery):
|
||||
"""A query that matches a substring in any of a list of metadata
|
||||
fields.
|
||||
class AnyFieldQuery(CollectionQuery):
|
||||
"""A query that matches if a given FieldQuery subclass matches in
|
||||
any field. The individual field query class is provided to the
|
||||
constructor.
|
||||
"""
|
||||
def __init__(self, pattern, fields=None):
|
||||
"""Create a query for pattern over the sequence of fields
|
||||
given. If no fields are given, all available fields are
|
||||
used.
|
||||
"""
|
||||
def __init__(self, pattern, fields, cls):
|
||||
self.pattern = pattern
|
||||
self.fields = fields or ITEM_KEYS_WRITABLE
|
||||
self.fields = fields
|
||||
self.query_class = cls
|
||||
|
||||
subqueries = []
|
||||
for field in self.fields:
|
||||
subqueries.append(SubstringQuery(field, pattern))
|
||||
super(AnySubstringQuery, self).__init__(subqueries)
|
||||
subqueries.append(cls(field, pattern))
|
||||
super(AnyFieldQuery, self).__init__(subqueries)
|
||||
|
||||
def clause(self):
|
||||
return self.clause_with_joiner('or')
|
||||
|
||||
def match(self, item):
|
||||
for fld in self.fields:
|
||||
try:
|
||||
val = getattr(item, fld)
|
||||
except KeyError:
|
||||
continue
|
||||
if isinstance(val, basestring) and \
|
||||
self.pattern.lower() in val.lower():
|
||||
for subq in self.subqueries:
|
||||
if subq.match(item):
|
||||
return True
|
||||
return False
|
||||
|
||||
class AnyRegexpQuery(CollectionQuery):
|
||||
"""A query that matches a regexp in any of a list of metadata
|
||||
fields.
|
||||
"""
|
||||
def __init__(self, pattern, fields=None):
|
||||
"""Create a query for regexp over the sequence of fields
|
||||
given. If no fields are given, all available fields are
|
||||
used.
|
||||
"""
|
||||
self.regexp = re.compile(pattern)
|
||||
self.fields = fields or ITEM_KEYS_WRITABLE
|
||||
|
||||
subqueries = []
|
||||
for field in self.fields:
|
||||
subqueries.append(RegexpQuery(field, pattern))
|
||||
super(AnyRegexpQuery, self).__init__(subqueries)
|
||||
|
||||
def clause(self):
|
||||
return self.clause_with_joiner('or')
|
||||
|
||||
def match(self, item):
|
||||
for fld in self.fields:
|
||||
try:
|
||||
val = getattr(item, fld)
|
||||
except KeyError:
|
||||
continue
|
||||
if isinstance(val, basestring) and \
|
||||
self.regexp.match(val) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class MutableCollectionQuery(CollectionQuery):
|
||||
"""A collection query whose subqueries may be modified after the
|
||||
query is initialized.
|
||||
|
|
@ -798,6 +706,96 @@ class ResultIterator(object):
|
|||
row = self.rowiter.next() # May raise StopIteration.
|
||||
return Item(row)
|
||||
|
||||
# Regular expression for parse_query_part, below.
|
||||
PARSE_QUERY_PART_REGEX = re.compile(
|
||||
# Non-capturing optional segment for the keyword.
|
||||
r'(?:'
|
||||
r'(\S+?)' # The field key.
|
||||
r'(?<!\\):' # Unescaped :
|
||||
r')?'
|
||||
|
||||
r'(.+)', # The term itself.
|
||||
|
||||
re.I # Case-insensitive.
|
||||
)
|
||||
def parse_query_part(part):
|
||||
"""Takes a query in the form of a key/value pair separated by a
|
||||
colon. The value part is matched against a list of prefixes that
|
||||
can be extended by plugins to add custom query types. For
|
||||
example, the colon prefix denotes a regular expression query.
|
||||
|
||||
The function returns a tuple of `(key, value, cls)`. `key` may
|
||||
be None, indicating that any field may be matched. `cls` is a
|
||||
subclass of `FieldQuery`.
|
||||
|
||||
For instance,
|
||||
parse_query('stapler') == (None, 'stapler', None)
|
||||
parse_query('color:red') == ('color', 'red', None)
|
||||
parse_query(':^Quiet') == (None, '^Quiet', RegexpQuery)
|
||||
parse_query('color::b..e') == ('color', 'b..e', RegexpQuery)
|
||||
|
||||
Prefixes may be 'escaped' with a backslash to disable the keying
|
||||
behavior.
|
||||
"""
|
||||
part = part.strip()
|
||||
match = PARSE_QUERY_PART_REGEX.match(part)
|
||||
|
||||
prefixes = {':': RegexpQuery}
|
||||
prefixes.update(plugins.queries())
|
||||
|
||||
if match:
|
||||
key = match.group(1)
|
||||
term = match.group(2).replace('\:', ':')
|
||||
# Match the search term against the list of prefixes.
|
||||
for pre, query_class in prefixes.items():
|
||||
if term.startswith(pre):
|
||||
return key, term[len(pre):], query_class
|
||||
return key, term, SubstringQuery # The default query type.
|
||||
|
||||
def construct_query_part(query_part, default_fields, all_keys):
|
||||
"""Create a query from a single query component. Return a Query
|
||||
instance or None if the value cannot be parsed.
|
||||
"""
|
||||
parsed = parse_query_part(query_part)
|
||||
if not parsed:
|
||||
return
|
||||
|
||||
key, pattern, query_class = parsed
|
||||
|
||||
# No key specified.
|
||||
if key is None:
|
||||
if os.sep in pattern and 'path' in all_keys:
|
||||
# This looks like a path.
|
||||
return PathQuery(pattern)
|
||||
elif issubclass(query_class, FieldQuery):
|
||||
# The query type matches a specific field, but none was
|
||||
# specified. So we use a version of the query that matches
|
||||
# any field.
|
||||
return AnyFieldQuery(pattern, default_fields, query_class)
|
||||
else:
|
||||
# Other query type.
|
||||
return query_class(pattern)
|
||||
|
||||
# A boolean field.
|
||||
elif key.lower() == 'comp':
|
||||
return BooleanQuery(key.lower(), pattern)
|
||||
|
||||
# Path field.
|
||||
elif key.lower() == 'path' and 'path' in all_keys:
|
||||
return PathQuery(pattern)
|
||||
|
||||
# Other (recognized) field.
|
||||
elif key.lower() in all_keys:
|
||||
return query_class(key.lower(), pattern)
|
||||
|
||||
# Singleton query (not a real field).
|
||||
elif key.lower() == 'singleton':
|
||||
return SingletonQuery(util.str2bool(pattern))
|
||||
|
||||
# Unrecognized field.
|
||||
else:
|
||||
log.warn(u'no such field in query: {0}'.format(key))
|
||||
|
||||
def get_query(val, album=False):
|
||||
"""Takes a value which may be None, a query string, a query string
|
||||
list, or a Query object, and returns a suitable Query object. album
|
||||
|
|
@ -825,7 +823,6 @@ def get_query(val, album=False):
|
|||
raise ValueError('query must be None or have type Query or str')
|
||||
|
||||
|
||||
|
||||
# An abstract library.
|
||||
|
||||
class BaseLibrary(object):
|
||||
|
|
@ -1123,8 +1120,12 @@ class Library(BaseLibrary):
|
|||
|
||||
# Access SELECT results like dictionaries.
|
||||
conn.row_factory = sqlite3.Row
|
||||
# Add the REGEXP function to SQLite queries.
|
||||
conn.create_function("REGEXP", 2, _regexp)
|
||||
|
||||
# Register plugin queries.
|
||||
RegexpQuery.register(conn)
|
||||
for prefix, query_class in plugins.queries().items():
|
||||
if issubclass(query_class, RegisteredFieldQuery):
|
||||
query_class.register(conn)
|
||||
|
||||
self._connections[thread_id] = conn
|
||||
return conn
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ class BeetsPlugin(object):
|
|||
commands that should be added to beets' CLI.
|
||||
"""
|
||||
return ()
|
||||
|
||||
def queries(self):
|
||||
"""Should return a dict mapping prefixes to PluginQuery
|
||||
subclasses.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def track_distance(self, item, info):
|
||||
"""Should return a (distance, distance_max) pair to be added
|
||||
|
|
@ -93,6 +99,7 @@ class BeetsPlugin(object):
|
|||
"""
|
||||
return {}
|
||||
|
||||
|
||||
listeners = None
|
||||
|
||||
@classmethod
|
||||
|
|
@ -209,6 +216,15 @@ def commands():
|
|||
out += plugin.commands()
|
||||
return out
|
||||
|
||||
def queries():
|
||||
"""Returns a dict mapping prefix strings to beet.library.PluginQuery
|
||||
subclasses all loaded plugins.
|
||||
"""
|
||||
out = {}
|
||||
for plugin in find_plugins():
|
||||
out.update(plugin.queries())
|
||||
return out
|
||||
|
||||
def track_distance(item, info):
|
||||
"""Gets the track distance calculated by all loaded plugins.
|
||||
Returns a (distance, distance_max) pair.
|
||||
|
|
|
|||
|
|
@ -643,9 +643,11 @@ def import_files(lib, paths, query):
|
|||
for path in paths:
|
||||
fullpath = syspath(normpath(path))
|
||||
if not config['import']['singletons'] and not os.path.isdir(fullpath):
|
||||
raise ui.UserError('not a directory: ' + path)
|
||||
raise ui.UserError(u'not a directory: {0}'.format(
|
||||
displayable_path(path)))
|
||||
elif config['import']['singletons'] and not os.path.exists(fullpath):
|
||||
raise ui.UserError('no such file: ' + path)
|
||||
raise ui.UserError(u'no such file: {0}'.format(
|
||||
displayable_path(path)))
|
||||
|
||||
# Check parameter consistency.
|
||||
if config['import']['quiet'] and config['import']['timid']:
|
||||
|
|
|
|||
|
|
@ -157,7 +157,16 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
raise ui.UserError('no Acoustid user API key provided')
|
||||
submit_items(apikey, lib.items(ui.decargs(args)))
|
||||
submit_cmd.func = submit_cmd_func
|
||||
return [submit_cmd]
|
||||
|
||||
fingerprint_cmd = ui.Subcommand('fingerprint',
|
||||
help='generate fingerprints for items without them')
|
||||
def fingerprint_cmd_func(lib, opts, args):
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
fingerprint_item(item, lib=lib,
|
||||
write=config['import']['write'].get(bool))
|
||||
fingerprint_cmd.func = fingerprint_cmd_func
|
||||
|
||||
return [submit_cmd, fingerprint_cmd]
|
||||
|
||||
|
||||
# Hooks into import process.
|
||||
|
|
@ -191,32 +200,14 @@ def submit_items(userkey, items, chunksize=64):
|
|||
def submit_chunk():
|
||||
"""Submit the current accumulated fingerprint data."""
|
||||
log.info('submitting {0} fingerprints'.format(len(data)))
|
||||
acoustid.submit(API_KEY, userkey, data)
|
||||
try:
|
||||
acoustid.submit(API_KEY, userkey, data)
|
||||
except acoustid.AcoustidError as exc:
|
||||
log.warn(u'acoustid submission error: {}'.format(exc))
|
||||
del data[:]
|
||||
|
||||
for item in items:
|
||||
# Get a fingerprint and length for this track.
|
||||
if not item.length:
|
||||
log.info(u'{0}: no duration available'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
continue
|
||||
elif item.acoustid_fingerprint:
|
||||
log.info(u'{0}: using existing fingerprint'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
fp = item.acoustid_fingerprint
|
||||
else:
|
||||
log.info(u'{0}: fingerprinting'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
try:
|
||||
_, fp = acoustid.fingerprint_file(item.path)
|
||||
except acoustid.FingerprintGenerationError as exc:
|
||||
log.info(
|
||||
'fingerprint generation failed: {0}'.format(exc)
|
||||
)
|
||||
continue
|
||||
fp = fingerprint_item(item)
|
||||
|
||||
# Construct a submission dictionary for this item.
|
||||
item_data = {
|
||||
|
|
@ -246,3 +237,46 @@ def submit_items(userkey, items, chunksize=64):
|
|||
# Submit remaining data in a final chunk.
|
||||
if data:
|
||||
submit_chunk()
|
||||
|
||||
|
||||
def fingerprint_item(item, lib=None, write=False):
|
||||
"""Get the fingerprint for an Item. If the item already has a
|
||||
fingerprint, it is not regenerated. If fingerprint generation fails,
|
||||
return None. If `lib` is provided, then new fingerprints are saved
|
||||
to the database. If `write` is set, then the new fingerprints are
|
||||
also written to files' metadata.
|
||||
"""
|
||||
# Get a fingerprint and length for this track.
|
||||
if not item.length:
|
||||
log.info(u'{0}: no duration available'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
elif item.acoustid_fingerprint:
|
||||
if write:
|
||||
log.info(u'{0}: fingerprint exists, skipping'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
else:
|
||||
log.info(u'{0}: using existing fingerprint'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
return item.acoustid_fingerprint
|
||||
else:
|
||||
log.info(u'{0}: fingerprinting'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
try:
|
||||
_, fp = acoustid.fingerprint_file(item.path)
|
||||
item.acoustid_fingerprint = fp
|
||||
if write:
|
||||
log.info(u'{0}: writing fingerprint'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
item.write()
|
||||
if lib:
|
||||
lib.store(item)
|
||||
return item.acoustid_fingerprint
|
||||
except acoustid.FingerprintGenerationError as exc:
|
||||
log.info(
|
||||
'fingerprint generation failed: {0}'.format(exc)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import logging
|
|||
import os
|
||||
import threading
|
||||
from subprocess import Popen
|
||||
import tempfile
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import ui, util
|
||||
|
|
@ -27,14 +28,29 @@ from beets import config
|
|||
log = logging.getLogger('beets')
|
||||
DEVNULL = open(os.devnull, 'wb')
|
||||
_fs_lock = threading.Lock()
|
||||
_temp_files = [] # Keep track of temporary transcoded files for deletion.
|
||||
|
||||
|
||||
def _destination(lib, dest_dir, item, keep_new):
|
||||
"""Return the path under `dest_dir` where the file should be placed
|
||||
(possibly after conversion).
|
||||
"""
|
||||
dest = lib.destination(item, basedir=dest_dir)
|
||||
if keep_new:
|
||||
# When we're keeping the converted file, no extension munging
|
||||
# occurs.
|
||||
return dest
|
||||
else:
|
||||
# Otherwise, replace the extension with .mp3.
|
||||
return os.path.splitext(dest)[0] + '.mp3'
|
||||
|
||||
|
||||
def encode(source, dest):
|
||||
log.info(u'Started encoding {0}'.format(util.displayable_path(source)))
|
||||
|
||||
opts = config['convert']['opts'].get(unicode).split(u' ')
|
||||
encode = Popen([config['convert']['ffmpeg'].get(unicode), '-i', source] +
|
||||
opts + [dest],
|
||||
encode = Popen([config['convert']['ffmpeg'].get(unicode), '-i',
|
||||
source, '-y'] + opts + [dest],
|
||||
close_fds=True, stderr=DEVNULL)
|
||||
encode.wait()
|
||||
if encode.returncode != 0:
|
||||
|
|
@ -47,12 +63,18 @@ def encode(source, dest):
|
|||
log.info(u'Finished encoding {0}'.format(util.displayable_path(source)))
|
||||
|
||||
|
||||
def convert_item(lib, dest_dir):
|
||||
def should_transcode(item):
|
||||
"""Determine whether the item should be transcoded as part of
|
||||
conversion (i.e., its bitrate is high or it has the wrong format).
|
||||
"""
|
||||
maxbr = config['convert']['max_bitrate'].get(int)
|
||||
return item.format != 'MP3' or item.bitrate >= 1000 * maxbr
|
||||
|
||||
|
||||
def convert_item(lib, dest_dir, keep_new):
|
||||
while True:
|
||||
item = yield
|
||||
|
||||
dest = os.path.join(dest_dir, lib.destination(item, fragment=True))
|
||||
dest = os.path.splitext(dest)[0] + '.mp3'
|
||||
dest = _destination(lib, dest_dir, item, keep_new)
|
||||
|
||||
if os.path.exists(util.syspath(dest)):
|
||||
log.info(u'Skipping {0} (target file exists)'.format(
|
||||
|
|
@ -66,16 +88,40 @@ def convert_item(lib, dest_dir):
|
|||
with _fs_lock:
|
||||
util.mkdirall(dest)
|
||||
|
||||
maxbr = config['convert']['max_bitrate'].get(int)
|
||||
if item.format == 'MP3' and item.bitrate < 1000 * maxbr:
|
||||
log.info(u'Copying {0}'.format(util.displayable_path(item.path)))
|
||||
util.copy(item.path, dest)
|
||||
else:
|
||||
encode(item.path, dest)
|
||||
# When keeping the new file in the library, we first move the
|
||||
# current (pristine) file to the destination. We'll then copy it
|
||||
# back to its old path or transcode it to a new path.
|
||||
if keep_new:
|
||||
log.info(u'Moving to {0}'.
|
||||
format(util.displayable_path(dest)))
|
||||
util.move(item.path, dest)
|
||||
|
||||
item.path = dest
|
||||
if not should_transcode(item):
|
||||
# No transcoding necessary.
|
||||
log.info(u'Copying {0}'.format(util.displayable_path(item.path)))
|
||||
if keep_new:
|
||||
util.copy(dest, item.path)
|
||||
else:
|
||||
util.copy(item.path, dest)
|
||||
|
||||
else:
|
||||
if keep_new:
|
||||
item.path = os.path.splitext(item.path)[0] + '.mp3'
|
||||
encode(dest, item.path)
|
||||
else:
|
||||
encode(item.path, dest)
|
||||
|
||||
# Write tags from the database to the converted file.
|
||||
if not keep_new:
|
||||
item.path = dest
|
||||
item.write()
|
||||
|
||||
# If we're keeping the transcoded file, read it again (after
|
||||
# writing) to get new bitrate, duration, etc.
|
||||
if keep_new:
|
||||
item.read()
|
||||
lib.store(item) # Store new path and audio data.
|
||||
|
||||
if config['convert']['embed']:
|
||||
album = lib.get_album(item)
|
||||
if album:
|
||||
|
|
@ -84,13 +130,30 @@ def convert_item(lib, dest_dir):
|
|||
_embed(artpath, [item])
|
||||
|
||||
|
||||
def convert_on_import(lib, item):
|
||||
"""Transcode a file automatically after it is imported into the
|
||||
library.
|
||||
"""
|
||||
if should_transcode(item):
|
||||
fd, dest = tempfile.mkstemp('.mp3')
|
||||
os.close(fd)
|
||||
_temp_files.append(dest) # Delete the transcode later.
|
||||
encode(item.path, dest)
|
||||
item.path = dest
|
||||
item.write()
|
||||
item.read() # Load new audio information data.
|
||||
lib.store(item)
|
||||
|
||||
|
||||
def convert_func(lib, opts, args):
|
||||
dest = opts.dest if opts.dest is not None else \
|
||||
config['convert']['dest'].get()
|
||||
if not dest:
|
||||
raise ui.UserError('no convert destination set')
|
||||
dest = util.bytestring_path(dest)
|
||||
threads = opts.threads if opts.threads is not None else \
|
||||
config['convert']['threads'].get(int)
|
||||
keep_new = opts.keep_new
|
||||
|
||||
ui.commands.list_items(lib, ui.decargs(args), opts.album, None)
|
||||
|
||||
|
|
@ -101,7 +164,7 @@ def convert_func(lib, opts, args):
|
|||
items = (i for a in lib.albums(ui.decargs(args)) for i in a.items())
|
||||
else:
|
||||
items = lib.items(ui.decargs(args))
|
||||
convert = [convert_item(lib, dest) for i in range(threads)]
|
||||
convert = [convert_item(lib, dest, keep_new) for i in range(threads)]
|
||||
pipe = util.pipeline.Pipeline([items, convert])
|
||||
pipe.run_parallel()
|
||||
|
||||
|
|
@ -116,7 +179,9 @@ class ConvertPlugin(BeetsPlugin):
|
|||
u'opts': u'-aq 2',
|
||||
u'max_bitrate': 500,
|
||||
u'embed': True,
|
||||
u'auto': False
|
||||
})
|
||||
self.import_stages = [self.auto_convert]
|
||||
|
||||
def commands(self):
|
||||
cmd = ui.Subcommand('convert', help='convert to external location')
|
||||
|
|
@ -125,7 +190,27 @@ class ConvertPlugin(BeetsPlugin):
|
|||
cmd.parser.add_option('-t', '--threads', action='store', type='int',
|
||||
help='change the number of threads, \
|
||||
defaults to maximum availble processors ')
|
||||
cmd.parser.add_option('-k', '--keep-new', action='store_true',
|
||||
dest='keep_new', help='keep only the converted \
|
||||
and move the old files')
|
||||
cmd.parser.add_option('-d', '--dest', action='store',
|
||||
help='set the destination directory')
|
||||
cmd.func = convert_func
|
||||
return [cmd]
|
||||
|
||||
def auto_convert(self, config, task):
|
||||
if self.config['auto'].get():
|
||||
if not task.is_album:
|
||||
convert_on_import(config.lib, task.item)
|
||||
else:
|
||||
for item in task.items:
|
||||
convert_on_import(config.lib, item)
|
||||
|
||||
|
||||
@ConvertPlugin.listen('import_task_files')
|
||||
def _cleanup(task, session):
|
||||
for path in task.old_paths:
|
||||
if path in _temp_files:
|
||||
if os.path.isfile(path):
|
||||
util.remove(path)
|
||||
_temp_files.remove(path)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from beets import ui
|
|||
from beets import config
|
||||
import pyechonest.config
|
||||
import pyechonest.song
|
||||
import socket
|
||||
|
||||
# Global logger.
|
||||
log = logging.getLogger('beets')
|
||||
|
|
@ -79,7 +80,7 @@ def get_tempo(artist, title):
|
|||
else:
|
||||
log.warn(u'echonest_tempo: {0}'.format(e.args[0][0]))
|
||||
return None
|
||||
except pyechonest.util.EchoNestIOError as e:
|
||||
except (pyechonest.util.EchoNestIOError, socket.error) as e:
|
||||
log.debug(u'echonest_tempo: IO error: {0}'.format(e))
|
||||
time.sleep(RETRY_INTERVAL)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -12,86 +12,34 @@
|
|||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""Like beet list, but with fuzzy matching
|
||||
"""Provides a fuzzy matching query.
|
||||
"""
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand, decargs, print_obj
|
||||
from beets.util.functemplate import Template
|
||||
from beets import config
|
||||
from beets.library import RegisteredFieldQuery
|
||||
import beets
|
||||
import difflib
|
||||
|
||||
|
||||
def fuzzy_score(queryMatcher, item):
|
||||
queryMatcher.set_seq1(item)
|
||||
return queryMatcher.quick_ratio()
|
||||
class FuzzyQuery(RegisteredFieldQuery):
|
||||
@classmethod
|
||||
def value_match(self, pattern, val):
|
||||
# smartcase
|
||||
if pattern.islower():
|
||||
val = val.lower()
|
||||
queryMatcher = difflib.SequenceMatcher(None, pattern, val)
|
||||
threshold = beets.config['fuzzy']['threshold'].as_number()
|
||||
return queryMatcher.quick_ratio() >= threshold
|
||||
|
||||
|
||||
def is_match(queryMatcher, item, album=False, verbose=False, threshold=0.7):
|
||||
if album:
|
||||
values = [item.albumartist, item.album]
|
||||
else:
|
||||
values = [item.artist, item.album, item.title]
|
||||
|
||||
s = max(fuzzy_score(queryMatcher, i.lower()) for i in values)
|
||||
if verbose:
|
||||
return (s >= threshold, s)
|
||||
else:
|
||||
return s >= threshold
|
||||
|
||||
|
||||
def fuzzy_list(lib, opts, args):
|
||||
query = decargs(args)
|
||||
query = ' '.join(query).lower()
|
||||
queryMatcher = difflib.SequenceMatcher(b=query)
|
||||
|
||||
if opts.threshold is not None:
|
||||
threshold = float(opts.threshold)
|
||||
else:
|
||||
threshold = config['fuzzy']['threshold'].as_number()
|
||||
|
||||
if opts.path:
|
||||
fmt = '$path'
|
||||
else:
|
||||
fmt = opts.format
|
||||
template = Template(fmt) if fmt else None
|
||||
|
||||
if opts.album:
|
||||
objs = lib.albums()
|
||||
else:
|
||||
objs = lib.items()
|
||||
|
||||
items = filter(lambda i: is_match(queryMatcher, i, album=opts.album,
|
||||
threshold=threshold), objs)
|
||||
|
||||
for item in items:
|
||||
print_obj(item, lib, template)
|
||||
if opts.verbose:
|
||||
print(is_match(queryMatcher, item,
|
||||
album=opts.album, verbose=True)[1])
|
||||
|
||||
|
||||
fuzzy_cmd = Subcommand('fuzzy',
|
||||
help='list items using fuzzy matching')
|
||||
fuzzy_cmd.parser.add_option('-a', '--album', action='store_true',
|
||||
help='choose an album instead of track')
|
||||
fuzzy_cmd.parser.add_option('-p', '--path', action='store_true',
|
||||
help='print the path of the matched item')
|
||||
fuzzy_cmd.parser.add_option('-f', '--format', action='store',
|
||||
help='print with custom format', default=None)
|
||||
fuzzy_cmd.parser.add_option('-v', '--verbose', action='store_true',
|
||||
help='output scores for matches')
|
||||
fuzzy_cmd.parser.add_option('-t', '--threshold', action='store',
|
||||
help='return result with a fuzzy score above threshold. \
|
||||
(default is 0.7)', default=None)
|
||||
fuzzy_cmd.func = fuzzy_list
|
||||
|
||||
|
||||
class Fuzzy(BeetsPlugin):
|
||||
class FuzzyPlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(Fuzzy, self).__init__()
|
||||
super(FuzzyPlugin, self).__init__()
|
||||
self.config.add({
|
||||
'prefix': '~',
|
||||
'threshold': 0.7,
|
||||
})
|
||||
|
||||
def commands(self):
|
||||
return [fuzzy_cmd]
|
||||
def queries(self):
|
||||
prefix = beets.config['fuzzy']['prefix'].get(basestring)
|
||||
return {prefix: FuzzyQuery}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,11 @@ class ImportFeedsPlugin(BeetsPlugin):
|
|||
'm3u_name': u'imported.m3u',
|
||||
'dir': None,
|
||||
'relative_to': None,
|
||||
'absolute_path': False,
|
||||
})
|
||||
|
||||
|
||||
feeds_dir = self.config['dir'].get()
|
||||
if feeds_dir:
|
||||
if feeds_dir:
|
||||
feeds_dir = os.path.expanduser(bytestring_path(feeds_dir))
|
||||
self.config['dir'] = feeds_dir
|
||||
if not os.path.exists(syspath(feeds_dir)):
|
||||
|
|
@ -92,9 +93,12 @@ def _record_items(lib, basename, items):
|
|||
|
||||
paths = []
|
||||
for item in items:
|
||||
paths.append(os.path.relpath(
|
||||
item.path, relative_to
|
||||
))
|
||||
if config['importfeeds']['absolute_path']:
|
||||
paths.append(item.path)
|
||||
else:
|
||||
paths.append(os.path.relpath(
|
||||
item.path, relative_to
|
||||
))
|
||||
|
||||
if 'm3u' in formats:
|
||||
basename = bytestring_path(
|
||||
|
|
|
|||
|
|
@ -19,15 +19,14 @@ from beets.ui import Subcommand
|
|||
from beets import ui
|
||||
from beets import config
|
||||
import musicbrainzngs
|
||||
from musicbrainzngs import musicbrainz
|
||||
|
||||
SUBMISSION_CHUNK_SIZE = 200
|
||||
|
||||
def mb_request(*args, **kwargs):
|
||||
"""Send a MusicBrainz API request and process exceptions.
|
||||
def mb_call(func, *args, **kwargs):
|
||||
"""Call a MusicBrainz API function and catch exceptions.
|
||||
"""
|
||||
try:
|
||||
return musicbrainz._mb_request(*args, **kwargs)
|
||||
return func(*args, **kwargs)
|
||||
except musicbrainzngs.AuthenticationError:
|
||||
raise ui.UserError('authentication with MusicBrainz failed')
|
||||
except musicbrainzngs.ResponseError as exc:
|
||||
|
|
@ -41,17 +40,14 @@ def submit_albums(collection_id, release_ids):
|
|||
"""
|
||||
for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE):
|
||||
chunk = release_ids[i:i+SUBMISSION_CHUNK_SIZE]
|
||||
releaselist = ";".join(chunk)
|
||||
mb_request(
|
||||
"collection/%s/releases/%s" % (collection_id, releaselist),
|
||||
'PUT', True, True, body='foo'
|
||||
mb_call(
|
||||
musicbrainzngs.add_releases_to_collection,
|
||||
collection_id, chunk
|
||||
)
|
||||
# A non-empty request body is required to avoid a 411 "Length
|
||||
# Required" error from the MB server.
|
||||
|
||||
def update_collection(lib, opts, args):
|
||||
# Get the collection to modify.
|
||||
collections = mb_request('collection', 'GET', True, True)
|
||||
collections = mb_call(musicbrainzngs.get_collections)
|
||||
if not collections['collection-list']:
|
||||
raise ui.UserError('no collections exist for user')
|
||||
collection_id = collections['collection-list'][0]['id']
|
||||
|
|
|
|||
|
|
@ -8,13 +8,14 @@ New configuration options:
|
|||
|
||||
* :ref:`languages` controls the preferred languages when selecting an alias
|
||||
from MusicBrainz. This feature requires `python-musicbrainz-ngs`_ 0.3 or
|
||||
later, which (at the time of this writing) is not yet released. Thanks to
|
||||
Sam Doshi.
|
||||
later. Thanks to Sam Doshi.
|
||||
* :ref:`detail` enables a mode where all tracks are listed in the importer UI,
|
||||
as opposed to only changed tracks.
|
||||
* The ``--flat`` option to the ``beet import`` command treats an entire
|
||||
directory tree of music files as a single album. This can help in situations
|
||||
where a multi-disc album is split across multiple directories.
|
||||
* :doc:`/plugins/importfeeds`: An option was added to use absolute, rather
|
||||
than relative, paths. Thanks to Lucas Duailibe.
|
||||
|
||||
Other stuff:
|
||||
|
||||
|
|
@ -22,6 +23,20 @@ Other stuff:
|
|||
track in MusicBrainz and updates your library to reflect it. This can help
|
||||
you easily correct errors that have been fixed in the MB database. Thanks to
|
||||
Jakob Schnitzer.
|
||||
* :doc:`/plugins/fuzzy`: The ``fuzzy`` command was removed and replaced with a
|
||||
new query type. To perform fuzzy searches, use the ``~`` prefix with
|
||||
:ref:`list-cmd` or other commands. Thanks to Philippe Mongeau.
|
||||
* As part of the above, plugins can now extend the query syntax and new kinds
|
||||
of matching capabilities to beets. See :ref:`extend-query`. Thanks again to
|
||||
Philippe Mongeau.
|
||||
* :doc:`/plugins/convert`: A new ``--keep-new`` option lets you store
|
||||
transcoded files in your library while backing up the originals (instead of
|
||||
vice-versa). Thanks to Lucas Duailibe.
|
||||
* :doc:`/plugins/convert`: Also, a new ``auto`` config option will transcode
|
||||
audio files automatically during import. Thanks again to Lucas Duailibe.
|
||||
* :doc:`/plugins/chroma`: A new ``fingerprint`` command lets you generate and
|
||||
store fingerprints for items that don't yet have them. One more round of
|
||||
applause for Lucas Duailibe.
|
||||
* :doc:`/plugins/echonest_tempo`: API errors now issue a warning instead of
|
||||
exiting with an exception. We also avoid an error when track metadata
|
||||
contains newlines.
|
||||
|
|
@ -42,8 +57,14 @@ Other stuff:
|
|||
pathnames.
|
||||
* Fix a spurious warning from the Unidecode module when matching albums that
|
||||
are missing all metadata.
|
||||
* Fix Unicode errors when a directory or file doesn't exist when invoking the
|
||||
import command. Thanks to Lucas Duailibe.
|
||||
* :doc:`/plugins/mbcollection`: Show friendly, human-readable errors when
|
||||
MusicBrainz exceptions occur.
|
||||
* :doc:`/plugins/echonest_tempo`: Catch socket errors that are not handled by
|
||||
the Echo Nest library.
|
||||
* :doc:`/plugins/chroma`: Catch Acoustid Web service errors when submitting
|
||||
fingerprints.
|
||||
|
||||
1.1b2 (February 16, 2013)
|
||||
-------------------------
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 36 KiB |
|
|
@ -45,7 +45,7 @@ Next, you will need a mechanism for decoding audio files supported by the
|
|||
* On Linux, you can install `GStreamer for Python`_, `FFmpeg`_, or `MAD`_ and
|
||||
`pymad`_. How you install these will depend on your distribution. For example,
|
||||
on Ubuntu, run ``apt-get install python-gst0.10-dev``. On Arch Linux, you want
|
||||
``pacman -S gstreamer0.10-python``.
|
||||
``pacman -S gstreamer0.10-python``.
|
||||
|
||||
* On Windows, try the Gstreamer "WinBuilds" from the `OSSBuild`_ project.
|
||||
|
||||
|
|
@ -78,6 +78,12 @@ editing your :doc:`configuration file </reference/config>`. Put ``chroma`` on
|
|||
your ``plugins:`` line. With that, beets will use fingerprinting the next time
|
||||
you run ``beet import``.
|
||||
|
||||
You can also use the ``beet fingerprint`` command to generate fingerprints for
|
||||
items already in your library. (Provide a query to fingerprint a subset of your
|
||||
library.) The generated fingerprints will be stored in the library database.
|
||||
If you have the ``import.write`` config option enabled, they will also be
|
||||
written to files' metadata.
|
||||
|
||||
.. _submitfp:
|
||||
|
||||
Submitting Fingerprints
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ to match albums instead of tracks.
|
|||
The ``-t`` (``--threads``) and ``-d`` (``--dest``) options allow you to specify
|
||||
or overwrite the respective configuration options.
|
||||
|
||||
By default, the command places converted files into the destination directory
|
||||
and leaves your library pristine. To instead back up your original files into
|
||||
the destination directory and keep converted files in your library, use the
|
||||
``-k`` (or ``--keep-new``) option.
|
||||
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
|
|
@ -51,6 +57,10 @@ The plugin offers several configuration options, all of which live under the
|
|||
"-aq 2". (Note that "-aq <num>" is equivalent to the LAME option "-V
|
||||
<num>".) If you want to specify a bitrate, use "-ab <bitrate>". Refer to the
|
||||
`FFmpeg`_ documentation for more details.
|
||||
* ``auto`` gives you the option to import transcoded versions of your files
|
||||
automatically during the ``import`` command. With this option enabled, the
|
||||
importer will transcode all non-MP3 files over the maximum bitrate before
|
||||
adding them to your library.
|
||||
* Finally, ``threads`` determines the number of threads to use for parallel
|
||||
encoding. By default, the plugin will detect the number of processors
|
||||
available and use them all.
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
Fuzzy Search Plugin
|
||||
===================
|
||||
|
||||
The ``fuzzy`` plugin provides a command that search your library using
|
||||
fuzzy pattern matching. This can be useful if you want to find a track with complicated characters in the title.
|
||||
The ``fuzzy`` plugin provides a prefixed query that search you library using
|
||||
fuzzy pattern matching. This can be useful if you want to find a track with
|
||||
complicated characters in the title.
|
||||
|
||||
First, enable the plugin named ``fuzzy`` (see :doc:`/plugins/index`).
|
||||
You'll then be able to use the ``beet fuzzy`` command::
|
||||
You'll then be able to use the ``~`` prefix to use fuzzy matching::
|
||||
|
||||
$ beet fuzzy Vareoldur
|
||||
$ beet ls '~Vareoldur'
|
||||
Sigur Rós - Valtari - Varðeldur
|
||||
|
||||
The command has several options that resemble those for the ``beet list``
|
||||
command (see :doc:`/reference/cli`). To choose an album instead of a single
|
||||
track, use ``-a``; to print paths to items instead of metadata, use ``-p``; and
|
||||
to use a custom format for printing, use ``-f FORMAT``.
|
||||
|
||||
The ``-t NUMBER`` option lets you specify how precise the fuzzy match has to be
|
||||
(default is 0.7). To make a fuzzier search, try ``beet fuzzy -t 0.5 Varoeldur``.
|
||||
A value of ``1`` will show only perfect matches and a value of ``0`` will match everything.
|
||||
|
||||
The default threshold can also be set in the config file::
|
||||
The plugin provides config options that let you choose the prefix and the
|
||||
threshold.::
|
||||
|
||||
fuzzy:
|
||||
threshold: 0.8
|
||||
prefix: '@'
|
||||
|
||||
A threshold value of 1.0 will show only perfect matches and a value of 0.0
|
||||
will match everything.
|
||||
|
||||
The default prefix ``~`` needs to be escaped or quoted in most shells. If this
|
||||
bothers you, you can change the prefix in your config file.
|
||||
|
|
|
|||
|
|
@ -15,11 +15,14 @@ relative to another folder than where the playlist is being written. If you're
|
|||
using importfeeds to generate a playlist for MPD, you should set this to the
|
||||
root of your music library.
|
||||
|
||||
The ``absolute_path`` configuration option can be set to use absolute paths
|
||||
instead of relative paths. Some applications may need this to work properly.
|
||||
|
||||
Three different types of outputs coexist, specify the ones you want to use by
|
||||
setting the ``formats`` parameter:
|
||||
setting the ``formats`` parameter:
|
||||
|
||||
- ``m3u``: catalog the imports in a centralized playlist. By default, the playlist is named ``imported.m3u``. To use a different file, just set the ``m3u_name`` parameter inside the ``importfeeds`` config section.
|
||||
- ``m3u_multi``: create a new playlist for each import (uniquely named by appending the date and track/album name).
|
||||
- ``m3u_multi``: create a new playlist for each import (uniquely named by appending the date and track/album name).
|
||||
- ``link``: create a symlink for each imported item. This is the recommended setting to propagate beets imports to your iTunes library: just drag and drop the ``dir`` folder on the iTunes dock icon.
|
||||
|
||||
Here's an example configuration for this plugin::
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ MusicBrainz Collection Plugin
|
|||
The ``mbcollection`` plugin lets you submit your catalog to MusicBrainz to
|
||||
maintain your `music collection`_ list there.
|
||||
|
||||
.. _music collection: http://musicbrainz.org/show/collection/
|
||||
.. _music collection: http://musicbrainz.org/doc/Collections
|
||||
|
||||
To begin, just enable the ``mbcollection`` plugin (see :doc:`/plugins/index`).
|
||||
Then, add your MusicBrainz username and password to your
|
||||
|
|
|
|||
|
|
@ -22,18 +22,18 @@ The default configuration moves all English articles to the end of the string,
|
|||
but you can override these defaults to make more complex changes::
|
||||
|
||||
the:
|
||||
# handle The, default is on
|
||||
the=yes
|
||||
# handle A/An, default is on
|
||||
a=yes
|
||||
# handle "The" (on by default)
|
||||
the: yes
|
||||
# handle "A/An" (on by default)
|
||||
a: yes
|
||||
# format string, {0} - part w/o article, {1} - article
|
||||
# spaces already trimmed from ends of both parts
|
||||
# default is '{0}, {1}'
|
||||
format={0}, {1}
|
||||
format: '{0}, {1}'
|
||||
# strip instead of moving to the end, default is off
|
||||
strip=no
|
||||
# custom regexp patterns, separated by space
|
||||
patterns=
|
||||
strip: no
|
||||
# custom regexp patterns, space-separated
|
||||
patterns: ...
|
||||
|
||||
Custom patterns are case-insensitive regular expressions. Patterns can be
|
||||
matched anywhere in the string (not just the beginning), so use ``^`` if you
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ currently available are:
|
|||
* *pluginload*: called after all the plugins have been loaded after the ``beet``
|
||||
command starts
|
||||
|
||||
* *import*: called after a ``beet import`` command fishes (the ``lib`` keyword
|
||||
* *import*: called after a ``beet import`` command finishes (the ``lib`` keyword
|
||||
argument is a Library object; ``paths`` is a list of paths (strings) that were
|
||||
imported)
|
||||
|
||||
|
|
@ -323,3 +323,39 @@ to register it::
|
|||
self.import_stages = [self.stage]
|
||||
def stage(self, config, task):
|
||||
print('Importing something!')
|
||||
|
||||
.. _extend-query:
|
||||
|
||||
Extend the Query Syntax
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
You can add new kinds of queries to beets' :doc:`query syntax
|
||||
</reference/query>` indicated by a prefix. As an example, beets already
|
||||
supports regular expression queries, which are indicated by a colon
|
||||
prefix---plugins can do the same.
|
||||
|
||||
To do so, define a subclass of the ``Query`` type from the ``beets.library``
|
||||
module. Then, in the ``queries`` method of your plugin class, return a
|
||||
dictionary mapping prefix strings to query classes.
|
||||
|
||||
One simple kind of query you can extend is the ``RegisteredFieldQuery``, which
|
||||
implements string comparisons. To use it, create a subclass inheriting from
|
||||
that class and override the ``value_match`` class method. (Remember the
|
||||
``@classmethod`` decorator!) The following example plugin declares a query
|
||||
using the ``@`` prefix to delimit exact string matches. The plugin will be
|
||||
used if we issue a command like ``beet ls @something`` or ``beet ls
|
||||
artist:@something``::
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.library import PluginQuery
|
||||
|
||||
class ExactMatchQuery(PluginQuery):
|
||||
@classmethod
|
||||
def value_match(self, pattern, val):
|
||||
return pattern == val
|
||||
|
||||
class ExactMatchPlugin(BeetsPlugin):
|
||||
def queries():
|
||||
return {
|
||||
'@': ExactMatchQuery
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ Ordinary metadata:
|
|||
* encoder
|
||||
|
||||
.. _artist credit: http://wiki.musicbrainz.org/Artist_Credit
|
||||
.. _list of type names: http://wiki.musicbrainz.org/XMLWebService#Release_Type_and_Status
|
||||
.. _list of type names: http://wiki.musicbrainz.org/Development/XML_Web_Service/Version_2#Release_Type_and_Status
|
||||
|
||||
Audio information:
|
||||
|
||||
|
|
|
|||
2
setup.py
2
setup.py
|
|
@ -74,7 +74,7 @@ setup(name='beets',
|
|||
'mutagen>=1.20',
|
||||
'munkres',
|
||||
'unidecode',
|
||||
'musicbrainzngs>=0.2',
|
||||
'musicbrainzngs>=0.3',
|
||||
'pyyaml',
|
||||
]
|
||||
+ (['colorama'] if (sys.platform == 'win32') else [])
|
||||
|
|
|
|||
|
|
@ -20,82 +20,64 @@ import _common
|
|||
from _common import unittest
|
||||
import beets.library
|
||||
|
||||
pqp = beets.library.CollectionQuery._parse_query_part
|
||||
pqp = beets.library.parse_query_part
|
||||
|
||||
some_item = _common.item()
|
||||
|
||||
class QueryParseTest(unittest.TestCase):
|
||||
def test_one_basic_term(self):
|
||||
q = 'test'
|
||||
r = (None, 'test', False)
|
||||
r = (None, 'test', beets.library.SubstringQuery)
|
||||
self.assertEqual(pqp(q), r)
|
||||
|
||||
def test_one_keyed_term(self):
|
||||
q = 'test:val'
|
||||
r = ('test', 'val', False)
|
||||
r = ('test', 'val', beets.library.SubstringQuery)
|
||||
self.assertEqual(pqp(q), r)
|
||||
|
||||
def test_colon_at_end(self):
|
||||
q = 'test:'
|
||||
r = (None, 'test:', False)
|
||||
r = (None, 'test:', beets.library.SubstringQuery)
|
||||
self.assertEqual(pqp(q), r)
|
||||
|
||||
def test_one_basic_regexp(self):
|
||||
q = r':regexp'
|
||||
r = (None, 'regexp', True)
|
||||
r = (None, 'regexp', beets.library.RegexpQuery)
|
||||
self.assertEqual(pqp(q), r)
|
||||
|
||||
def test_keyed_regexp(self):
|
||||
q = r'test::regexp'
|
||||
r = ('test', 'regexp', True)
|
||||
r = ('test', 'regexp', beets.library.RegexpQuery)
|
||||
self.assertEqual(pqp(q), r)
|
||||
|
||||
def test_escaped_colon(self):
|
||||
q = r'test\:val'
|
||||
r = (None, 'test:val', False)
|
||||
r = (None, 'test:val', beets.library.SubstringQuery)
|
||||
self.assertEqual(pqp(q), r)
|
||||
|
||||
def test_escaped_colon_in_regexp(self):
|
||||
q = r':test\:regexp'
|
||||
r = (None, 'test:regexp', True)
|
||||
r = (None, 'test:regexp', beets.library.RegexpQuery)
|
||||
self.assertEqual(pqp(q), r)
|
||||
|
||||
class AnySubstringQueryTest(unittest.TestCase):
|
||||
class AnyFieldQueryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.lib = beets.library.Library(':memory:')
|
||||
self.lib.add(some_item)
|
||||
|
||||
def test_no_restriction(self):
|
||||
q = beets.library.AnySubstringQuery('title')
|
||||
q = beets.library.AnyFieldQuery('title', beets.library.ITEM_KEYS,
|
||||
beets.library.SubstringQuery)
|
||||
self.assertEqual(self.lib.items(q).next().title, 'the title')
|
||||
|
||||
def test_restriction_completeness(self):
|
||||
q = beets.library.AnySubstringQuery('title', ['title'])
|
||||
q = beets.library.AnyFieldQuery('title', ['title'],
|
||||
beets.library.SubstringQuery)
|
||||
self.assertEqual(self.lib.items(q).next().title, 'the title')
|
||||
|
||||
def test_restriction_soundness(self):
|
||||
q = beets.library.AnySubstringQuery('title', ['artist'])
|
||||
self.assertRaises(StopIteration, self.lib.items(q).next)
|
||||
|
||||
class AnyRegexpQueryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.lib = beets.library.Library(':memory:')
|
||||
self.lib.add(some_item)
|
||||
|
||||
def test_no_restriction(self):
|
||||
q = beets.library.AnyRegexpQuery(r'^the ti')
|
||||
self.assertEqual(self.lib.items(q).next().title, 'the title')
|
||||
|
||||
def test_restriction_completeness(self):
|
||||
q = beets.library.AnyRegexpQuery(r'^the ti', ['title'])
|
||||
self.assertEqual(self.lib.items(q).next().title, 'the title')
|
||||
|
||||
def test_restriction_soundness(self):
|
||||
q = beets.library.AnyRegexpQuery(r'^the ti', ['artist'])
|
||||
self.assertRaises(StopIteration, self.lib.items(q).next)
|
||||
|
||||
def test_restriction_soundness_2(self):
|
||||
q = beets.library.AnyRegexpQuery(r'the ti$', ['title'])
|
||||
q = beets.library.AnyFieldQuery('title', ['artist'],
|
||||
beets.library.SubstringQuery)
|
||||
self.assertRaises(StopIteration, self.lib.items(q).next)
|
||||
|
||||
# Convenient asserts for matching items.
|
||||
|
|
|
|||
Loading…
Reference in a new issue