manual specification of MBIDs

This commit is contained in:
Adrian Sampson 2011-05-22 21:18:01 -07:00
parent da6ee13159
commit a074db78e1
5 changed files with 80 additions and 20 deletions

3
NEWS
View file

@ -5,6 +5,9 @@
* The "list" command now accepts a "-p" switch that causes it to show
paths instead of titles. This makes the output of "beet ls -p"
suitable for piping into another command such as xargs.
* The importer now provides the option to specify a MusicBrainz ID
manually if the built-in searching isn't working for a particular
album or track.
* The import logger has been improved for "always-on" use. First,
it is now possible to specify a log file in .beetsconfig. Also,
logs are now appended rather than overwritten and contain

View file

@ -386,13 +386,16 @@ def match_by_id(items):
# Is there a consensus on the MB album ID?
albumids = [item.mb_albumid for item in items if item.mb_albumid]
if not albumids:
log.debug('No album IDs found.')
return None
# If all album IDs are equal, look up the album.
if bool(reduce(lambda x,y: x if x==y else (), albumids)):
albumid = albumids[0]
log.debug('Searching for discovered album ID: ' + albumid)
return mb.album_for_id(albumid)
else:
log.debug('No album ID consensus.')
return None
#fixme In the future, at the expense of performance, we could use
@ -456,7 +459,8 @@ def validate_candidate(items, tuple_dict, info):
tuple_dict[info['album_id']] = dist, ordered, info
def tag_album(items, timid=False, search_artist=None, search_album=None):
def tag_album(items, timid=False, search_artist=None, search_album=None,
search_id=None):
"""Bundles together the functionality used to infer tags for a
set of items comprised by an album. Returns everything relevant:
- The current artist.
@ -469,8 +473,8 @@ def tag_album(items, timid=False, search_artist=None, search_album=None):
or RECOMMEND_NONE; indicating that the first candidate is
very likely, it is somewhat likely, or no conclusion could
be reached.
If search_artist and search_album are provided, then they are used
as search terms in place of the current metadata.
If search_artist and search_album or search_id are provided, then
they are used as search terms in place of the current metadata.
May raise an AutotagError if existing metadata is insufficient.
"""
# Get current metadata.
@ -481,17 +485,29 @@ def tag_album(items, timid=False, search_artist=None, search_album=None):
out_tuples = {}
# Try to find album indicated by MusicBrainz IDs.
id_info = match_by_id(items)
if search_id:
log.debug('Searching for album ID: ' + search_id)
id_info = mb.album_for_id(search_id)
else:
id_info = match_by_id(items)
if id_info:
validate_candidate(items, out_tuples, id_info)
rec = recommendation(out_tuples.values())
log.debug('Album ID match recommendation is ' + str(rec))
if out_tuples and not timid:
# If we have a very good MBID match, return immediately.
# Otherwise, this match will compete against metadata-based
# matches.
rec = recommendation(out_tuples.values())
if rec == RECOMMEND_STRONG:
log.debug('ID match.')
return cur_artist, cur_album, out_tuples.values(), rec
# If searching by ID, don't continue to metadata search.
if search_id is not None:
if out_tuples:
return cur_artist, cur_album, out_tuples.values(), rec
else:
return cur_artist, cur_album, [], RECOMMEND_NONE
# Search terms.
if not (search_artist and search_album):
@ -530,19 +546,21 @@ def tag_album(items, timid=False, search_artist=None, search_album=None):
rec = recommendation(out_tuples)
return cur_artist, cur_album, out_tuples, rec
def tag_item(item, timid=False, search_artist=None, search_title=None):
def tag_item(item, timid=False, search_artist=None, search_title=None,
search_id=None):
"""Attempts to find metadata for a single track. Returns a
`(candidates, recommendation)` pair where `candidates` is a list
of `(distance, track_info)` pairs. `search_artist` and
`search_title` may be used to override the current metadata for
the purposes of the MusicBrainz category.
the purposes of the MusicBrainz title; likewise `search_id`.
"""
candidates = []
# First, try matching by MusicBrainz ID.
trackid = item.mb_trackid
trackid = search_id or item.mb_trackid
if trackid:
track_info = mb.track_for_id(item.mb_trackid)
log.debug('Searching for track ID: ' + trackid)
track_info = mb.track_for_id(trackid)
if track_info:
dist = track_distance(item, track_info, incl_artist=True)
candidates.append((dist, track_info))
@ -551,6 +569,13 @@ def tag_item(item, timid=False, search_artist=None, search_title=None):
if rec == RECOMMEND_STRONG and not timid:
log.debug('Track ID match.')
return candidates, rec
# If we're searching by ID, don't proceed.
if search_id is not None:
if candidates:
return candidates, rec
else:
return [], RECOMMEND_NONE
# Search terms.
if not (search_artist and search_title):

View file

