From a074db78e185bca325288c23a417c6fd88fa1c18 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 22 May 2011 21:18:01 -0700 Subject: [PATCH] manual specification of MBIDs --- NEWS | 3 +++ beets/autotag/__init__.py | 43 +++++++++++++++++++++++++++++++-------- beets/autotag/mb.py | 6 ++++-- beets/importer.py | 5 +++-- beets/ui/commands.py | 43 ++++++++++++++++++++++++++++++++------- 5 files changed, 80 insertions(+), 20 deletions(-) diff --git a/NEWS b/NEWS index 31a1ce020..4388f77de 100644 --- a/NEWS +++ b/NEWS @@ -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 diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 9b8915e2a..d0fe9f718 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -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): diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 2bf19b3ce..4ccb2c7a5 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -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) diff --git a/beets/importer.py b/beets/importer.py index 9fceb792f..3c2bab0a2 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -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 diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 39a88e3f5..8136f56b0 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -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)