merge with master

We should work off the latest changes because of the recent changes to the
Query class hierarchy.
This commit is contained in:
Adrian Sampson 2013-03-16 12:50:55 -07:00
commit d41fb7f0ea
36 changed files with 934 additions and 413 deletions

View file

@ -17,3 +17,4 @@ c84744f4519be7416dc1653142f1763f406d6896 1.0rc1
f3cd4c138c6f40dc324a23bf01c4c7d97766477e 1.0rc2
6f29c0f4dc7025e8d8216ea960000c353886c4f4 v1.1.0-beta.1
f28ea9e2ef8d39913d79dbba73db280ff0740c50 v1.1.0-beta.2
8f070ce28a7b33d8509b29a8dbe937109bbdbd21 v1.1.0-beta.3

View file

@ -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:

View file

@ -257,8 +257,8 @@ def distance(items, album_info, mapping):
`album_info.tracks`.
"""
cur_artist, cur_album, _ = current_metadata(items)
cur_artist = cur_artist or ''
cur_album = cur_album or ''
cur_artist = cur_artist or u''
cur_album = cur_album or u''
# These accumulate the possible distance components. The final
# distance will be dist/dist_max.

View file

@ -45,8 +45,8 @@ class MusicBrainzAPIError(util.HumanReadableException):
log = logging.getLogger('beets')
RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups',
'labels', 'artist-credits']
TRACK_INCLUDES = ['artists']
'labels', 'artist-credits', 'aliases']
TRACK_INCLUDES = ['artists', 'aliases']
def configure():
"""Set up the python-musicbrainz-ngs module according to settings
@ -58,6 +58,34 @@ def configure():
config['musicbrainz']['ratelimit'].get(int),
)
def _preferred_alias(aliases):
"""Given an list of alias structures for an artist credit, select
and return the user's preferred alias alias or None if no matching
alias is found.
"""
if not aliases:
return
# Only consider aliases that have locales set.
aliases = [a for a in aliases if 'locale' in a]
# Search configured locales in order.
for locale in config['import']['languages'].as_str_seq():
# Find matching aliases for this locale.
matches = [a for a in aliases if a['locale'] == locale]
# Skip to the next locale if we have no matches
if not matches:
continue
# Find the aliases that have the primary flag set.
primaries = [a for a in matches if 'primary' in a]
# Take the primary if we have it, otherwise take the first
# match with the correct locale.
if primaries:
return primaries[0]
else:
return matches[0]
def _flatten_artist_credit(credit):
"""Given a list representing an ``artist-credit`` block, flatten the
data into a triple of joined artist name strings: canonical, sort, and
@ -74,12 +102,19 @@ def _flatten_artist_credit(credit):
artist_sort_parts.append(el)
else:
alias = _preferred_alias(el['artist'].get('alias-list', ()))
# An artist.
cur_artist_name = el['artist']['name']
if alias:
cur_artist_name = alias['alias']
else:
cur_artist_name = el['artist']['name']
artist_parts.append(cur_artist_name)
# Artist sort name.
if 'sort-name' in el['artist']:
if alias:
artist_sort_parts.append(alias['sort-name'])
elif 'sort-name' in el['artist']:
artist_sort_parts.append(el['artist']['sort-name'])
else:
artist_sort_parts.append(cur_artist_name)

View file

@ -16,6 +16,9 @@ import:
quiet: no
singletons: no
default_action: apply
languages: []
detail: no
flat: no
clutter: ["Thumbs.DB", ".DS_Store"]
ignore: [".*", "*~", "System Volume Information"]

View file

@ -564,6 +564,15 @@ def read_tasks(session):
yield ImportTask.item_task(item)
continue
# A flat album import merges all items into one album.
if config['import']['flat'] and not config['import']['singletons']:
all_items = []
for _, items in autotag.albums_in_dir(toppath):
all_items += items
yield ImportTask(toppath, toppath, all_items)
yield ImportTask.done_sentinel(toppath)
continue
# Produce paths under this directory.
if _resume():
resume_dir = resume_dirs.get(toppath)
@ -634,7 +643,7 @@ def initial_lookup(session):
log.debug('Looking up: %s' % displayable_path(task.paths))
task.set_candidates(
*autotag.tag_album(task.items, config['import']['timid'].get(bool))
*autotag.tag_album(task.items)
)
def user_query(session):

View file

@ -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
@ -218,6 +203,8 @@ def format_for_path(value, key=None, pathmod=None):
elif key == 'samplerate':
# Sample rate formatted as kHz.
value = u'%ikHz' % ((value or 0) // 1000)
elif value is None:
value = u''
else:
value = unicode(value)
@ -497,12 +484,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):
@ -511,8 +537,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."""
@ -523,24 +552,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
@ -592,103 +619,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.
"""
@ -698,71 +647,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.
@ -832,6 +742,122 @@ class ResultIterator(object):
)
return Item(row, get_flexattrs(flex_rows))
# 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
determines whether the query is to match items or albums.
"""
if album:
default_fields = ALBUM_DEFAULT_FIELDS
all_keys = ALBUM_KEYS
else:
default_fields = ITEM_DEFAULT_FIELDS
all_keys = ITEM_KEYS
# Convert a single string into a list of space-separated
# criteria.
if isinstance(val, basestring):
val = val.split()
if val is None:
return TrueQuery()
elif isinstance(val, list) or isinstance(val, tuple):
return AndQuery.from_strings(val, default_fields, all_keys)
elif isinstance(val, Query):
return val
else:
raise ValueError('query must be None or have type Query or str')
# An abstract library.
@ -843,37 +869,6 @@ class BaseLibrary(object):
raise NotImplementedError
# Helpers.
@classmethod
def _get_query(cls, val=None, 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 determines whether the query is to match items
or albums.
"""
if album:
default_fields = ALBUM_DEFAULT_FIELDS
all_keys = ALBUM_KEYS
else:
default_fields = ITEM_DEFAULT_FIELDS
all_keys = ITEM_KEYS
# Convert a single string into a list of space-separated
# criteria.
if isinstance(val, basestring):
val = val.split()
if val is None:
return TrueQuery()
elif isinstance(val, list) or isinstance(val, tuple):
return AndQuery.from_strings(val, default_fields, all_keys)
elif isinstance(val, Query):
return val
elif not isinstance(val, Query):
raise ValueError('query must be None or have type Query or str')
# Basic operations.
def add(self, item, copy=False):
@ -1180,8 +1175,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
@ -1424,7 +1423,7 @@ class Library(BaseLibrary):
# Querying.
def albums(self, query=None, artist=None):
query = self._get_query(query, True)
query = get_query(query, True)
if artist is not None:
# "Add" the artist to the query.
query = AndQuery((query, MatchQuery('albumartist', artist)))
@ -1438,7 +1437,7 @@ class Library(BaseLibrary):
return [Album(self, dict(res)) for res in rows]
def items(self, query=None, artist=None, album=None, title=None):
queries = [self._get_query(query, False)]
queries = [get_query(query, False)]
if artist is not None:
queries.append(MatchQuery('artist', artist))
if album is not None:

View file

@ -56,6 +56,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
@ -95,6 +101,7 @@ class BeetsPlugin(object):
"""
return {}
listeners = None
@classmethod
@ -211,6 +218,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.

View file

@ -226,13 +226,17 @@ def show_change(cur_artist, cur_album, match):
if lhs != rhs:
lines.append((lhs, rhs, lhs_width))
elif config['import']['detail']:
lines.append((lhs, '', lhs_width))
# Print each track in two columns, or across two lines.
col_width = (ui.term_width() - len(''.join([' * ', ' -> ']))) // 2
if lines:
max_width = max(w for _, _, w in lines)
for lhs, rhs, lhs_width in lines:
if max_width > col_width:
if not rhs:
print_(u' * {0}'.format(lhs))
elif max_width > col_width:
print_(u' * %s ->\n %s' % (lhs, rhs))
else:
pad = max_width - lhs_width
@ -639,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']:
@ -709,6 +715,8 @@ import_cmd.parser.add_option('-i', '--incremental', dest='incremental',
action='store_true', help='skip already-imported directories')
import_cmd.parser.add_option('-I', '--noincremental', dest='incremental',
action='store_false', help='do not skip already-imported directories')
import_cmd.parser.add_option('--flat', dest='flat',
action='store_true', help='import an entire tree as a single album')
def import_func(lib, opts, args):
config['import'].set_args(opts)

View file

@ -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)
)

