mirror of
https://github.com/beetbox/beets.git
synced 2026-02-08 08:25:23 +01:00
Support multiple pseudo-releases and reimport in musicbrainz
This commit is contained in:
parent
9a2dd2459f
commit
5ce66951a7
4 changed files with 241 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue