Merge remote-tracking branch 'upstream/master' into discogs-relax-assumptions

Conflicts:
	docs/changelog.rst
This commit is contained in:
Diego M. Rodriguez 2016-12-29 13:26:07 +01:00
commit 4364757fcc
No known key found for this signature in database
GPG key ID: 7DFAF18D8415B8F9
14 changed files with 229 additions and 234 deletions

View file

@ -23,7 +23,7 @@ from beets import config
# Parts of external interface.
from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch # noqa
from .match import tag_item, tag_album # noqa
from .match import tag_item, tag_album, Proposal # noqa
from .match import Recommendation # noqa
# Global logger.

View file

@ -22,6 +22,7 @@ from __future__ import division, absolute_import, print_function
import datetime
import re
from munkres import Munkres
from collections import namedtuple
from beets import logging
from beets import plugins
@ -52,6 +53,13 @@ class Recommendation(OrderedEnum):
strong = 3
# A structure for holding a set of possible matches to choose between. This
# consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo
# objects) and a recommendation value.
Proposal = namedtuple('Proposal', ('candidates', 'recommendation'))
# Primary matching functionality.
def current_metadata(items):
@ -379,9 +387,8 @@ def _add_candidate(items, results, info):
def tag_album(items, search_artist=None, search_album=None,
search_ids=[]):
"""Return a tuple of a artist name, an album name, a list of
`AlbumMatch` candidates from the metadata backend, and a
`Recommendation`.
"""Return a tuple of the current artist name, the current album
name, and a `Proposal` containing `AlbumMatch` candidates.
The artist and album are the most common values of these fields
among `items`.
@ -429,7 +436,7 @@ def tag_album(items, search_artist=None, search_album=None,
if rec == Recommendation.strong:
log.debug(u'ID match.')
return cur_artist, cur_album, \
list(candidates.values()), rec
Proposal(list(candidates.values()), rec)
# Search terms.
if not (search_artist and search_album):
@ -454,14 +461,15 @@ def tag_album(items, search_artist=None, search_album=None,
# Sort and get the recommendation.
candidates = _sort_candidates(candidates.values())
rec = _recommendation(candidates)
return cur_artist, cur_album, candidates, rec
return cur_artist, cur_album, Proposal(candidates, rec)
def tag_item(item, search_artist=None, search_title=None,
search_ids=[]):
"""Attempts to find metadata for a single track. Returns a
`(candidates, recommendation)` pair where `candidates` is a list of
TrackMatch objects. `search_artist` and `search_title` may be used
"""Find metadata for a single track. Return a `Proposal` consisting
of `TrackMatch` objects.
`search_artist` and `search_title` may be used
to override the current metadata for the purposes of the MusicBrainz
title. `search_ids` may be used for restricting the search to a list
of metadata backend IDs.
@ -484,14 +492,14 @@ def tag_item(item, search_artist=None, search_title=None,
if rec == Recommendation.strong and \
not config['import']['timid']:
log.debug(u'Track ID match.')
return _sort_candidates(candidates.values()), rec
return Proposal(_sort_candidates(candidates.values()), rec)
# If we're searching by ID, don't proceed.
if search_ids:
if candidates:
return _sort_candidates(candidates.values()), rec
return Proposal(_sort_candidates(candidates.values()), rec)
else:
return [], Recommendation.none
return Proposal([], Recommendation.none)
# Search terms.
if not (search_artist and search_title):
@ -507,4 +515,4 @@ def tag_item(item, search_artist=None, search_title=None,
log.debug(u'Found {0} candidates.', len(candidates))
candidates = _sort_candidates(candidates.values())
rec = _recommendation(candidates)
return candidates, rec
return Proposal(candidates, rec)

View file

@ -43,8 +43,7 @@ from enum import Enum
from beets import mediafile
action = Enum('action',
['SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'APPLY', 'MANUAL_ID',
'ALBUMS', 'RETAG'])
['SKIP', 'ASIS', 'TRACKS', 'APPLY', 'ALBUMS', 'RETAG'])
# The RETAG action represents "don't apply any match, but do record
# new metadata". It's not reachable via the standard command prompt but
# can be used by plugins.
@ -443,7 +442,6 @@ class ImportTask(BaseImportTask):
indicates that an action has been selected for this task.
"""
# Not part of the task structure:
assert choice not in (action.MANUAL, action.MANUAL_ID)
assert choice != action.APPLY # Only used internally.
if choice in (action.SKIP, action.ASIS, action.TRACKS, action.ALBUMS,
action.RETAG):
@ -587,12 +585,12 @@ class ImportTask(BaseImportTask):
candidate IDs are stored in self.search_ids: if present, the
initial lookup is restricted to only those IDs.
"""
artist, album, candidates, recommendation = \
artist, album, prop = \
autotag.tag_album(self.items, search_ids=self.search_ids)
self.cur_artist = artist
self.cur_album = album
self.candidates = candidates
self.rec = recommendation
self.candidates = prop.candidates
self.rec = prop.recommendation
def find_duplicates(self, lib):
"""Return a list of albums from `lib` with the same artist and
@ -830,10 +828,9 @@ class SingletonImportTask(ImportTask):
plugins.send('item_imported', lib=lib, item=item)
def lookup_candidates(self):
candidates, recommendation = autotag.tag_item(
self.item, search_ids=self.search_ids)
self.candidates = candidates
self.rec = recommendation
prop = autotag.tag_item(self.item, search_ids=self.search_ids)
self.candidates = prop.candidates
self.rec = prop.recommendation
def find_duplicates(self, lib):
"""Return a list of items from `lib` that have the same artist

0
beets/library.py Executable file → Normal file
View file

View file

@ -87,8 +87,8 @@ PREFERRED_IMAGE_EXTENSIONS = {'jpeg': 'jpg'}
class UnreadableFileError(Exception):
"""Mutagen is not able to extract information from the file.
"""
def __init__(self, path):
Exception.__init__(self, repr(path))
def __init__(self, path, msg):
Exception.__init__(self, msg if msg else repr(path))
class FileTypeError(UnreadableFileError):
@ -132,7 +132,7 @@ def mutagen_call(action, path, func, *args, **kwargs):
return func(*args, **kwargs)
except mutagen.MutagenError as exc:
log.debug(u'%s failed: %s', action, six.text_type(exc))
raise UnreadableFileError(path)
raise UnreadableFileError(path, six.text_type(exc))
except Exception as exc:
# Isolate bugs in Mutagen.
log.debug(u'%s', traceback.format_exc())

View file

@ -42,7 +42,7 @@ from beets.util.confit import _package_path
import six
VARIOUS_ARTISTS = u'Various Artists'
PromptChoice = namedtuple('ExtraChoice', ['short', 'long', 'callback'])
PromptChoice = namedtuple('PromptChoice', ['short', 'long', 'callback'])
# Global logger.
log = logging.getLogger('beets')
@ -158,7 +158,7 @@ def disambig_string(info):
if isinstance(info, hooks.AlbumInfo):
if info.media:
if info.mediums > 1:
if info.mediums and info.mediums > 1:
disambig.append(u'{0}x{1}'.format(
info.mediums, info.media
))
@ -495,7 +495,7 @@ def _summary_judgment(rec):
def choose_candidate(candidates, singleton, rec, cur_artist=None,
cur_album=None, item=None, itemcount=None,
extra_choices=[]):
choices=[]):
"""Given a sorted list of candidates, ask the user for a selection
of which candidate to use. Applies to both full albums and
singletons (tracks). Candidates are either AlbumMatch or TrackMatch
@ -503,16 +503,12 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
`cur_album`, and `itemcount` must be provided. For singletons,
`item` must be provided.
`extra_choices` is a list of `PromptChoice`s, containg the choices
appended by the plugins after receiving the `before_choose_candidate`
event. If not empty, the choices are appended to the prompt presented
to the user.
`choices` is a list of `PromptChoice`s to be used in each prompt.
Returns one of the following:
* the result of the choice, which may be SKIP, ASIS, TRACKS, or MANUAL
* the result of the choice, which may be SKIP or ASIS
* a candidate (an AlbumMatch/TrackMatch object)
* the short letter of a `PromptChoice` (if the user selected one of
the `extra_choices`).
* a chosen `PromptChoice` from `choices`
"""
# Sanity check.
if singleton:
@ -521,41 +517,22 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
assert cur_artist is not None
assert cur_album is not None
# Build helper variables for extra choices.
extra_opts = tuple(c.long for c in extra_choices)
extra_actions = tuple(c.short for c in extra_choices)
# Build helper variables for the prompt choices.
choice_opts = tuple(c.long for c in choices)
choice_actions = {c.short: c for c in choices}
# Zero candidates.
if not candidates:
if singleton:
print_(u"No matching recordings found.")
opts = (u'Use as-is', u'Skip', u'Enter search', u'enter Id',
u'aBort')
else:
print_(u"No matching release found for {0} tracks."
.format(itemcount))
print_(u'For help, see: '
u'http://beets.readthedocs.org/en/latest/faq.html#nomatch')
opts = (u'Use as-is', u'as Tracks', u'Group albums', u'Skip',
u'Enter search', u'enter Id', u'aBort')
sel = ui.input_options(opts + extra_opts)
if sel == u'u':
return importer.action.ASIS
elif sel == u't':
assert not singleton
return importer.action.TRACKS
elif sel == u'e':
return importer.action.MANUAL
elif sel == u's':
return importer.action.SKIP
elif sel == u'b':
raise importer.ImportAbort()
elif sel == u'i':
return importer.action.MANUAL_ID
elif sel == u'g':
return importer.action.ALBUMS
elif sel in extra_actions:
return sel
sel = ui.input_options(choice_opts)
if sel in choice_actions:
return choice_actions[sel]
else:
assert False
@ -603,33 +580,12 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
print_(u' '.join(line))
# Ask the user for a choice.
if singleton:
opts = (u'Skip', u'Use as-is', u'Enter search', u'enter Id',
u'aBort')
else:
opts = (u'Skip', u'Use as-is', u'as Tracks', u'Group albums',
u'Enter search', u'enter Id', u'aBort')
sel = ui.input_options(opts + extra_opts,
sel = ui.input_options(choice_opts,
numrange=(1, len(candidates)))
if sel == u's':
return importer.action.SKIP
elif sel == u'u':
return importer.action.ASIS
elif sel == u'm':
if sel == u'm':
pass
elif sel == u'e':
return importer.action.MANUAL
elif sel == u't':
assert not singleton
return importer.action.TRACKS
elif sel == u'b':
raise importer.ImportAbort()
elif sel == u'i':
return importer.action.MANUAL_ID
elif sel == u'g':
return importer.action.ALBUMS
elif sel in extra_actions:
return sel
elif sel in choice_actions:
return choice_actions[sel]
else: # Numerical selection.
match = candidates[sel - 1]
if sel != 1:
@ -649,13 +605,6 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
return match
# Ask for confirmation.
if singleton:
opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is',
u'Enter search', u'enter Id', u'aBort')
else:
opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is',
u'as Tracks', u'Group albums', u'Enter search',
u'enter Id', u'aBort')
default = config['import']['default_action'].as_choice({
u'apply': u'a',
u'skip': u's',
@ -664,43 +613,54 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
})
if default is None:
require = True
sel = ui.input_options(opts + extra_opts, require=require,
default=default)
sel = ui.input_options((u'Apply', u'More candidates') + choice_opts,
require=require, default=default)
if sel == u'a':
return match
elif sel == u'g':
return importer.action.ALBUMS
elif sel == u's':
return importer.action.SKIP
elif sel == u'u':
return importer.action.ASIS
elif sel == u't':
assert not singleton
return importer.action.TRACKS
elif sel == u'e':
return importer.action.MANUAL
elif sel == u'b':
raise importer.ImportAbort()
elif sel == u'i':
return importer.action.MANUAL_ID
elif sel in extra_actions:
return sel
elif sel in choice_actions:
return choice_actions[sel]
def manual_search(singleton):
"""Input either an artist and album (for full albums) or artist and
def manual_search(session, task):
"""Get a new `Proposal` using manual search criteria.
Input either an artist and album (for full albums) or artist and
track name (for singletons) for manual search.
"""
artist = input_(u'Artist:')
name = input_(u'Track:' if singleton else u'Album:')
return artist.strip(), name.strip()
artist = input_(u'Artist:').strip()
name = input_(u'Album:' if task.is_album else u'Track:').strip()
if task.is_album:
_, _, prop = autotag.tag_album(
task.items, artist, name
)
return prop
else:
return autotag.tag_item(task.item, artist, name)
def manual_id(singleton):
"""Input an ID, either for an album ("release") or a track ("recording").
def manual_id(session, task):
"""Get a new `Proposal` using a manually-entered ID.
Input an ID, either for an album ("release") or a track ("recording").
"""
prompt = u'Enter {0} ID:'.format(u'recording' if singleton else u'release')
return input_(prompt).strip()
prompt = u'Enter {0} ID:'.format(u'release' if task.is_album
else u'recording')
search_id = input_(prompt).strip()
if task.is_album:
_, _, prop = autotag.tag_album(
task.items, search_ids=search_id.split()
)
return prop
else:
return autotag.tag_item(task.item, search_ids=search_id.split())
def abort_action(session, task):
"""A prompt choice callback that aborts the importer.
"""
raise importer.ImportAbort()
class TerminalImportSession(importer.ImportSession):
@ -728,40 +688,33 @@ class TerminalImportSession(importer.ImportSession):
# Loop until we have a choice.
candidates, rec = task.candidates, task.rec
while True:
# Gather extra choices from plugins.
extra_choices = self._get_plugin_choices(task)
extra_ops = {c.short: c.callback for c in extra_choices}
# Ask for a choice from the user.
# Ask for a choice from the user. The result of
# `choose_candidate` may be an `importer.action`, an
# `AlbumMatch` object for a specific selection, or a
# `PromptChoice`.
choices = self._get_choices(task)
choice = choose_candidate(
candidates, False, rec, task.cur_artist, task.cur_album,
itemcount=len(task.items), extra_choices=extra_choices
itemcount=len(task.items), choices=choices
)
# Choose which tags to use.
if choice in (importer.action.SKIP, importer.action.ASIS,
importer.action.TRACKS, importer.action.ALBUMS):
# Basic choices that require no more action here.
if choice in (importer.action.SKIP, importer.action.ASIS):
# Pass selection to main control flow.
return choice
elif choice is importer.action.MANUAL:
# Try again with manual search terms.
search_artist, search_album = manual_search(False)
_, _, candidates, rec = autotag.tag_album(
task.items, search_artist, search_album
)
elif choice is importer.action.MANUAL_ID:
# Try a manually-entered ID.
search_id = manual_id(False)
if search_id:
_, _, candidates, rec = autotag.tag_album(
task.items, search_ids=search_id.split()
)
elif choice in list(extra_ops.keys()):
# Allow extra ops to automatically set the post-choice.
post_choice = extra_ops[choice](self, task)
# Plugin-provided choices. We invoke the associated callback
# function.
elif choice in choices:
post_choice = choice.callback(self, task)
if isinstance(post_choice, importer.action):
# MANUAL and MANUAL_ID have no effect, even if returned.
return post_choice
elif isinstance(post_choice, autotag.Proposal):
# Use the new candidates and continue around the loop.
candidates = post_choice.candidates
rec = post_choice.recommendation
# Otherwise, we have a specific match selection.
else:
# We have a candidate! Finish tagging. Here, choice is an
# AlbumMatch object.
@ -786,34 +739,22 @@ class TerminalImportSession(importer.ImportSession):
return action
while True:
extra_choices = self._get_plugin_choices(task)
extra_ops = {c.short: c.callback for c in extra_choices}
# Ask for a choice.
choices = self._get_choices(task)
choice = choose_candidate(candidates, True, rec, item=task.item,
extra_choices=extra_choices)
choices=choices)
if choice in (importer.action.SKIP, importer.action.ASIS):
return choice
elif choice == importer.action.TRACKS:
assert False # TRACKS is only legal for albums.
elif choice == importer.action.MANUAL:
# Continue in the loop with a new set of candidates.
search_artist, search_title = manual_search(True)
candidates, rec = autotag.tag_item(task.item, search_artist,
search_title)
elif choice == importer.action.MANUAL_ID:
# Ask for a track ID.
search_id = manual_id(True)
if search_id:
candidates, rec = autotag.tag_item(
task.item, search_ids=search_id.split())
elif choice in list(extra_ops.keys()):
# Allow extra ops to automatically set the post-choice.
post_choice = extra_ops[choice](self, task)
elif choice in choices:
post_choice = choice.callback(self, task)
if isinstance(post_choice, importer.action):
# MANUAL and MANUAL_ID have no effect, even if returned.
return post_choice
elif isinstance(post_choice, autotag.Proposal):
candidates = post_choice.candidates
rec = post_choice.recommendation
else:
# Chose a candidate.
assert isinstance(choice, autotag.TrackMatch)
@ -865,8 +806,10 @@ class TerminalImportSession(importer.ImportSession):
u"was interrupted. Resume (Y/n)?"
.format(displayable_path(path)))
def _get_plugin_choices(self, task):
"""Get the extra choices appended to the plugins to the ui prompt.
def _get_choices(self, task):
"""Get the list of prompt choices that should be presented to the
user. This consists of both built-in choices and ones provided by
plugins.
The `before_choose_candidate` event is sent to the plugins, with
session and task as its parameters. Plugins are responsible for
@ -879,20 +822,37 @@ class TerminalImportSession(importer.ImportSession):
Returns a list of `PromptChoice`s.
"""
# Standard, built-in choices.
choices = [
PromptChoice(u's', u'Skip',
lambda s, t: importer.action.SKIP),
PromptChoice(u'u', u'Use as-is',
lambda s, t: importer.action.ASIS)
]
if task.is_album:
choices += [
PromptChoice(u't', u'as Tracks',
lambda s, t: importer.action.TRACKS),
PromptChoice(u'g', u'Group albums',
lambda s, t: importer.action.ALBUMS),
]
choices += [
PromptChoice(u'e', u'Enter search', manual_search),
PromptChoice(u'i', u'enter Id', manual_id),
PromptChoice(u'b', u'aBort', abort_action),
]
# Send the before_choose_candidate event and flatten list.
extra_choices = list(chain(*plugins.send('before_choose_candidate',
session=self, task=task)))
# Take into account default options, for duplicate checking.
all_choices = [PromptChoice(u'a', u'Apply', None),
PromptChoice(u's', u'Skip', None),
PromptChoice(u'u', u'Use as-is', None),
PromptChoice(u't', u'as Tracks', None),
PromptChoice(u'g', u'Group albums', None),
PromptChoice(u'e', u'Enter search', None),
PromptChoice(u'i', u'enter Id', None),
PromptChoice(u'b', u'aBort', None)] +\
extra_choices
# Add a "dummy" choice for the other baked-in option, for
# duplicate checking.
all_choices = [
PromptChoice(u'a', u'Apply', None),
] + choices + extra_choices
# Check for conflicts.
short_letters = [c.short for c in all_choices]
if len(short_letters) != len(set(short_letters)):
# Duplicate short letter has been found.
@ -906,7 +866,8 @@ class TerminalImportSession(importer.ImportSession):
u"with '{1}' (short letter: '{2}')",
c.long, dup_choices[0].long, c.short)
extra_choices.remove(c)
return extra_choices
return choices + extra_choices
# The import command.

View file

@ -107,7 +107,11 @@ class AcousticPlugin(plugins.BeetsPlugin):
def __init__(self):
super(AcousticPlugin, self).__init__()
self.config.add({'auto': True})
self.config.add({
'auto': True,
'force': False,
})
if self.config['auto']:
self.register_listener('import_task_files',
self.import_task_files)
@ -115,10 +119,16 @@ class AcousticPlugin(plugins.BeetsPlugin):
def commands(self):
cmd = ui.Subcommand('acousticbrainz',
help=u"fetch metadata from AcousticBrainz")
cmd.parser.add_option(
u'-f', u'--force', dest='force_refetch',
action='store_true', default=False,
help=u're-download data when already present'
)
def func(lib, opts, args):
items = lib.items(ui.decargs(args))
self._fetch_info(items, ui.should_write())
self._fetch_info(items, ui.should_write(),
opts.force_refetch or self.config['force'])
cmd.func = func
return [cmd]
@ -126,7 +136,7 @@ class AcousticPlugin(plugins.BeetsPlugin):
def import_task_files(self, session, task):
"""Function is called upon beet import.
"""
self._fetch_info(task.imported_items(), False)
self._fetch_info(task.imported_items(), False, True)
def _get_data(self, mbid):
data = {}
@ -151,10 +161,21 @@ class AcousticPlugin(plugins.BeetsPlugin):
return data
def _fetch_info(self, items, write):
def _fetch_info(self, items, write, force):
"""Fetch additional information from AcousticBrainz for the `item`s.
"""
for item in items:
# If we're not forcing re-downloading for all tracks, check
# whether the data is already present. We use one
# representative field name to check for previously fetched
# data.
if not force:
mood_str = item.get('mood_acoustic', u'')
if mood_str:
self._log.info(u'data already present for: {}', item)
continue
# We can only fetch data for tracks with MBIDs.
if not item.mb_trackid:
continue
@ -191,7 +212,8 @@ class AcousticPlugin(plugins.BeetsPlugin):
joined with `' '`. This is hardcoded and not very flexible, but it gets
the job done.
Example:
For example:
>>> scheme = {
'key1': 'attribute',
'key group': {
@ -213,24 +235,24 @@ class AcousticPlugin(plugins.BeetsPlugin):
('attribute', 'value'),
('composite attribute', 'part 1 of composite attr part 2')]
"""
"""First, we traverse `scheme` and `data`, `yield`ing all the non
composites attributes straight away and populating the dictionary
`composites` with the composite attributes.
# First, we traverse `scheme` and `data`, `yield`ing all the non
# composites attributes straight away and populating the dictionary
# `composites` with the composite attributes.
When we are finished traversing `scheme`, `composites` should map
each composite attribute to an ordered list of the values belonging to
the attribute, for example:
`composites = {'initial_key': ['B', 'minor']}`.
"""
# When we are finished traversing `scheme`, `composites` should
# map each composite attribute to an ordered list of the values
# belonging to the attribute, for example:
# `composites = {'initial_key': ['B', 'minor']}`.
# The recursive traversal.
composites = defaultdict(list)
# The recursive traversal
for attr, val in self._data_to_scheme_child(data,
scheme,
composites):
yield attr, val
"""When composites has been populated, yield the composite attributes
by joining their parts.
"""
# When composites has been populated, yield the composite attributes
# by joining their parts.
for composite_attr, value_parts in composites.items():
yield composite_attr, ' '.join(value_parts)

