Repository: beetbox/beets

Issue:
Follow MusicBrainz pseudo-releases
The MB database uses "pseudo-releases" to provide translations of track names when there was, in fact, never a translated release. Apparently, [the correct way to handle these](http://tickets.musicbrainz.org/browse/PICARD-145) is to use the original release's IDs but the pseudo-release's human-readable metadata. The autotagger would need to follow the [translation/transliteration relationship](http://musicbrainz.org/relationship/fc399d47-23a7-4c28-bfcf-0607a562b644) to the original release.


Discussion:
sampsyo: Awesome! It looks like there is one minor conflict with a recent change to `RELEASE_INCLUDES`—any chance you could take a shot at resolving that? Then perhaps we'll have a clean merge.
TypicalFence: @sampsyo I'll try to get around rebasing soon

Patch:
diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py
index 7eecb1db0..c7dba1bc8 100644
--- a/beets/autotag/mb.py
+++ b/beets/autotag/mb.py
@@ -73,7 +73,8 @@ 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']:
@@ -133,7 +134,7 @@ def _preferred_alias(aliases: List):
         matches = []
         for a in aliases:
             if a['locale'] == locale and 'primary' in a and \
-               a.get('type', '').lower() not in ignored_alias_types:
+                    a.get('type', '').lower() not in ignored_alias_types:
                 matches.append(a)
 
         # Skip to the next locale if we have no matches
@@ -583,10 +584,10 @@ def album_info(release: Dict) -> beets.autotag.hooks.AlbumInfo:
 
 
 def match_album(
-    artist: str,
-    album: str,
-    tracks: Optional[int] = None,
-    extra_tags: Optional[Dict[str, Any]] = None,
+        artist: str,
+        album: str,
+        tracks: Optional[int] = None,
+        extra_tags: Optional[Dict[str, Any]] = None,
 ) -> Iterator[beets.autotag.hooks.AlbumInfo]:
     """Searches for a single album ("release" in MusicBrainz parlance)
     and returns an iterator over AlbumInfo objects. May raise a
@@ -670,6 +671,64 @@ def _parse_id(s: str) -> Optional[str]:
     return None
 
 
+def _is_translation(r):
+    _trans_key = 'transl-tracklisting'
+    return 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
+    translations = [r for r in relations if _is_translation(r)]
+
+    if not translations:
+        return None
+
+    actual_id = translations[0]['target']
+
+    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()
+    from_actual = {k: actual[k] for k in [
+        "media",
+        "mediums",
+        "country",
+        "catalognum",
+        "year",
+        "month",
+        "day",
+        "original_year",
+        "original_month",
+        "original_day",
+        "label",
+        "asin",
+        "style",
+        "genre"
+    ]}
+    merged.update(from_actual)
+    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 +742,29 @@ 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 is potentially a pseudo release
+    release = album_info(res['release'])
+
+    # should be None unless we're dealing with a pseudo 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..808052f86 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)
+        # Let the user differentiate between pseudo and actual releases.
+        if info.albumstatus == 'Pseudo-Release':
+            disambig.append(info.albumstatus)
 
     if disambig:
         return ', '.join(disambig)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 6d1dc02bb..26c917887 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -11,6 +11,8 @@ for Python 3.6).
 
 New features:
 
+* resolve transl-tracklisting relations for pseudo releases and merge data with the actual release
+  :bug:`654`
 * Fetchart: Use the right field (`spotify_album_id`) to obtain the Spotify album id
   :bug:`4803`
 * Prevent reimporting album if it is permanently removed from Spotify


Test patch:
diff --git a/test/test_importer.py b/test/test_importer.py
index 967f5fc95..55874fc6e 100644
--- a/test/test_importer.py
+++ b/test/test_importer.py
@@ -1931,7 +1931,8 @@ def mocked_get_release_by_id(id_, includes=[], release_status=[],
             }],
             'release-group': {
                 'id': 'another-id',
-            }
+            },
+            'status': 'Official',
         }
     }
 
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__)