diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 09aed89ce..3e79a4498 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -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. diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 493fd20c9..71d80e821 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -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) diff --git a/beets/importer.py b/beets/importer.py index 2c1d07c5c..6a10f4c97 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -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 diff --git a/beets/library.py b/beets/library.py old mode 100755 new mode 100644 diff --git a/beets/mediafile.py b/beets/mediafile.py index ad66b74c5..0f595a36a 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -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()) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 8ead3bfad..168f0d515 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -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. diff --git a/beetsplug/acousticbrainz.py b/beetsplug/acousticbrainz.py index 138fd8809..4291d9117 100644 --- a/beetsplug/acousticbrainz.py +++ b/beetsplug/acousticbrainz.py @@ -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) diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index 4dcefe572..8ecf8ad5d 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -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. diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 810de8718..e7b9ec81f 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -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 diff --git a/beetsplug/zero.py b/beetsplug/zero.py index 6a3013069..022c2c721 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -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 diff --git a/docs/changelog.rst b/docs/changelog.rst index bf6c5cdd7..c1aa003db 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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) ------------------------- diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index 80409d5f5..fb063aee0 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -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. diff --git a/docs/plugins/acousticbrainz.rst b/docs/plugins/acousticbrainz.rst index b66bf17de..bf2102790 100644 --- a/docs/plugins/acousticbrainz.rst +++ b/docs/plugins/acousticbrainz.rst @@ -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``. diff --git a/test/test_ui.py b/test/test_ui.py index 39504aa4e..c519e66fb 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -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):