@ -281,7 +281,8 @@ def album_for_id(albumid):
inc = mbws.ReleaseIncludes(artist=True, tracks=True)
try:
album = _query_wrap(query.getReleaseById, albumid, inc)
except (mbws.ResourceNotFoundError, mbws.RequestError):
except (mbws.ResourceNotFoundError, mbws.RequestError), exc:
log.debug('Album ID match failed: ' + str(exc))
return None
return release_dict(album, album.tracks)
@ -293,6 +294,7 @@ def track_for_id(trackid):
inc = mbws.TrackIncludes(artist=True)
try:
track = _query_wrap(query.getTrackById, trackid, inc)
except (mbws.ResourceNotFoundError, mbws.RequestError):
except (mbws.ResourceNotFoundError, mbws.RequestError), exc:
log.debug('Track ID match failed: ' + str(exc))
return None
return track_dict(track)

View file

@ -29,7 +29,7 @@ from beets.util import syspath, normpath
from beets.util.enumeration import enum
action = enum(
'SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'APPLY',
'SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'APPLY', 'MANUAL_ID',
name='action'
)
@ -249,7 +249,8 @@ class ImportTask(object):
automatically).
"""
assert not self.sentinel
assert choice != action.MANUAL # Not part of the task structure.
# 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):
self.choice_flag = choice

View file

@ -190,9 +190,11 @@ def choose_candidate(candidates, singleton, rec, color, timid,
if not candidates:
print_("No match found.")
if singleton:
opts = ('Use as-is', 'Skip', 'Enter search', 'aBort')
opts = ('Use as-is', 'Skip', 'Enter search', 'enter Id',
'aBort')
else:
opts = ('Use as-is', 'as Tracks', 'Skip', 'Enter search', 'aBort')
opts = ('Use as-is', 'as Tracks', 'Skip', 'Enter search',
'enter Id', 'aBort')
sel = ui.input_options(opts, color=color)
if sel == 'u':
return importer.action.ASIS
@ -205,6 +207,8 @@ def choose_candidate(candidates, singleton, rec, color, timid,
return importer.action.SKIP
elif sel == 'b':
raise importer.ImportAbort()
elif sel == 'i':
return importer.action.MANUAL_ID
else:
assert False
@ -238,10 +242,11 @@ def choose_candidate(candidates, singleton, rec, color, timid,
# Ask the user for a choice.
if singleton:
opts = ('Skip', 'Use as-is', 'Enter search', 'aBort')
opts = ('Skip', 'Use as-is', 'Enter search', 'enter Id',
'aBort')
else:
opts = ('Skip', 'Use as-is', 'as Tracks', 'Enter search',
'aBort')
'enter Id', 'aBort')
sel = ui.input_options(opts, numrange=(1, len(candidates)),
color=color)
if sel == 's':
@ -255,6 +260,8 @@ def choose_candidate(candidates, singleton, rec, color, timid,
return importer.action.TRACKS
elif sel == 'b':
raise importer.ImportAbort()
elif sel == 'i':
return importer.action.MANUAL_ID
else: # Numerical selection.
if singleton:
dist, info = candidates[sel-1]
@ -278,10 +285,10 @@ def choose_candidate(candidates, singleton, rec, color, timid,
# Ask for confirmation.
if singleton:
opts = ('Apply', 'More candidates', 'Skip', 'Use as-is',
'Enter search', 'aBort')
'Enter search', 'enter Id', 'aBort')
else:
opts = ('Apply', 'More candidates', 'Skip', 'Use as-is',
'as Tracks', 'Enter search', 'aBort')
'as Tracks', 'Enter search', 'enter Id', 'aBort')
sel = ui.input_options(opts, color=color)
if sel == 'a':
if singleton:
@ -301,6 +308,8 @@ def choose_candidate(candidates, singleton, rec, color, timid,
return importer.action.MANUAL
elif sel == 'b':
raise importer.ImportAbort()
elif sel == 'i':
return importer.action.MANUAL_ID
def manual_search(singleton):
"""Input either an artist and album (for full albums) or artist and
@ -311,6 +320,12 @@ def manual_search(singleton):
.decode(sys.stdin.encoding)
return artist.strip(), name.strip()
def manual_id(singleton):
"""Input a MusicBrainz ID, either for an album or a track.
"""
prompt = 'Enter MusicBrainz %s ID: ' % ('track' if singleton else 'album')
return raw_input(prompt).decode(sys.stdin.encoding).strip()
def choose_match(task, config):
"""Given an initial autotagging of items, go through an interactive
dance with the user to ask for a choice of metadata. Returns an
@ -352,6 +367,15 @@ def choose_match(task, config):
search_album)
except autotag.AutotagError:
candidates, rec = None, None
elif choice is importer.action.MANUAL_ID:
# Try a manually-entered ID.
search_id = manual_id(False)
try:
_, _, candidates, rec = \
autotag.tag_album(task.items, config.timid,
search_id=search_id)
except autotag.AutotagError:
candidates, rec = None, None
else:
# We have a candidate! Finish tagging. Here, choice is
# an (info, items) pair as desired.
@ -386,9 +410,14 @@ def choose_item(task, config):
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(False)
search_artist, search_title = manual_search(True)
candidates, rec = autotag.tag_item(task.item, config.timid,
search_artist, search_title)
elif choice == importer.action.MANUAL_ID:
# Ask for a track ID.
search_id = manual_id(True)
candidates, rec = autotag.tag_item(task.item, config.timid,
search_id=search_id)
else:
# Chose a candidate.
assert not isinstance(choice, importer.action)