mirror of
https://github.com/beetbox/beets.git
synced 2026-01-09 09:22:55 +01:00
Merge remote-tracking branch 'upstream/master' into discogs-relax-assumptions
Conflicts: docs/changelog.rst
This commit is contained in:
commit
4364757fcc
14 changed files with 229 additions and 234 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
0
beets/library.py
Executable file → Normal 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())
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
-------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue