diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 7eecb1db0..dbd49d75c 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -73,7 +73,7 @@ log = logging.getLogger('beets') RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups', 'labels', 'artist-credits', 'aliases', 'recording-level-rels', 'work-rels', - 'work-level-rels', 'artist-rels', 'isrcs', 'url-rels'] + 'work-level-rels', 'artist-rels', 'isrcs', 'url-rels', 'release-rels'] BROWSE_INCLUDES = ['artist-credits', 'work-rels', 'artist-rels', 'recording-rels', 'release-rels'] if "work-level-rels" in musicbrainzngs.VALID_BROWSE_INCLUDES['recording']: @@ -670,6 +670,61 @@ def _parse_id(s: str) -> Optional[str]: return None +# this was defined within the function below but pep8 made me move it here +_trans_key = 'transl-tracklisting' +_is_trans = lambda r: r['type'] == _trans_key and r['direction'] == "backward" + + +def _find_actual_release_from_pseudo_release(pseudo_rel: Dict)\ + -> Optional[Dict]: + relations = pseudo_rel['release']["release-relation-list"] + + # currently we only support trans(liter)ation's + actual_id = next(filter(_is_trans, relations), {'target': None})['target'] + + if actual_id is None: + return None + + return musicbrainzngs.get_release_by_id(actual_id, + RELEASE_INCLUDES) + + +def _merge_pseudo_and_actual_album( + pseudo: beets.autotag.hooks.AlbumInfo, + actual: beets.autotag.hooks.AlbumInfo + ) -> Optional[beets.autotag.hooks.AlbumInfo]: + """ + Merges a pseudo release with its actual release. + + This implementation is naive, it doesn't overwrite fields, + like status or ids. + + According to the ticket PICARD-145, the main release id should be used. + But the ticket has been in limbo since over a decade now. + It also suggests the introduction of the tag `musicbrainz_pseudoreleaseid`, + but as of this field can't be found in any offical Picard docs, + hence why we did not implement that for now. + """ + merged = pseudo.copy() + merged.update({ + "media": actual.media, + "mediums": actual.mediums, + "country": actual.country, + "catalognum": actual.catalognum, + "year": actual.year, + "month": actual.month, + "day": actual.day, + "original_year": actual.original_year, + "original_month": actual.original_month, + "original_day": actual.original_day, + "label": actual.label, + "asin": actual.asin, + "style": actual.style, + "genre": actual.genre, + }) + return merged + + def album_for_id(releaseid: str) -> Optional[beets.autotag.hooks.AlbumInfo]: """Fetches an album by its MusicBrainz ID and returns an AlbumInfo object or None if the album is not found. May raise a @@ -683,13 +738,27 @@ def album_for_id(releaseid: str) -> Optional[beets.autotag.hooks.AlbumInfo]: try: res = musicbrainzngs.get_release_by_id(albumid, RELEASE_INCLUDES) + + # resolve linked release relations + actual_res = None + + if res['release']['status'] == 'Pseudo-Release': + actual_res = _find_actual_release_from_pseudo_release(res) + except musicbrainzngs.ResponseError: log.debug('Album ID match failed.') return None except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError(exc, 'get release by ID', albumid, traceback.format_exc()) - return album_info(res['release']) + + release = album_info(res['release']) + + if actual_res is not None: + actual_release = album_info(actual_res['release']) + return _merge_pseudo_and_actual_album(release, actual_release) + else: + return release def track_for_id(releaseid: str) -> Optional[beets.autotag.hooks.TrackInfo]: diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 68cc7b635..31ac96ed8 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -211,6 +211,9 @@ def disambig_string(info): disambig.append(info.catalognum) if info.albumdisambig: disambig.append(info.albumdisambig) + # pseudo releases can't be differentiated from real release otherwise + if info.albumstatus == 'Pseudo-Release': + disambig.append(info.albumstatus) if disambig: return ', '.join(disambig) diff --git a/test/test_mb.py b/test/test_mb.py index f005c741a..55faa3a29 100644 --- a/test/test_mb.py +++ b/test/test_mb.py @@ -604,6 +604,7 @@ class MBLibraryTest(unittest.TestCase): 'release': { 'title': 'hi', 'id': mbid, + 'status': 'status', 'medium-list': [{ 'track-list': [{ 'id': 'baz', @@ -648,6 +649,164 @@ class MBLibraryTest(unittest.TestCase): self.assertFalse(p.called) self.assertEqual(ail, []) + def test_follow_pseudo_releases(self): + side_effect = [ + { + 'release': { + 'title': 'pseudo', + 'id': 'd2a6f856-b553-40a0-ac54-a321e8e2da02', + 'status': 'Pseudo-Release', + 'medium-list': [{ + 'track-list': [{ + 'id': 'baz', + 'recording': { + 'title': 'translated title', + 'id': 'bar', + 'length': 42, + }, + 'position': 9, + 'number': 'A1', + }], + 'position': 5, + }], + 'artist-credit': [{ + 'artist': { + 'name': 'some-artist', + 'id': 'some-id', + }, + }], + 'release-group': { + 'id': 'another-id', + }, + 'release-relation-list': [ + { + 'type': 'transl-tracklisting', + 'target': 'd2a6f856-b553-40a0-ac54-a321e8e2da01', + 'direction': 'backward' + } + ] + } + }, + { + 'release': { + 'title': 'actual', + 'id': 'd2a6f856-b553-40a0-ac54-a321e8e2da01', + 'status': 'Offical', + 'medium-list': [{ + 'track-list': [{ + 'id': 'baz', + 'recording': { + 'title': 'original title', + 'id': 'bar', + 'length': 42, + }, + 'position': 9, + 'number': 'A1', + }], + 'position': 5, + }], + 'artist-credit': [{ + 'artist': { + 'name': 'some-artist', + 'id': 'some-id', + }, + }], + 'release-group': { + 'id': 'another-id', + }, + 'country': 'COUNTRY', + } + } + ] + + with mock.patch('musicbrainzngs.get_release_by_id') as gp: + gp.side_effect = side_effect + album = mb.album_for_id('d2a6f856-b553-40a0-ac54-a321e8e2da02') + self.assertEqual(album.country, 'COUNTRY') + + def test_pseudo_releases_without_links(self): + side_effect = [{ + 'release': { + 'title': 'pseudo', + 'id': 'd2a6f856-b553-40a0-ac54-a321e8e2da02', + 'status': 'Pseudo-Release', + 'medium-list': [{ + 'track-list': [{ + 'id': 'baz', + 'recording': { + 'title': 'translated title', + 'id': 'bar', + 'length': 42, + }, + 'position': 9, + 'number': 'A1', + }], + 'position': 5, + }], + 'artist-credit': [{ + 'artist': { + 'name': 'some-artist', + 'id': 'some-id', + }, + }], + 'release-group': { + 'id': 'another-id', + }, + 'release-relation-list': [] + } + }, + ] + + with mock.patch('musicbrainzngs.get_release_by_id') as gp: + gp.side_effect = side_effect + album = mb.album_for_id('d2a6f856-b553-40a0-ac54-a321e8e2da02') + self.assertEqual(album.country, None) + + def test_pseudo_releases_with_unsupported_links(self): + side_effect = [ + { + 'release': { + 'title': 'pseudo', + 'id': 'd2a6f856-b553-40a0-ac54-a321e8e2da02', + 'status': 'Pseudo-Release', + 'medium-list': [{ + 'track-list': [{ + 'id': 'baz', + 'recording': { + 'title': 'translated title', + 'id': 'bar', + 'length': 42, + }, + 'position': 9, + 'number': 'A1', + }], + 'position': 5, + }], + 'artist-credit': [{ + 'artist': { + 'name': 'some-artist', + 'id': 'some-id', + }, + }], + 'release-group': { + 'id': 'another-id', + }, + 'release-relation-list': [ + { + 'type': 'remaster', + 'target': 'd2a6f856-b553-40a0-ac54-a321e8e2da01', + 'direction': 'backward' + } + ] + } + }, + ] + + with mock.patch('musicbrainzngs.get_release_by_id') as gp: + gp.side_effect = side_effect + album = mb.album_for_id('d2a6f856-b553-40a0-ac54-a321e8e2da02') + self.assertEqual(album.country, None) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__)