From 5ce66951a77b84b15c74ce5732db2e6220bca023 Mon Sep 17 00:00:00 2001 From: asardaes Date: Sat, 17 Jan 2026 15:03:14 +0100 Subject: [PATCH] Support multiple pseudo-releases and reimport in musicbrainz --- beetsplug/musicbrainz.py | 90 ++++++++++++++++--- docs/plugins/musicbrainz.rst | 21 +++++ test/plugins/test_musicbrainz_pseudo.py | 98 ++++++++++++++++++++- test/rsrc/musicbrainz/official_release.json | 45 ++++++++++ 4 files changed, 241 insertions(+), 13 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 29357c371..063c3dbfd 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -354,6 +354,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): "pseudo_releases": { "scripts": [], "custom_tags_only": False, + "multiple_allowed": False, "album_custom_tags": { "album_transl": "album", "album_artist_transl": "artist", @@ -410,8 +411,30 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): # ignore errors due to duplicates pass + self.register_listener( + "album_info_received", self._determine_pseudo_album_info_ref + ) self.register_listener("album_matched", self._adjust_final_album_match) + def _determine_pseudo_album_info_ref( + self, + items: Iterable[Item], + album_info: AlbumInfo, + ): + if isinstance(album_info, PseudoAlbumInfo): + for item in items: + # particularly relevant for reimport but could also happen during import + if "mb_albumid" in item: + del item["mb_albumid"] + if "mb_trackid" in item: + del item["mb_trackid"] + + self._log.debug( + "Using {0} release for distance calculations for album {1}", + album_info.determine_best_ref(list(items)), + album_info.album_id, + ) + def track_info( self, recording: JSONDict, @@ -817,18 +840,17 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): va_likely: bool, ) -> Iterable[AlbumInfo]: criteria = self.get_album_criteria(items, artist, album, va_likely) - release_ids = [r["id"] for r in self._search_api("release", criteria)] + release_ids = (r["id"] for r in self._search_api("release", criteria)) for id_ in release_ids: with suppress(HTTPNotFoundError): album_info = self.album_for_id(id_) # always yield pseudo first to give it priority - if isinstance(album_info, PseudoAlbumInfo): - self._log.debug( - "Using {0} release for distance calculations for album {1}", - album_info.determine_best_ref(list(items)), - album_info.album_id, - ) + if isinstance(album_info, MultiPseudoAlbumInfo): + yield from album_info.unwrap() + yield album_info + elif isinstance(album_info, PseudoAlbumInfo): + self._determine_pseudo_album_info_ref(items, album_info) yield album_info yield album_info.get_official_release() elif isinstance(album_info, AlbumInfo): @@ -937,11 +959,23 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): return len(languages) pseudo_releases.sort(key=sort_fun) - return self._resolve_pseudo_album_info( - release, - custom_tags_only, - languages, - pseudo_releases[0], + multiple_allowed = pseudo_config["multiple_allowed"].get(bool) + if custom_tags_only or not multiple_allowed: + return self._resolve_pseudo_album_info( + release, + custom_tags_only, + languages, + pseudo_releases[0], + ) + + pseudo_album_infos = [ + self._resolve_pseudo_album_info( + release, custom_tags_only, languages, i + ) + for i in pseudo_releases + ] + return MultiPseudoAlbumInfo( + *pseudo_album_infos, official_release=release ) def track_for_id( @@ -1171,3 +1205,35 @@ class PseudoAlbumInfo(AlbumInfo): result[k] = deepcopy(v, memo) return result + + +class MultiPseudoAlbumInfo(AlbumInfo): + """For releases that have multiple pseudo-releases""" + + def __init__( + self, + *args, + official_release: AlbumInfo, + **kwargs, + ): + super().__init__(official_release.tracks, **kwargs) + self.__dict__["_pseudo_album_infos"] = [ + arg for arg in args if isinstance(arg, PseudoAlbumInfo) + ] + for k, v in official_release.items(): + if k not in kwargs: + self[k] = v + + def unwrap(self) -> list[PseudoAlbumInfo]: + return self.__dict__["_pseudo_album_infos"] + + def __deepcopy__(self, memo): + cls = self.__class__ + result = cls.__new__(cls) + + memo[id(self)] = result + result.__dict__.update(self.__dict__) + for k, v in self.items(): + result[k] = deepcopy(v, memo) + + return result diff --git a/docs/plugins/musicbrainz.rst b/docs/plugins/musicbrainz.rst index f30c514b5..430f20b2a 100644 --- a/docs/plugins/musicbrainz.rst +++ b/docs/plugins/musicbrainz.rst @@ -45,6 +45,7 @@ Default pseudo_releases: scripts: [] custom_tags_only: no + multiple_allowed: no album_custom_tags: album_transl: album album_artist_transl: artist @@ -194,6 +195,26 @@ Pseudo-releases will only be included if the initial search in MusicBrainz returns releases whose script is *not* desired and whose relationships include pseudo-releases with desired scripts. +A release may have multiple pseudo-releases, for example when there is both a +transliteration and a translation available. By default, only 1 pseudo-release +per original release is emitted as a candidate, using the languages from the +configuration to decide which one has most priority. If you're importing in +timid mode and you would like to receive all valid pseudo-releases as additional +candidates, you can add the following to the configuration: + +.. code-block:: yaml + + musicbrainz: + pseudo_releases: + # other config not shown + multiple_allowed: yes + +.. note:: + + A limitation of reimporting in particular is that it will *not* give you a + pseudo-release proposal if multiple candidates exist and are allowed, so you + should disallow multiple in that scenario. + By default, the data from the pseudo-release will be used to create a proposal that is independent from the original release and sets all properties in its metadata. It's possible to change the configuration so that some information diff --git a/test/plugins/test_musicbrainz_pseudo.py b/test/plugins/test_musicbrainz_pseudo.py index 52e1891b7..874429fdb 100644 --- a/test/plugins/test_musicbrainz_pseudo.py +++ b/test/plugins/test_musicbrainz_pseudo.py @@ -11,7 +11,11 @@ from beets.autotag.distance import Distance from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.library import Item from beets.test.helper import PluginMixin -from beetsplug.musicbrainz import MusicBrainzPlugin, PseudoAlbumInfo +from beetsplug.musicbrainz import ( + MultiPseudoAlbumInfo, + MusicBrainzPlugin, + PseudoAlbumInfo, +) if TYPE_CHECKING: import pathlib @@ -136,6 +140,37 @@ class TestMBPseudoReleases(TestMBPseudoMixin): def test_scripts_init(self, musicbrainz_plugin: MusicBrainzPlugin): assert musicbrainz_plugin._scripts == ["Latn", "Dummy"] + def test_reimport_logic( + self, + musicbrainz_plugin: MusicBrainzPlugin, + official_release_info: AlbumInfo, + pseudo_release_info: AlbumInfo, + ): + pseudo_info = PseudoAlbumInfo( + pseudo_release_info, official_release_info + ) + + item = Item() + item["title"] = "百花繚乱" + + # if items don't have mb_*, they are not modified + musicbrainz_plugin._determine_pseudo_album_info_ref([item], pseudo_info) + assert pseudo_info.album == item.title + + pseudo_info.use_pseudo_as_ref() + assert pseudo_info.album == "In Bloom" + + item["mb_albumid"] = "mb_aid" + item["mb_trackid"] = "mb_tid" + assert item.get("mb_albumid") == "mb_aid" + assert item.get("mb_trackid") == "mb_tid" + + # if items have mb_*, they are deleted + musicbrainz_plugin._determine_pseudo_album_info_ref([item], pseudo_info) + assert pseudo_info.album == item.title + assert item.get("mb_albumid") == "" + assert item.get("mb_trackid") == "" + def test_album_info_for_pseudo_release( self, musicbrainz_plugin: MusicBrainzPlugin, @@ -242,6 +277,67 @@ class TestMBPseudoReleases(TestMBPseudoMixin): assert match.info.album == "In Bloom" +class TestMBMultiplePseudoReleases(PluginMixin): + plugin = "musicbrainz" + + @pytest.fixture(autouse=True) + def patch_get_release( + self, + monkeypatch, + official_release: JSONDict, + pseudo_release: JSONDict, + ): + def mock_get_release(_, album_id: str, **kwargs): + if album_id == official_release["id"]: + return official_release + elif album_id == pseudo_release["id"]: + return pseudo_release + else: + clone = deepcopy(pseudo_release) + clone["id"] = album_id + clone["text-representation"]["language"] = "jpn" + return clone + + monkeypatch.setattr( + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release", + mock_get_release, + ) + + @pytest.fixture(scope="class") + def plugin_config(self): + return { + "pseudo_releases": { + "scripts": ["Latn", "Dummy"], + "multiple_allowed": True, + } + } + + @pytest.fixture + def musicbrainz_plugin(self, config, plugin_config) -> MusicBrainzPlugin: + self.config[self.plugin].set(plugin_config) + config["import"]["languages"] = ["jp", "en"] + return MusicBrainzPlugin() + + def test_multiple_releases( + self, + musicbrainz_plugin: MusicBrainzPlugin, + official_release: JSONDict, + pseudo_release: JSONDict, + ): + album_info = musicbrainz_plugin.album_for_id(official_release["id"]) + assert isinstance(album_info, MultiPseudoAlbumInfo) + assert album_info.data_source == "MusicBrainz" + assert len(album_info.unwrap()) == 2 + assert ( + album_info.unwrap()[0].album_id + == "mockedid-0bc1-49eb-b8c4-34473d279a43" + ) + assert ( + album_info.unwrap()[1].album_id + == "dc3ee2df-0bc1-49eb-b8c4-34473d279a43" + ) + + class TestMBPseudoReleasesCustomTagsOnly(TestMBPseudoMixin): @pytest.fixture(scope="class") def plugin_config(self): diff --git a/test/rsrc/musicbrainz/official_release.json b/test/rsrc/musicbrainz/official_release.json index cd6bb3ba9..f06093b3f 100644 --- a/test/rsrc/musicbrainz/official_release.json +++ b/test/rsrc/musicbrainz/official_release.json @@ -1642,6 +1642,51 @@ "target-credit": "", "type": "transl-tracklisting", "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644" + }, + { + "attribute-ids": {}, + "attribute-values": {}, + "attributes": [], + "begin": null, + "direction": "forward", + "end": null, + "ended": false, + "release": { + "artist-credit": [ + { + "artist": { + "country": "JP", + "disambiguation": "", + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "type": null, + "type-id": null + }, + "joinphrase": "", + "name": "Lilas Ikuta" + } + ], + "barcode": null, + "disambiguation": "", + "id": "mockedid-0bc1-49eb-b8c4-34473d279a43", + "media": [], + "packaging": null, + "packaging-id": null, + "quality": "normal", + "release-group": null, + "status": null, + "status-id": null, + "text-representation": { + "language": "jpn", + "script": "Latn" + }, + "title": "Title Desu" + }, + "source-credit": "", + "target-credit": "", + "type": "transl-tracklisting", + "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644" } ], "status": "Official",