View file

@ -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,33 +28,55 @@ 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:
# Something went wrong (probably Ctrl+C), remove temporary files
log.info(u'Encoding {0} failed. Cleaning up...'.format(source))
log.info(u'Encoding {0} failed. Cleaning up...'
.format(util.displayable_path(source)))
util.remove(dest)
util.prune_dirs(os.path.dirname(dest))
return
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 = _destination(lib, dest_dir, item, keep_new)
dest = os.path.join(dest_dir, lib.destination(item, fragment=True))
dest = os.path.splitext(dest)[0] + '.mp3'
if os.path.exists(dest):
if os.path.exists(util.syspath(dest)):
log.info(u'Skipping {0} (target file exists)'.format(
util.displayable_path(item.path)
))
@ -65,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:
@ -83,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)
@ -100,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()
@ -115,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')
@ -124,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)

View file

@ -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:

View file

@ -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}

View file

@ -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(

View file

@ -19,26 +19,35 @@ 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_call(func, *args, **kwargs):
"""Call a MusicBrainz API function and catch exceptions.
"""
try:
return func(*args, **kwargs)
except musicbrainzngs.AuthenticationError:
raise ui.UserError('authentication with MusicBrainz failed')
except musicbrainzngs.ResponseError as exc:
raise ui.UserError('MusicBrainz API error: {0}'.format(exc))
except musicbrainzngs.UsageError:
raise ui.UserError('MusicBrainz credentials missing')
def submit_albums(collection_id, release_ids):
"""Add all of the release IDs to the indicated collection. Multiple
requests are made if there are many release IDs to submit.
"""
for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE):
chunk = release_ids[i:i+SUBMISSION_CHUNK_SIZE]
releaselist = ";".join(chunk)
musicbrainz._mb_request(
"collection/%s/releases/%s" % (collection_id, releaselist),
'PUT', True, True, body='foo')
# A non-empty request body is required to avoid a 411 "Length
# Required" error from the MB server.
mb_call(
musicbrainzngs.add_releases_to_collection,
collection_id, chunk
)
def update_collection(lib, opts, args):
# Get the collection to modify.
collections = musicbrainz._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']
@ -60,7 +69,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
super(MusicBrainzCollectionPlugin, self).__init__()
musicbrainzngs.auth(
config['musicbrainz']['user'].get(unicode),
config['musicbrainz']['pass'].get(unicode)
config['musicbrainz']['pass'].get(unicode),
)
def commands(self):

