mirror of
https://github.com/beetbox/beets.git
synced 2025-12-16 13:43:53 +01:00
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:
commit
d41fb7f0ea
36 changed files with 934 additions and 413 deletions
1
.hgtags
1
.hgtags
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
413
beets/library.py
413
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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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)
|
||||
|
|
|
|||
|
|
@ -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,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
159
beetsplug/mbsync.py
Normal 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]
|
||||
|
|
@ -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)
|
||||
-------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-----------------------
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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::
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
35
docs/plugins/mbsync.rst
Normal 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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [])
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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