Repository: beetbox/beets

Issue:
mbsync: Tolerate MusicBrainz recording ID changes
mbsync uses the musicbrainz recording id to match local track information against a certain track in a musicbrainz release. However, there is no fallback matching defined, so once the recording id changes in musicbrainz the local track can no longer be synced unless the recording id is changed manually. But this behavior is also not very visible (I stumbled upon it by accident), so most users would not even be aware that the recording id is outdated.

mbsync: Tolerate MusicBrainz recording ID changes
mbsync uses the musicbrainz recording id to match local track information against a certain track in a musicbrainz release. However, there is no fallback matching defined, so once the recording id changes in musicbrainz the local track can no longer be synced unless the recording id is changed manually. But this behavior is also not very visible (I stumbled upon it by accident), so most users would not even be aware that the recording id is outdated.



Discussion:
On recording merges (which I would estimate are somewhat more common than other kinds of merges), looking up a merged MBID should return the "new" canonical MBID in the WS response, which would mean that `beet` should just update the metadata. The real trouble would be if a recording has somehow gotten deleted (this [shouldn't happen](https://musicbrainz.org/doc/Merge_Rather_Than_Delete), but it still does on occasion) - I don't know what would be the best way to handle this, apart from re-importing. Perhaps a notice to the user that the recording should be re-imported, or try to resolve it using track or release MBID?

It's not just merge and delete which could cause this though. If a track points to a wrong recording, and this is fixed in musicbrainz by pointing to the correct recording, both recording are still present. Fetching information for the old recording id in this case does not help us at all in getting the new one, similar to the delete case.
It's a matter of what would be considered an adequate fallback value for the recording id. Maybe once we have release track ids (#406) those could be used as well. Otherwise one could argue that track number + disc number might be sufficient the re-identify the track? This would however break when both position and recording are both changed.

Track IDs as a fallback seem fine, I think. Track/disc position seem dicier—it would be a good goal to keep "guesswork" out of `mbsync`. If things change a lot, a re-import is probably the right solution.

On recording merges (which I would estimate are somewhat more common than other kinds of merges), looking up a merged MBID should return the "new" canonical MBID in the WS response, which would mean that `beet` should just update the metadata. The real trouble would be if a recording has somehow gotten deleted (this [shouldn't happen](https://musicbrainz.org/doc/Merge_Rather_Than_Delete), but it still does on occasion) - I don't know what would be the best way to handle this, apart from re-importing. Perhaps a notice to the user that the recording should be re-imported, or try to resolve it using track or release MBID?

It's not just merge and delete which could cause this though. If a track points to a wrong recording, and this is fixed in musicbrainz by pointing to the correct recording, both recording are still present. Fetching information for the old recording id in this case does not help us at all in getting the new one, similar to the delete case.
It's a matter of what would be considered an adequate fallback value for the recording id. Maybe once we have release track ids (#406) those could be used as well. Otherwise one could argue that track number + disc number might be sufficient the re-identify the track? This would however break when both position and recording are both changed.

Track IDs as a fallback seem fine, I think. Track/disc position seem dicier—it would be a good goal to keep "guesswork" out of `mbsync`. If things change a lot, a re-import is probably the right solution.


Resolving patch:
diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py
--- a/beetsplug/mbsync.py
+++ b/beetsplug/mbsync.py
@@ -117,28 +117,35 @@ def albums(self, lib, query, move, pretend, write):
                                album_formatted)
                 continue
 
-            # Map recording MBIDs to their information. Recordings can appear
-            # multiple times on a release, so each MBID maps to a list of
-            # TrackInfo objects.
+            # Map release track and recording MBIDs to their information.
+            # Recordings can appear multiple times on a release, so each MBID
+            # maps to a list of TrackInfo objects.
+            releasetrack_index = dict()
             track_index = defaultdict(list)
             for track_info in album_info.tracks:
+                releasetrack_index[track_info.release_track_id] = track_info
                 track_index[track_info.track_id].append(track_info)
 
-            # Construct a track mapping according to MBIDs. This should work
-            # for albums that have missing or extra tracks. If there are
-            # multiple copies of a recording, they are disambiguated using
-            # their disc and track number.
+            # Construct a track mapping according to MBIDs (release track MBIDs
+            # first, if available, and recording MBIDs otherwise). This should
+            # work for albums that have missing or extra tracks.
             mapping = {}
             for item in items:
-                candidates = track_index[item.mb_trackid]
-                if len(candidates) == 1:
-                    mapping[item] = candidates[0]
+                if item.mb_releasetrackid and \
+                        item.mb_releasetrackid in releasetrack_index:
+                    mapping[item] = releasetrack_index[item.mb_releasetrackid]
                 else:
-                    for c in candidates:
-                        if (c.medium_index == item.track and
-                                c.medium == item.disc):
-                            mapping[item] = c
-                            break
+                    candidates = track_index[item.mb_trackid]
+                    if len(candidates) == 1:
+                        mapping[item] = candidates[0]
+                    else:
+                        # If there are multiple copies of a recording, they are
+                        # disambiguated using their disc and track number.
+                        for c in candidates:
+                            if (c.medium_index == item.track and
+                                    c.medium == item.disc):
+                                mapping[item] = c
+                                break
 
             # Apply.
             self._log.debug(u'applying changes to {}', album_formatted)


Test patch:
diff --git a/test/helper.py b/test/helper.py
--- a/test/helper.py
+++ b/test/helper.py
@@ -557,16 +557,16 @@ def resolve_duplicate(self, task, found_duplicates):
             task.should_merge_duplicates = True
 
 
-def generate_album_info(album_id, track_ids):
+def generate_album_info(album_id, track_values):
     """Return `AlbumInfo` populated with mock data.
 
     Sets the album info's `album_id` field is set to the corresponding
-    argument. For each value in `track_ids` the `TrackInfo` from
-    `generate_track_info` is added to the album info's `tracks` field.
+    argument. For each pair (`id`, `values`) in `track_values` the `TrackInfo`
+    from `generate_track_info` is added to the album info's `tracks` field.
     Most other fields of the album and track info are set to "album
     info" and "track info", respectively.
     """
-    tracks = [generate_track_info(id) for id in track_ids]
+    tracks = [generate_track_info(id, values) for id, values in track_values]
     album = AlbumInfo(
         album_id=u'album info',
         album=u'album info',
diff --git a/test/test_mbsync.py b/test/test_mbsync.py
--- a/test/test_mbsync.py
+++ b/test/test_mbsync.py
@@ -41,7 +41,10 @@ def tearDown(self):
     @patch('beets.autotag.hooks.track_for_mbid')
     def test_update_library(self, track_for_mbid, album_for_mbid):
         album_for_mbid.return_value = \
-            generate_album_info('album id', ['track id'])
+            generate_album_info(
+                'album id',
+                [('track id', {'release_track_id': u'release track id'})]
+            )
         track_for_mbid.return_value = \
             generate_track_info(u'singleton track id',
                                 {'title': u'singleton info'})
@@ -49,7 +52,8 @@ def test_update_library(self, track_for_mbid, album_for_mbid):
         album_item = Item(
             album=u'old title',
             mb_albumid=u'album id',
-            mb_trackid=u'track id',
+            mb_trackid=u'old track id',
+            mb_releasetrackid=u'release track id',
             path=''
         )
         album = self.lib.add_album([album_item])
@@ -68,6 +72,7 @@ def test_update_library(self, track_for_mbid, album_for_mbid):
 
         album_item.load()
         self.assertEqual(album_item.title, u'track info')
+        self.assertEqual(album_item.mb_trackid, u'track id')
 
         album.load()
         self.assertEqual(album.album, u'album info')