159
beetsplug/mbsync.py Normal file
View file

@ -0,0 +1,159 @@
# This file is part of beets.
# Copyright 2013, Jakob Schnitzer.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Update library's tags using MusicBrainz.
"""
import logging
from beets.plugins import BeetsPlugin
from beets import autotag, library, ui, util
from beets.autotag import hooks
from beets import config
log = logging.getLogger('beets')
def _print_and_apply_changes(lib, item, move, pretend, write):
"""Apply changes to an Item and preview them in the console. Return
a boolean indicating whether any changes were made.
"""
changes = {}
for key in library.ITEM_KEYS_META:
if item.dirty[key]:
changes[key] = item.old_data[key], getattr(item, key)
if not changes:
return False
# Something changed.
ui.print_obj(item, lib)
for key, (oldval, newval) in changes.iteritems():
ui.commands._showdiff(key, oldval, newval)
# If we're just pretending, then don't move or save.
if not pretend:
# Move the item if it's in the library.
if move and lib.directory in util.ancestry(item.path):
lib.move(item, with_album=False)
if write:
item.write()
lib.store(item)
return True
def mbsync_singletons(lib, query, move, pretend, write):
"""Synchronize matching singleton items.
"""
singletons_query = library.get_query(query, False)
singletons_query.subqueries.append(library.SingletonQuery(True))
for s in lib.items(singletons_query):
if not s.mb_trackid:
log.info(u'Skipping singleton {0}: has no mb_trackid'
.format(s.title))
continue
s.old_data = dict(s.record)
# Get the MusicBrainz recording info.
track_info = hooks._track_for_id(s.mb_trackid)
if not track_info:
log.info(u'Recording ID not found: {0}'.format(s.mb_trackid))
continue
# Apply.
with lib.transaction():
autotag.apply_item_metadata(s, track_info)
_print_and_apply_changes(lib, s, move, pretend, write)
def mbsync_albums(lib, query, move, pretend, write):
"""Synchronize matching albums.
"""
# Process matching albums.
for a in lib.albums(query):
if not a.mb_albumid:
log.info(u'Skipping album {0}: has no mb_albumid'.format(a.id))
continue
items = list(a.items())
for item in items:
item.old_data = dict(item.record)
# Get the MusicBrainz album information.
album_info = hooks._album_for_id(a.mb_albumid)
if not album_info:
log.info(u'Release ID not found: {0}'.format(a.mb_albumid))
continue
# Construct a track mapping according to MBIDs. This should work
# for albums that have missing or extra tracks.
mapping = {}
for item in items:
for track_info in album_info.tracks:
if item.mb_trackid == track_info.track_id:
mapping[item] = track_info
break
# Apply.
with lib.transaction():
autotag.apply_metadata(album_info, mapping)
changed = False
for item in items:
changed = changed or \
_print_and_apply_changes(lib, item, move, pretend, write)
if not changed:
# No change to any item.
continue
if not pretend:
# Update album structure to reflect an item in it.
for key in library.ALBUM_KEYS_ITEM:
setattr(a, key, getattr(items[0], key))
# Move album art (and any inconsistent items).
if move and lib.directory in util.ancestry(items[0].path):
log.debug(u'moving album {0}'.format(a.id))
a.move()
def mbsync_func(lib, opts, args):
"""Command handler for the mbsync function.
"""
move = opts.move
pretend = opts.pretend
write = opts.write
query = ui.decargs(args)
mbsync_singletons(lib, query, move, pretend, write)
mbsync_albums(lib, query, move, pretend, write)
class MBSyncPlugin(BeetsPlugin):
def __init__(self):
super(MBSyncPlugin, self).__init__()
def commands(self):
cmd = ui.Subcommand('mbsync',
help='update metadata from musicbrainz')
cmd.parser.add_option('-p', '--pretend', action='store_true',
help='show all changes but do nothing')
cmd.parser.add_option('-M', '--nomove', action='store_false',
default=True, dest='move',
help="don't move files in library")
cmd.parser.add_option('-W', '--nowrite', action='store_false',
default=config['import']['write'], dest='write',
help="don't write updated metadata to files")
cmd.func = mbsync_func
return [cmd]

View file

@ -1,21 +1,77 @@
Changelog
=========
1.1b3 (in development)
1.1b3 (March 16, 2013)
----------------------
This third beta of beets 1.1 brings a hodgepodge of little new features (and
internal overhauls that will make improvements easier in the future). There
are new options for getting metadata in a particular language and seeing more
detail during the import process. There's also a new plugin for synchronizing
your metadata with MusicBrainz. Under the hood, plugins can now extend the
query syntax.
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. 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:
* A new :doc:`/plugins/mbsync` provides a command that looks up each item and
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.
* When the importer encounters an error (insufficient permissions, for
example) when walking a directory tree, it now logs an error instead of
crashing.
* In path formats, null database values now expand to the empty string instead
of the string "None".
* Add "System Volume Information" (an internal directory found on some
Windows filesystems) to the default ignore list.
* Fix a crash when ReplayGain values were set to null.
* Fix a crash when iTunes Sound Check tags contained invalid data.
* Fix an error when the configuration file (``config.yaml``) is completely
empty.
* Fix an error introduced in 1.1b1 when importing using timid mode. Thanks to
Sam Doshi.
* :doc:`/plugins/convert`: Fix a bug when creating files with Unicode
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)
-------------------------

View file

@ -255,7 +255,7 @@ MusicBrainz---so consider adding the data yourself.
If you think beets is ignoring an album that's listed in MusicBrainz, please
`file a bug report`_.
.. _file a bug report: http://code.google.com/p/beets/issues/entry
.. _file a bug report: https://github.com/sampsyo/beets/issues
I Hope That Makes Sense
-----------------------

View file

@ -18,7 +18,7 @@ where you think this documentation can be improved.
.. _beets: http://beets.radbox.org/
.. _email the author: mailto:adrian@radbox.org
.. _file a bug: http://code.google.com/p/beets/issues/entry
.. _file a bug: https://github.com/sampsyo/beets/issues
Contents
--------

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -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

View file

@ -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.

View file

@ -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.

View 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::

View file

@ -60,6 +60,7 @@ disabled by default, but you can turn them on as described above.
convert
info
smartplaylist
mbsync
Autotagger Extensions
''''''''''''''''''''''
@ -73,6 +74,7 @@ Metadata
* :doc:`lyrics`: Automatically fetch song lyrics.
* :doc:`echonest_tempo`: Automatically fetch song tempos (bpm).
* :doc:`lastgenre`: Fetch genres based on Last.fm tags.
* :doc:`mbsync`: Fetch updated metadata from MusicBrainz
* :doc:`fetchart`: Fetch album cover art from various sources.
* :doc:`embedart`: Embed album art images into files' metadata.
* :doc:`replaygain`: Calculate volume normalization for players that support it.

View file

@ -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

35
docs/plugins/mbsync.rst Normal file
View file

@ -0,0 +1,35 @@
MBSync Plugin
=============
This plugin provides the ``mbsync`` command, which lets you fetch metadata
from MusicBrainz for albums and tracks that already have MusicBrainz IDs. This
is useful for updating tags as they are fixed in the MusicBrainz database, or
when you change your mind about some config options that change how tags are
written to files. If you have a music library that is already nicely tagged by
a program that also uses MusicBrainz like Picard, this can speed up the
initial import if you just import "as-is" and then use ``mbsync`` to get
up-to-date tags that are written to the files according to your beets
configuration.
Usage
-----
Enable the plugin and then run ``beet mbsync QUERY`` to fetch updated metadata
for a part of your collection (or omit the query to run over your whole
library).
This plugin treats albums and singletons (non-album tracks) separately. It
first processes all matching singletons and then proceeds on to full albums.
The same query is used to search for both kinds of entities.
The command has a few command-line options:
* To preview the changes that would be made without applying them, use the
``-p`` (``--pretend``) flag.
* By default, files will be moved (renamed) according to their metadata if
they are inside your beets library directory. To disable this, use the
``-M`` (``--nomove``) command-line option.
* If you have the `import.write` configuration option enabled, then this
plugin will write new metadata to files' tags. To disable this, use the
``-W`` (``--nowrite``) option.

View file

@ -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

View file

@ -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
}

View file

@ -93,16 +93,21 @@ right now; this is something we need to work on. Read the
instead want to import individual, non-album tracks, use the *singleton*
mode by supplying the ``-s`` option.
* If you have an album that's split across several directories under a common
top directory, use the ``--flat`` option. This takes all the music files
under the directory (recursively) and treats them as a single large album
instead of as one album per directory. This can help with your more stubborn
multi-disc albums.
.. only:: html
Reimporting
^^^^^^^^^^^
The ``import`` command can also be used to "reimport" music that you've
already added to your library. This is useful for updating tags as they are
fixed in the MusicBrainz database, for when you change your mind about some
selections you made during the initial import, or if you prefer to import
everything "as-is" and then correct tags later.
already added to your library. This is useful when you change your mind
about some selections you made during the initial import, or if you prefer
to import everything "as-is" and then correct tags later.
Just point the ``beet import`` command at a directory of files that are
already catalogged in your library. Beets will automatically detect this
@ -121,6 +126,11 @@ right now; this is something we need to work on. Read the
or full albums. If you want to retag your whole library, just supply a null
query, which matches everything: ``beet import -L``
Note that, if you just want to update your files' tags according to
changes in the MusicBrainz database, the :doc:`/plugins/mbsync` is a
better choice. Reimporting uses the full matching machinery to guess
metadata matches; ``mbsync`` just relies on MusicBrainz IDs.
.. _list-cmd:
list

View file

@ -317,6 +317,26 @@ should be the *default* when selecting an action for a given match. This is the
action that will be taken when you type return without an option letter. The
default is ``apply``.
.. _languages:
languages
~~~~~~~~~
A list of locale names to search for preferred aliases. For example, setting
this to "en" uses the transliterated artist name "Pyotr Ilyich Tchaikovsky"
instead of the Cyrillic script for the composer's name when tagging from
MusicBrainz. Defaults to an empty list, meaning that no language is preferred.
.. _detail:
detail
~~~~~~
Whether the importer UI should show detailed information about each match it
finds. When enabled, this mode prints out the title of every track, regardless
of whether it matches the original metadata. (The default behavior only shows
changes.) Default: ``no``.
.. _musicbrainz-config:
MusicBrainz Options

View file

@ -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:

View file

@ -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 [])

View file

@ -362,6 +362,10 @@ class DestinationTest(unittest.TestCase):
val = beets.library.format_for_path(12345, 'samplerate', posixpath)
self.assertEqual(val, u'12kHz')
def test_component_sanitize_none(self):
val = beets.library.format_for_path(None, 'foo', posixpath)
self.assertEqual(val, u'')
def test_artist_falls_back_to_albumartist(self):
self.i.artist = ''
self.i.albumartist = 'something'

View file

@ -16,6 +16,7 @@
"""
from _common import unittest
from beets.autotag import mb
from beets import config
class MBAlbumInfoTest(unittest.TestCase):
def _make_release(self, date_str='2009', tracks=None):
@ -286,6 +287,18 @@ class ArtistFlatteningTest(unittest.TestCase):
'name': 'CREDIT' + suffix,
}
def _add_alias(self, credit_dict, suffix='', locale='', primary=False):
alias = {
'alias': 'ALIAS' + suffix,
'locale': locale,
'sort-name': 'ALIASSORT' + suffix
}
if primary:
alias['primary'] = 'primary'
if 'alias-list' not in credit_dict['artist']:
credit_dict['artist']['alias-list'] = []
credit_dict['artist']['alias-list'].append(alias)
def test_single_artist(self):
a, s, c = mb._flatten_artist_credit([self._credit_dict()])
self.assertEqual(a, 'NAME')
@ -300,6 +313,38 @@ class ArtistFlatteningTest(unittest.TestCase):
self.assertEqual(s, 'SORTa AND SORTb')
self.assertEqual(c, 'CREDITa AND CREDITb')
def test_alias(self):
credit_dict = self._credit_dict()
self._add_alias(credit_dict, suffix='en', locale='en')
self._add_alias(credit_dict, suffix='en_GB', locale='en_GB')
self._add_alias(credit_dict, suffix='fr', locale='fr')
self._add_alias(credit_dict, suffix='fr_P', locale='fr', primary=True)
# test no alias
config['import']['languages'] = ['']
flat = mb._flatten_artist_credit([credit_dict])
self.assertEqual(flat, ('NAME', 'SORT', 'CREDIT'))
# test en
config['import']['languages'] = ['en']
flat = mb._flatten_artist_credit([credit_dict])
self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT'))
# test en_GB en
config['import']['languages'] = ['en_GB', 'en']
flat = mb._flatten_artist_credit([credit_dict])
self.assertEqual(flat, ('ALIASen_GB', 'ALIASSORTen_GB', 'CREDIT'))
# test en en_GB
config['import']['languages'] = ['en', 'en_GB']
flat = mb._flatten_artist_credit([credit_dict])
self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT'))
# test fr primary
config['import']['languages'] = ['fr']
flat = mb._flatten_artist_credit([credit_dict])
self.assertEqual(flat, ('ALIASfr_P', 'ALIASSORTfr_P', 'CREDIT'))
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

View file

@ -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.