View file

@ -122,6 +122,7 @@ class ScrubPlugin(BeetsPlugin):
except mediafile.UnreadableFileError as exc:
self._log.error(u'could not open file to scrub: {0}',
exc)
return
art = mf.art
# Remove all tags.

View file

@ -206,7 +206,7 @@ def item_file(item_id):
response = flask.send_file(
util.py3_path(item.path),
as_attachment=True,
attachment_filename=os.path.basename(item.path),
attachment_filename=os.path.basename(util.py3_path(item.path)),
)
response.headers['Content-Length'] = os.path.getsize(item.path)
return response

View file

@ -27,14 +27,12 @@ from beets.ui import Subcommand, decargs, input_yn
from beets.util import confit
__author__ = 'baobab@heresiarch.info'
__version__ = '0.10'
class ZeroPlugin(BeetsPlugin):
def __init__(self):
super(ZeroPlugin, self).__init__()
# Listeners.
self.register_listener('write', self.write_event)
self.register_listener('import_task_choice',
self.import_task_choice_event)
@ -49,6 +47,13 @@ class ZeroPlugin(BeetsPlugin):
self.fields_to_progs = {}
self.warned = False
"""Read the bulk of the config into `self.fields_to_progs`.
After construction, `fields_to_progs` contains all the fields that
should be zeroed as keys and maps each of those to a list of compiled
regexes (progs) as values.
A field is zeroed if its value matches one of the associated progs. If
progs is empty, then the associated field is always zeroed.
"""
if self.config['fields'] and self.config['keep_fields']:
self._log.warning(
u'cannot blacklist and whitelist at the same time'
@ -80,9 +85,8 @@ class ZeroPlugin(BeetsPlugin):
return [zero_command]
def _set_pattern(self, field):
"""Set a field in `self.patterns` to a string list corresponding to
the configuration, or `True` if the field has no specific
configuration.
"""Populate `self.fields_to_progs` for a given field.
Do some sanity checks then compile the regexes.
"""
if field not in MediaFile.fields():
self._log.error(u'invalid field: {0}', field)
@ -99,20 +103,22 @@ class ZeroPlugin(BeetsPlugin):
self.fields_to_progs[field] = []
def import_task_choice_event(self, session, task):
"""Listen for import_task_choice event."""
if task.choice_flag == action.ASIS and not self.warned:
self._log.warning(u'cannot zero in \"as-is\" mode')
self.warned = True
# TODO request write in as-is mode
def write_event(self, item, path, tags):
"""Set values in tags to `None` if the key and value are matched
by `self.patterns`.
"""
if self.config['auto']:
self.set_fields(item, tags)
def set_fields(self, item, tags):
"""Set values in `tags` to `None` if the field is in
`self.fields_to_progs` and any of the corresponding `progs` matches the
field value.
Also update the `item` itself if `update_database` is set in the
config.
"""
fields_set = False
if not self.fields_to_progs:
@ -122,7 +128,7 @@ class ZeroPlugin(BeetsPlugin):
for field, progs in self.fields_to_progs.items():
if field in tags:
value = tags[field]
match = _match_progs(tags[field], progs, self._log)
match = _match_progs(tags[field], progs)
else:
value = ''
match = not progs
@ -145,9 +151,9 @@ class ZeroPlugin(BeetsPlugin):
item.store(fields=tags)
def _match_progs(value, progs, log):
"""Check if field (as string) is matching any of the patterns in
the list.
def _match_progs(value, progs):
"""Check if `value` (as string) is matching any of the compiled regexes in
the `progs` list.
"""
if not progs:
return True

View file

@ -15,14 +15,22 @@ Features:
:bug:`2305` :bug:`2322`
* :doc:`/plugins/zero`: Added ``zero`` command to manually trigger the zero
plugin. Thanks to :user:`SJoshBrown`. :bug:`2274` :bug:`2329`
* :doc:`/plugins/acousticbrainz`: The plugin will avoid re-downloading data
for files that already have it by default. You can override this behavior
using a new ``force`` option. Thanks to :user:`SusannaMaria`. :bug:`2347`
:bug:`2349`
Fixes:
* :doc:`/plugins/bpd`: Fix a crash on non-ASCII MPD commands. :bug:`2332`
* :doc:`/plugins/scrub`: Avoid a crash when files cannot be read or written.
:bug:`2351`
* :doc:`/plugins/discogs`: Fix a crash when a release did not contain Format
information, and increased robustness when other fields are missing.
:bug:`2302`
For plugin developers: new importer prompt choices (see :ref:`append_prompt_choices`), you can now provide new candidates for the user to consider.
1.4.2 (December 16, 2016)
-------------------------

View file

@ -592,8 +592,6 @@ by the choices on the core importer prompt, and hence should not be used:
``a``, ``s``, ``u``, ``t``, ``g``, ``e``, ``i``, ``b``.
Additionally, the callback function can optionally specify the next action to
be performed by returning one of the values from ``importer.action``, which
will be passed to the main loop upon the callback has been processed. Note that
``action.MANUAL`` and ``action.MANUAL_ID`` will have no effect even if
returned by the callback, due to the current architecture of the import
process.
be performed by returning a ``importer.action`` value. It may also return a
``autotag.Proposal`` value to update the set of current proposals to be
considered.

View file

@ -8,7 +8,13 @@ The ``acousticbrainz`` plugin gets acoustic-analysis information from the
Enable the ``acousticbrainz`` plugin in your configuration (see :ref:`using-plugins`) and run it by typing::
$ beet acousticbrainz [QUERY]
$ beet acousticbrainz [-f] [QUERY]
By default, the command will only look for AcousticBrainz data when the tracks
doesn't already have it; the ``-f`` or ``--force`` switch makes it re-download
data even when it already exists. If you specify a query, only matching tracks
will be processed; otherwise, the command processes every track in your
library.
For all tracks with a MusicBrainz recording ID, the plugin currently sets
these fields:
@ -40,7 +46,7 @@ Automatic Tagging
-----------------
To automatically tag files using AcousticBrainz data during import, just
enable the ``acousticbrainz`` plugin (see :ref:`using-plugins`). When importing
enable the ``acousticbrainz`` plugin (see :ref:`using-plugins`). When importing
new files, beets will query the AcousticBrainz API using MBID and
set the appropriate metadata.
@ -52,3 +58,6 @@ configuration file. There is one option:
- **auto**: Enable AcousticBrainz during ``beet import``.
Default: ``yes``.
- **force**: Download AcousticBrainz data even for tracks that already have
it.
Default: ``no``.

View file

@ -670,21 +670,6 @@ class ImportTest(_common.TestCase):
None)
class InputTest(_common.TestCase):
def setUp(self):
super(InputTest, self).setUp()
self.io.install()
def test_manual_search_gets_unicode(self):
# The input here uses "native strings": bytes on Python 2, Unicode on
# Python 3.
self.io.addinput('foö')
self.io.addinput('bár')
artist, album = commands.manual_search(False)
self.assertEqual(artist, u'foö')
self.assertEqual(album, u'bár')
@_common.slow_test()
class ConfigTest(unittest.TestCase, TestHelper, _common.Assertions):
def setUp(self):