This commit is contained in:
Fabrice Laporte 2013-03-16 09:16:21 +01:00
commit 9c0fa57f34
23 changed files with 515 additions and 375 deletions

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

@ -45,13 +45,8 @@ class MusicBrainzAPIError(util.HumanReadableException):
log = logging.getLogger('beets')
RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups',
'labels', 'artist-credits']
TRACK_INCLUDES = ['artists']
# Only versions >= 0.3 of python-musicbrainz-ngs support artist aliases.
if musicbrainzngs.musicbrainz._version >= '0.3':
RELEASE_INCLUDES.append('aliases')
TRACK_INCLUDES.append('aliases')
'labels', 'artist-credits', 'aliases']
TRACK_INCLUDES = ['artists', 'aliases']
def configure():
"""Set up the python-musicbrainz-ngs module according to settings

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
@ -469,12 +454,51 @@ class Query(object):
class FieldQuery(Query):
"""An abstract query that searches in a specific field for a
pattern.
pattern. Subclasses must provide a `value_match` class method, which
determines whether a certain pattern string matches a certain value
string. Subclasses also need to provide `clause` to implement the
same matching functionality in SQLite.
"""
def __init__(self, field, pattern):
self.field = field
self.pattern = pattern
@classmethod
def value_match(cls, pattern, value):
"""Determine whether the value matches the pattern. Both
arguments are strings.
"""
raise NotImplementedError()
@classmethod
def _raw_value_match(cls, pattern, value):
"""Determine whether the value matches the pattern. The value
may have any type.
"""
return cls.value_match(pattern, util.as_string(value))
def match(self, item):
return self._raw_value_match(self.pattern, getattr(item, self.field))
class RegisteredFieldQuery(FieldQuery):
"""A FieldQuery that uses a registered SQLite callback function.
Before it can be used to execute queries, the `register` method must
be called.
"""
def clause(self):
# Invoke the registered SQLite function.
clause = "{name}(?, {field})".format(name=self.__class__.__name__,
field=self.field)
return clause, [self.pattern]
@classmethod
def register(cls, conn):
"""Register this query's matching function with the SQLite
connection. This method should only be invoked when the query
type chooses not to override `clause`.
"""
conn.create_function(cls.__name__, 2, cls._raw_value_match)
class MatchQuery(FieldQuery):
"""A query that looks for exact matches in an item field."""
def clause(self):
@ -483,8 +507,11 @@ class MatchQuery(FieldQuery):
pattern = buffer(pattern)
return self.field + " = ?", [pattern]
def match(self, item):
return self.pattern == getattr(item, self.field)
# We override the "raw" version here as a special case because we
# want to compare objects before conversion.
@classmethod
def _raw_value_match(cls, pattern, value):
return pattern == value
class SubstringQuery(FieldQuery):
"""A query that matches a substring in a specific item field."""
@ -495,24 +522,22 @@ class SubstringQuery(FieldQuery):
subvals = [search]
return clause, subvals
def match(self, item):
value = util.as_string(getattr(item, self.field))
return self.pattern.lower() in value.lower()
@classmethod
def value_match(cls, pattern, value):
return pattern.lower() in value.lower()
class RegexpQuery(FieldQuery):
"""A query that matches a regular expression in a specific item field."""
def __init__(self, field, pattern):
super(RegexpQuery, self).__init__(field, pattern)
self.regexp = re.compile(pattern)
def clause(self):
clause = self.field + " REGEXP ?"
subvals = [self.pattern]
return clause, subvals
def match(self, item):
value = util.as_string(getattr(item, self.field))
return self.regexp.search(value) is not None
class RegexpQuery(RegisteredFieldQuery):
"""A query that matches a regular expression in a specific item
field.
"""
@classmethod
def value_match(cls, pattern, value):
try:
res = re.search(pattern, value)
except re.error:
# Invalid regular expression.
return False
return res is not None
class BooleanQuery(MatchQuery):
"""Matches a boolean field. Pattern should either be a boolean or a
@ -564,103 +589,25 @@ class CollectionQuery(Query):
clause = (' ' + joiner + ' ').join(clause_parts)
return clause, subvals
# Regular expression for _parse_query_part, below.
_pq_regex = re.compile(
# Non-capturing optional segment for the keyword.
r'(?:'
r'(\S+?)' # The field key.
r'(?<!\\):' # Unescaped :
r')?'
r'((?<!\\):?)' # Unescaped : indicating a regex.
r'(.+)', # The term itself.
re.I # Case-insensitive.
)
@classmethod
def _parse_query_part(cls, part):
"""Takes a query in the form of a key/value pair separated by a
colon. An additional colon before the value indicates that the
value is a regular expression. Returns tuple (key, term,
is_regexp) where key is None if the search term has no key and
is_regexp indicates whether term is a regular expression or an
ordinary substring match.
For instance,
parse_query('stapler') == (None, 'stapler', false)
parse_query('color:red') == ('color', 'red', false)
parse_query(':^Quiet') == (None, '^Quiet', true)
parse_query('color::b..e') == ('color', 'b..e', true)
Colons may be 'escaped' with a backslash to disable the keying
behavior.
"""
part = part.strip()
match = cls._pq_regex.match(part)
if match:
return (
match.group(1), # Key.
match.group(3).replace(r'\:', ':'), # Term.
match.group(2) == ':', # Regular expression.
)
@classmethod
def from_strings(cls, query_parts, default_fields=None,
all_keys=ITEM_KEYS):
def from_strings(cls, query_parts, default_fields, all_keys):
"""Creates a query from a list of strings in the format used by
_parse_query_part. If default_fields are specified, they are the
parse_query_part. If default_fields are specified, they are the
fields to be searched by unqualified search terms. Otherwise,
all fields are searched for those terms.
"""
subqueries = []
for part in query_parts:
res = cls._parse_query_part(part)
if not res:
continue
key, pattern, is_regexp = res
# No key specified.
if key is None:
if os.sep in pattern and 'path' in all_keys:
# This looks like a path.
subqueries.append(PathQuery(pattern))
else:
# Match any field.
if is_regexp:
subq = AnyRegexpQuery(pattern, default_fields)
else:
subq = AnySubstringQuery(pattern, default_fields)
subqueries.append(subq)
# A boolean field.
elif key.lower() == 'comp':
subqueries.append(BooleanQuery(key.lower(), pattern))
# Path field.
elif key.lower() == 'path' and 'path' in all_keys:
subqueries.append(PathQuery(pattern))
# Other (recognized) field.
elif key.lower() in all_keys:
if is_regexp:
subqueries.append(RegexpQuery(key.lower(), pattern))
else:
subqueries.append(SubstringQuery(key.lower(), pattern))
# Singleton query (not a real field).
elif key.lower() == 'singleton':
subqueries.append(SingletonQuery(util.str2bool(pattern)))
# Unrecognized field.
else:
log.warn(u'no such field in query: {0}'.format(key))
subq = construct_query_part(part, default_fields, all_keys)
if subq:
subqueries.append(subq)
if not subqueries: # No terms in query.
subqueries = [TrueQuery()]
return cls(subqueries)
@classmethod
def from_string(cls, query, default_fields=None, all_keys=ITEM_KEYS):
def from_string(cls, query, default_fields=ITEM_DEFAULT_FIELDS,
all_keys=ITEM_KEYS):
"""Creates a query based on a single string. The string is split
into query parts using shell-style syntax.
"""
@ -670,71 +617,32 @@ class CollectionQuery(Query):
if isinstance(query, unicode):
query = query.encode('utf8')
parts = [s.decode('utf8') for s in shlex.split(query)]
return cls.from_strings(parts, default_fields=default_fields,
all_keys=all_keys)
return cls.from_strings(parts, default_fields, all_keys)
class AnySubstringQuery(CollectionQuery):
"""A query that matches a substring in any of a list of metadata
fields.
class AnyFieldQuery(CollectionQuery):
"""A query that matches if a given FieldQuery subclass matches in
any field. The individual field query class is provided to the
constructor.
"""
def __init__(self, pattern, fields=None):
"""Create a query for pattern over the sequence of fields
given. If no fields are given, all available fields are
used.
"""
def __init__(self, pattern, fields, cls):
self.pattern = pattern
self.fields = fields or ITEM_KEYS_WRITABLE
self.fields = fields
self.query_class = cls
subqueries = []
for field in self.fields:
subqueries.append(SubstringQuery(field, pattern))
super(AnySubstringQuery, self).__init__(subqueries)
subqueries.append(cls(field, pattern))
super(AnyFieldQuery, self).__init__(subqueries)
def clause(self):
return self.clause_with_joiner('or')
def match(self, item):
for fld in self.fields:
try:
val = getattr(item, fld)
except KeyError:
continue
if isinstance(val, basestring) and \
self.pattern.lower() in val.lower():
for subq in self.subqueries:
if subq.match(item):
return True
return False
class AnyRegexpQuery(CollectionQuery):
"""A query that matches a regexp in any of a list of metadata
fields.
"""
def __init__(self, pattern, fields=None):
"""Create a query for regexp over the sequence of fields
given. If no fields are given, all available fields are
used.
"""
self.regexp = re.compile(pattern)
self.fields = fields or ITEM_KEYS_WRITABLE
subqueries = []
for field in self.fields:
subqueries.append(RegexpQuery(field, pattern))
super(AnyRegexpQuery, self).__init__(subqueries)
def clause(self):
return self.clause_with_joiner('or')
def match(self, item):
for fld in self.fields:
try:
val = getattr(item, fld)
except KeyError:
continue
if isinstance(val, basestring) and \
self.regexp.match(val) is not None:
return True
return False
class MutableCollectionQuery(CollectionQuery):
"""A collection query whose subqueries may be modified after the
query is initialized.
@ -798,6 +706,96 @@ class ResultIterator(object):
row = self.rowiter.next() # May raise StopIteration.
return Item(row)
# Regular expression for parse_query_part, below.
PARSE_QUERY_PART_REGEX = re.compile(
# Non-capturing optional segment for the keyword.
r'(?:'
r'(\S+?)' # The field key.
r'(?<!\\):' # Unescaped :
r')?'
r'(.+)', # The term itself.
re.I # Case-insensitive.
)
def parse_query_part(part):
"""Takes a query in the form of a key/value pair separated by a
colon. The value part is matched against a list of prefixes that
can be extended by plugins to add custom query types. For
example, the colon prefix denotes a regular expression query.
The function returns a tuple of `(key, value, cls)`. `key` may
be None, indicating that any field may be matched. `cls` is a
subclass of `FieldQuery`.
For instance,
parse_query('stapler') == (None, 'stapler', None)
parse_query('color:red') == ('color', 'red', None)
parse_query(':^Quiet') == (None, '^Quiet', RegexpQuery)
parse_query('color::b..e') == ('color', 'b..e', RegexpQuery)
Prefixes may be 'escaped' with a backslash to disable the keying
behavior.
"""
part = part.strip()
match = PARSE_QUERY_PART_REGEX.match(part)
prefixes = {':': RegexpQuery}
prefixes.update(plugins.queries())
if match:
key = match.group(1)
term = match.group(2).replace('\:', ':')
# Match the search term against the list of prefixes.
for pre, query_class in prefixes.items():
if term.startswith(pre):
return key, term[len(pre):], query_class
return key, term, SubstringQuery # The default query type.
def construct_query_part(query_part, default_fields, all_keys):
"""Create a query from a single query component. Return a Query
instance or None if the value cannot be parsed.
"""
parsed = parse_query_part(query_part)
if not parsed:
return
key, pattern, query_class = parsed
# No key specified.
if key is None:
if os.sep in pattern and 'path' in all_keys:
# This looks like a path.
return PathQuery(pattern)
elif issubclass(query_class, FieldQuery):
# The query type matches a specific field, but none was
# specified. So we use a version of the query that matches
# any field.
return AnyFieldQuery(pattern, default_fields, query_class)
else:
# Other query type.
return query_class(pattern)
# A boolean field.
elif key.lower() == 'comp':
return BooleanQuery(key.lower(), pattern)
# Path field.
elif key.lower() == 'path' and 'path' in all_keys:
return PathQuery(pattern)
# Other (recognized) field.
elif key.lower() in all_keys:
return query_class(key.lower(), pattern)
# Singleton query (not a real field).
elif key.lower() == 'singleton':
return SingletonQuery(util.str2bool(pattern))
# Unrecognized field.
else:
log.warn(u'no such field in query: {0}'.format(key))
def get_query(val, album=False):
"""Takes a value which may be None, a query string, a query string
list, or a Query object, and returns a suitable Query object. album
@ -825,7 +823,6 @@ def get_query(val, album=False):
raise ValueError('query must be None or have type Query or str')
# An abstract library.
class BaseLibrary(object):
@ -1123,8 +1120,12 @@ class Library(BaseLibrary):
# Access SELECT results like dictionaries.
conn.row_factory = sqlite3.Row
# Add the REGEXP function to SQLite queries.
conn.create_function("REGEXP", 2, _regexp)
# Register plugin queries.
RegexpQuery.register(conn)
for prefix, query_class in plugins.queries().items():
if issubclass(query_class, RegisteredFieldQuery):
query_class.register(conn)
self._connections[thread_id] = conn
return conn

View file

@ -54,6 +54,12 @@ class BeetsPlugin(object):
commands that should be added to beets' CLI.
"""
return ()
def queries(self):
"""Should return a dict mapping prefixes to PluginQuery
subclasses.
"""
return {}
def track_distance(self, item, info):
"""Should return a (distance, distance_max) pair to be added
@ -93,6 +99,7 @@ class BeetsPlugin(object):
"""
return {}
listeners = None
@classmethod
@ -209,6 +216,15 @@ def commands():
out += plugin.commands()
return out
def queries():
"""Returns a dict mapping prefix strings to beet.library.PluginQuery
subclasses all loaded plugins.
"""
out = {}
for plugin in find_plugins():
out.update(plugin.queries())
return out
def track_distance(item, info):
"""Gets the track distance calculated by all loaded plugins.
Returns a (distance, distance_max) pair.

View file

@ -643,9 +643,11 @@ def import_files(lib, paths, query):
for path in paths:
fullpath = syspath(normpath(path))
if not config['import']['singletons'] and not os.path.isdir(fullpath):
raise ui.UserError('not a directory: ' + path)
raise ui.UserError(u'not a directory: {0}'.format(
displayable_path(path)))
elif config['import']['singletons'] and not os.path.exists(fullpath):
raise ui.UserError('no such file: ' + path)
raise ui.UserError(u'no such file: {0}'.format(
displayable_path(path)))
# Check parameter consistency.
if config['import']['quiet'] and config['import']['timid']:

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,14 +28,29 @@ from beets import config
log = logging.getLogger('beets')
DEVNULL = open(os.devnull, 'wb')
_fs_lock = threading.Lock()
_temp_files = [] # Keep track of temporary transcoded files for deletion.
def _destination(lib, dest_dir, item, keep_new):
"""Return the path under `dest_dir` where the file should be placed
(possibly after conversion).
"""
dest = lib.destination(item, basedir=dest_dir)
if keep_new:
# When we're keeping the converted file, no extension munging
# occurs.
return dest
else:
# Otherwise, replace the extension with .mp3.
return os.path.splitext(dest)[0] + '.mp3'
def encode(source, dest):
log.info(u'Started encoding {0}'.format(util.displayable_path(source)))
opts = config['convert']['opts'].get(unicode).split(u' ')
encode = Popen([config['convert']['ffmpeg'].get(unicode), '-i', source] +
opts + [dest],
encode = Popen([config['convert']['ffmpeg'].get(unicode), '-i',
source, '-y'] + opts + [dest],
close_fds=True, stderr=DEVNULL)
encode.wait()
if encode.returncode != 0:
@ -47,12 +63,18 @@ def encode(source, dest):
log.info(u'Finished encoding {0}'.format(util.displayable_path(source)))
def convert_item(lib, dest_dir):
def should_transcode(item):
"""Determine whether the item should be transcoded as part of
conversion (i.e., its bitrate is high or it has the wrong format).
"""
maxbr = config['convert']['max_bitrate'].get(int)
return item.format != 'MP3' or item.bitrate >= 1000 * maxbr
def convert_item(lib, dest_dir, keep_new):
while True:
item = yield
dest = os.path.join(dest_dir, lib.destination(item, fragment=True))
dest = os.path.splitext(dest)[0] + '.mp3'
dest = _destination(lib, dest_dir, item, keep_new)
if os.path.exists(util.syspath(dest)):
log.info(u'Skipping {0} (target file exists)'.format(
@ -66,16 +88,40 @@ def convert_item(lib, dest_dir):
with _fs_lock:
util.mkdirall(dest)
maxbr = config['convert']['max_bitrate'].get(int)
if item.format == 'MP3' and item.bitrate < 1000 * maxbr:
log.info(u'Copying {0}'.format(util.displayable_path(item.path)))
util.copy(item.path, dest)
else:
encode(item.path, dest)
# When keeping the new file in the library, we first move the
# current (pristine) file to the destination. We'll then copy it
# back to its old path or transcode it to a new path.
if keep_new:
log.info(u'Moving to {0}'.
format(util.displayable_path(dest)))
util.move(item.path, dest)
item.path = dest
if not should_transcode(item):
# No transcoding necessary.
log.info(u'Copying {0}'.format(util.displayable_path(item.path)))
if keep_new:
util.copy(dest, item.path)
else:
util.copy(item.path, dest)
else:
if keep_new:
item.path = os.path.splitext(item.path)[0] + '.mp3'
encode(dest, item.path)
else:
encode(item.path, dest)
# Write tags from the database to the converted file.
if not keep_new:
item.path = dest
item.write()
# If we're keeping the transcoded file, read it again (after
# writing) to get new bitrate, duration, etc.
if keep_new:
item.read()
lib.store(item) # Store new path and audio data.
if config['convert']['embed']:
album = lib.get_album(item)
if album:
@ -84,13 +130,30 @@ def convert_item(lib, dest_dir):
_embed(artpath, [item])
def convert_on_import(lib, item):
"""Transcode a file automatically after it is imported into the
library.
"""
if should_transcode(item):
fd, dest = tempfile.mkstemp('.mp3')
os.close(fd)
_temp_files.append(dest) # Delete the transcode later.
encode(item.path, dest)
item.path = dest
item.write()
item.read() # Load new audio information data.
lib.store(item)
def convert_func(lib, opts, args):
dest = opts.dest if opts.dest is not None else \
config['convert']['dest'].get()
if not dest:
raise ui.UserError('no convert destination set')
dest = util.bytestring_path(dest)
threads = opts.threads if opts.threads is not None else \
config['convert']['threads'].get(int)
keep_new = opts.keep_new
ui.commands.list_items(lib, ui.decargs(args), opts.album, None)
@ -101,7 +164,7 @@ def convert_func(lib, opts, args):
items = (i for a in lib.albums(ui.decargs(args)) for i in a.items())
else:
items = lib.items(ui.decargs(args))
convert = [convert_item(lib, dest) for i in range(threads)]
convert = [convert_item(lib, dest, keep_new) for i in range(threads)]
pipe = util.pipeline.Pipeline([items, convert])
pipe.run_parallel()
@ -116,7 +179,9 @@ class ConvertPlugin(BeetsPlugin):
u'opts': u'-aq 2',
u'max_bitrate': 500,
u'embed': True,
u'auto': False
})
self.import_stages = [self.auto_convert]
def commands(self):
cmd = ui.Subcommand('convert', help='convert to external location')
@ -125,7 +190,27 @@ class ConvertPlugin(BeetsPlugin):
cmd.parser.add_option('-t', '--threads', action='store', type='int',
help='change the number of threads, \
defaults to maximum availble processors ')
cmd.parser.add_option('-k', '--keep-new', action='store_true',
dest='keep_new', help='keep only the converted \
and move the old files')
cmd.parser.add_option('-d', '--dest', action='store',
help='set the destination directory')
cmd.func = convert_func
return [cmd]
def auto_convert(self, config, task):
if self.config['auto'].get():
if not task.is_album:
convert_on_import(config.lib, task.item)
else:
for item in task.items:
convert_on_import(config.lib, item)
@ConvertPlugin.listen('import_task_files')
def _cleanup(task, session):
for path in task.old_paths:
if path in _temp_files:
if os.path.isfile(path):
util.remove(path)
_temp_files.remove(path)

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,15 +19,14 @@ from beets.ui import Subcommand
from beets import ui
from beets import config
import musicbrainzngs
from musicbrainzngs import musicbrainz
SUBMISSION_CHUNK_SIZE = 200
def mb_request(*args, **kwargs):
"""Send a MusicBrainz API request and process exceptions.
def mb_call(func, *args, **kwargs):
"""Call a MusicBrainz API function and catch exceptions.
"""
try:
return musicbrainz._mb_request(*args, **kwargs)
return func(*args, **kwargs)
except musicbrainzngs.AuthenticationError:
raise ui.UserError('authentication with MusicBrainz failed')
except musicbrainzngs.ResponseError as exc:
@ -41,17 +40,14 @@ def submit_albums(collection_id, release_ids):
"""
for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE):
chunk = release_ids[i:i+SUBMISSION_CHUNK_SIZE]
releaselist = ";".join(chunk)
mb_request(
"collection/%s/releases/%s" % (collection_id, releaselist),
'PUT', True, True, body='foo'
mb_call(
musicbrainzngs.add_releases_to_collection,
collection_id, chunk
)
# A non-empty request body is required to avoid a 411 "Length
# Required" error from the MB server.
def update_collection(lib, opts, args):
# Get the collection to modify.
collections = mb_request('collection', 'GET', True, True)
collections = mb_call(musicbrainzngs.get_collections)
if not collections['collection-list']:
raise ui.UserError('no collections exist for user')
collection_id = collections['collection-list'][0]['id']

View file

@ -8,13 +8,14 @@ New configuration options:
* :ref:`languages` controls the preferred languages when selecting an alias
from MusicBrainz. This feature requires `python-musicbrainz-ngs`_ 0.3 or
later, which (at the time of this writing) is not yet released. Thanks to
Sam Doshi.
later. Thanks to Sam Doshi.
* :ref:`detail` enables a mode where all tracks are listed in the importer UI,
as opposed to only changed tracks.
* The ``--flat`` option to the ``beet import`` command treats an entire
directory tree of music files as a single album. This can help in situations
where a multi-disc album is split across multiple directories.
* :doc:`/plugins/importfeeds`: An option was added to use absolute, rather
than relative, paths. Thanks to Lucas Duailibe.
Other stuff:
@ -22,6 +23,20 @@ Other stuff:
track in MusicBrainz and updates your library to reflect it. This can help
you easily correct errors that have been fixed in the MB database. Thanks to
Jakob Schnitzer.
* :doc:`/plugins/fuzzy`: The ``fuzzy`` command was removed and replaced with a
new query type. To perform fuzzy searches, use the ``~`` prefix with
:ref:`list-cmd` or other commands. Thanks to Philippe Mongeau.
* As part of the above, plugins can now extend the query syntax and new kinds
of matching capabilities to beets. See :ref:`extend-query`. Thanks again to
Philippe Mongeau.
* :doc:`/plugins/convert`: A new ``--keep-new`` option lets you store
transcoded files in your library while backing up the originals (instead of
vice-versa). Thanks to Lucas Duailibe.
* :doc:`/plugins/convert`: Also, a new ``auto`` config option will transcode
audio files automatically during import. Thanks again to Lucas Duailibe.
* :doc:`/plugins/chroma`: A new ``fingerprint`` command lets you generate and
store fingerprints for items that don't yet have them. One more round of
applause for Lucas Duailibe.
* :doc:`/plugins/echonest_tempo`: API errors now issue a warning instead of
exiting with an exception. We also avoid an error when track metadata
contains newlines.
@ -42,8 +57,14 @@ Other stuff:
pathnames.
* Fix a spurious warning from the Unidecode module when matching albums that
are missing all metadata.
* Fix Unicode errors when a directory or file doesn't exist when invoking the
import command. Thanks to Lucas Duailibe.
* :doc:`/plugins/mbcollection`: Show friendly, human-readable errors when
MusicBrainz exceptions occur.
* :doc:`/plugins/echonest_tempo`: Catch socket errors that are not handled by
the Echo Nest library.
* :doc:`/plugins/chroma`: Catch Acoustid Web service errors when submitting
fingerprints.
1.1b2 (February 16, 2013)
-------------------------

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 36 KiB

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

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

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

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

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