Support multiple pseudo-releases and reimport in musicbrainz

This commit is contained in:
asardaes 2026-01-17 15:03:14 +01:00
parent 9a2dd2459f
commit 5ce66951a7
4 changed files with 241 additions and 13 deletions

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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",