diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 165e88269..9d1f84450 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -167,11 +167,17 @@ TrackMatch = namedtuple('TrackMatch', ['distance', 'info']) # Aggregation of sources. def _album_for_id(album_id): - """Get an album corresponding to a MusicBrainz release ID.""" + """Get a list of albums corresponding to a release ID.""" + candidates = [] try: - return mb.album_for_id(album_id) + out = mb.album_for_id(album_id) except mb.MusicBrainzAPIError as exc: exc.log(log) + if out: + candidates.append(out) + out = plugins.album_for_id(album_id) + candidates.extend(x for x in out if x is not None) + return candidates def _track_for_id(track_id): """Get an item for a recording MBID.""" diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 85097a592..591ecbcc1 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -482,11 +482,7 @@ def tag_album(items, search_artist=None, search_album=None, candidates = {} # Try to find album indicated by MusicBrainz IDs. - if search_id: - log.debug('Searching for album ID: ' + search_id) - id_info = hooks._album_for_id(search_id) - else: - id_info = match_by_id(items) + id_info = match_by_id(items) if id_info: _add_candidate(items, candidates, id_info) rec = _recommendation(candidates.values()) @@ -499,13 +495,6 @@ def tag_album(items, search_artist=None, search_album=None, log.debug('ID match.') return cur_artist, cur_album, candidates.values(), rec - # If searching by ID, don't continue to metadata search. - if search_id is not None: - if candidates: - return cur_artist, cur_album, candidates.values(), rec - else: - return cur_artist, cur_album, [], recommendation.none - # Search terms. if not (search_artist and search_album): # No explicit search terms -- use current metadata. @@ -519,8 +508,12 @@ def tag_album(items, search_artist=None, search_album=None, log.debug(u'Album might be VA: %s' % str(va_likely)) # Get the results from the data sources. - search_cands = hooks._album_candidates(items, search_artist, search_album, - va_likely) + if search_id: + log.debug('Searching for album ID: ' + search_id) + search_cands = hooks._album_for_id(search_id) + else: + search_cands = hooks._album_candidates(items, search_artist, + search_album, va_likely) log.debug(u'Evaluating %i candidates.' % len(search_cands)) for info in search_cands: _add_candidate(items, candidates, info) diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 566340e83..6fe7854d3 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -16,6 +16,7 @@ """ import logging import musicbrainzngs +import re import traceback import beets.autotag.hooks @@ -326,13 +327,19 @@ def album_for_id(albumid): object or None if the album is not found. May raise a MusicBrainzAPIError. """ + # Find the first thing that looks like a UUID/MBID. + match = re.search('[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}', albumid) + if not match: + log.error('Invalid MBID.') + return None try: - res = musicbrainzngs.get_release_by_id(albumid, RELEASE_INCLUDES) + res = musicbrainzngs.get_release_by_id(match.group(), + RELEASE_INCLUDES) except musicbrainzngs.ResponseError: log.debug('Album ID match failed.') return None except musicbrainzngs.MusicBrainzError as exc: - raise MusicBrainzAPIError(exc, 'get release by ID', albumid, + raise MusicBrainzAPIError(exc, 'get release by ID', match.group(), traceback.format_exc()) return album_info(res['release']) diff --git a/beets/plugins.py b/beets/plugins.py index 167ce2eb4..fa7c74900 100755 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -101,6 +101,12 @@ class BeetsPlugin(object): """ return {} + def album_for_id(self, album_id): + """Should return an AlbumInfo object or None if no matching release + was found. + """ + return None + listeners = None @@ -266,6 +272,17 @@ def item_candidates(item, artist, title): out.extend(plugin.item_candidates(item, artist, title)) return out +def album_for_id(album_id): + out = [] + for plugin in find_plugins(): + try: + out.append(plugin.album_for_id(album_id)) + except Exception: + log.warn('** error running album_for_id in plugin %s' + % plugin.name) + log.warn(traceback.format_exc()) + return out + def configure(config): """Sends the configuration object to each plugin.""" for plugin in find_plugins(): diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 4ce706283..680d99843 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -589,20 +589,11 @@ def manual_search(singleton): return artist.strip(), name.strip() def manual_id(singleton): - """Input a MusicBrainz ID, either for an album ("release") or a - track ("recording"). If no valid ID is entered, returns None. + """Input an ID, either for an album ("release") or a track ("recording"). """ - prompt = 'Enter MusicBrainz %s ID:' % \ + prompt = 'Enter %s ID:' % \ ('recording' if singleton else 'release') - entry = input_(prompt).strip() - - # Find the first thing that looks like a UUID/MBID. - match = re.search('[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}', entry) - if match: - return match.group() - else: - log.error('Invalid MBID.') - return None + return input_(prompt).strip() class TerminalImportSession(importer.ImportSession): """An import session that runs in a terminal. diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index b7965e8e1..9c7a40058 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -67,6 +67,30 @@ class DiscogsPlugin(BeetsPlugin): log.debug('Discogs API Error: %s (query: %s' % (e, query)) return [] + def album_for_id(self, album_id): + """Fetches an album by its Discogs ID and returns an AlbumInfo object + or None if the album is not found. + """ + log.debug('Searching discogs for release %s' % str(album_id)) + # Discogs-IDs are simple integers. We only look for those at the end + # of an input string as to avoid confusion with other metadata plugins. + # An optional bracket can follow the integer, as this is how discogs + # displays the release ID on its webpage. + match = re.search(r'(^|\[*r|discogs\.com/.+/release/)(\d+)($|\])', + album_id) + if not match: + return None + result = Release(match.group(2)) + # Try to obtain title to verify that we indeed have a valid Release + try: + getattr(result, 'title') + except DiscogsAPIError as e: + if e.message != '404 Not Found': + log.debug('Discogs API Error: %s (query: %s)' + % (e, result._uri)) + return None + return self.get_album_info(result) + def get_albums(self, query): """Returns a list of AlbumInfo objects for a discogs search query. """ diff --git a/test/test_mb.py b/test/test_mb.py index e23201706..278e5cd1f 100644 --- a/test/test_mb.py +++ b/test/test_mb.py @@ -277,6 +277,22 @@ class MBAlbumInfoTest(unittest.TestCase): self.assertEqual(track.artist_sort, 'TRACK ARTIST SORT NAME') self.assertEqual(track.artist_credit, 'TRACK ARTIST CREDIT') + def test_album_for_id_correct(self): + id_string = "28e32c71-1450-463e-92bf-e0a46446fc11" + out = mb.album_for_id(id_string) + self.assertEqual(out.album_id, id_string) + + def test_album_for_id_non_id_returns_none(self): + id_string = "blah blah" + out = mb.album_for_id(id_string) + self.assertEqual(out, None) + + def test_album_for_id_url_finds_id(self): + id_string = "28e32c71-1450-463e-92bf-e0a46446fc11" + id_url = "http://musicbrainz.org/entity/%s" % id_string + out = mb.album_for_id(id_url) + self.assertEqual(out.album_id, id_string) + class ArtistFlatteningTest(unittest.TestCase): def _credit_dict(self, suffix=''): return { diff --git a/test/test_ui.py b/test/test_ui.py index c9d57a466..b679021f7 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -585,28 +585,6 @@ class ShowdiffTest(_common.TestCase): self.assertEqual(complete_diff, partial_diff) -AN_ID = "28e32c71-1450-463e-92bf-e0a46446fc11" -class ManualIDTest(_common.TestCase): - def setUp(self): - super(ManualIDTest, self).setUp() - _common.log.setLevel(logging.CRITICAL) - self.io.install() - - def test_id_accepted(self): - self.io.addinput(AN_ID) - out = commands.manual_id(False) - self.assertEqual(out, AN_ID) - - def test_non_id_returns_none(self): - self.io.addinput("blah blah") - out = commands.manual_id(False) - self.assertEqual(out, None) - - def test_url_finds_id(self): - self.io.addinput("http://musicbrainz.org/entity/%s?something" % AN_ID) - out = commands.manual_id(False) - self.assertEqual(out, AN_ID) - class ShowChangeTest(_common.TestCase): def setUp(self): super(ShowChangeTest, self).setUp()