From 017930dd9918446ce7fd755e74bcbfa3e66dce60 Mon Sep 17 00:00:00 2001 From: asardaes Date: Sun, 20 Jul 2025 10:44:40 +0200 Subject: [PATCH 01/13] Use pseudo-release's track titles for its recordings --- beetsplug/musicbrainz.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 8e259e94b..75cc063b8 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -871,11 +871,34 @@ class MusicBrainzPlugin(MetadataSourcePlugin): # should be None unless we're dealing with a pseudo release if actual_res is not None: - actual_release = self.album_info(actual_res["release"]) + actual_release = self._get_actual_release(res, actual_res) return _merge_pseudo_and_actual_album(release, actual_release) else: return release + def _get_actual_release( + self, + res: JSONDict, + actual_res: JSONDict, + ) -> beets.autotag.hooks.AlbumInfo: + medium_list = res["release"]["medium-list"] + for medium in medium_list: + for track in medium.get("track-list", []): + if "recording" not in track: + continue + + recording_overrides = { + k: v + for k, v in track.items() + if (k != "id" and k != "recording") + } + track["recording"].update(recording_overrides) + + actual_res = actual_res["release"] + actual_res["medium-list"] = medium_list + actual_release = self.album_info(actual_res) + return actual_release + def track_for_id( self, track_id: str ) -> beets.autotag.hooks.TrackInfo | None: From ac0b221802852c43e75e84535d385e77f311e398 Mon Sep 17 00:00:00 2001 From: asardaes Date: Sun, 20 Jul 2025 20:09:27 +0200 Subject: [PATCH 02/13] Revert "Use pseudo-release's track titles for its recordings" This reverts commit f3ddda3a422ffbe06722215abeec63436f1a1a43. --- beetsplug/musicbrainz.py | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 75cc063b8..8e259e94b 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -871,34 +871,11 @@ class MusicBrainzPlugin(MetadataSourcePlugin): # should be None unless we're dealing with a pseudo release if actual_res is not None: - actual_release = self._get_actual_release(res, actual_res) + actual_release = self.album_info(actual_res["release"]) return _merge_pseudo_and_actual_album(release, actual_release) else: return release - def _get_actual_release( - self, - res: JSONDict, - actual_res: JSONDict, - ) -> beets.autotag.hooks.AlbumInfo: - medium_list = res["release"]["medium-list"] - for medium in medium_list: - for track in medium.get("track-list", []): - if "recording" not in track: - continue - - recording_overrides = { - k: v - for k, v in track.items() - if (k != "id" and k != "recording") - } - track["recording"].update(recording_overrides) - - actual_res = actual_res["release"] - actual_res["medium-list"] = medium_list - actual_release = self.album_info(actual_res) - return actual_release - def track_for_id( self, track_id: str ) -> beets.autotag.hooks.TrackInfo | None: From f3934dc58bfc0ba8bdcf28e1443f8b51d8bc374b Mon Sep 17 00:00:00 2001 From: asardaes Date: Sun, 20 Jul 2025 10:44:58 +0200 Subject: [PATCH 03/13] Add mbpseudo plugin --- .github/CODEOWNERS | 3 +- beetsplug/mbpseudo.py | 424 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 beetsplug/mbpseudo.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bb888d520..d014b925b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,4 +2,5 @@ * @beetbox/maintainers # Specific ownerships: -/beets/metadata_plugins.py @semohr \ No newline at end of file +/beets/metadata_plugins.py @semohr +/beetsplug/mbpseudo.py @asardaes \ No newline at end of file diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py new file mode 100644 index 000000000..76e9ac0cd --- /dev/null +++ b/beetsplug/mbpseudo.py @@ -0,0 +1,424 @@ +# This file is part of beets. +# Copyright 2025, Alexis Sarda-Espinosa. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Adds pseudo-releases from MusicBrainz as candidates during import.""" + +import itertools +from typing import Iterable, Sequence + +from typing_extensions import override + +import beetsplug.musicbrainz as mbplugin # avoid implicit loading of main plugin +from beets.autotag import AlbumInfo, Distance +from beets.autotag.distance import distance +from beets.autotag.hooks import V, TrackInfo +from beets.autotag.match import assign_items +from beets.library import Item +from beets.metadata_plugins import MetadataSourcePlugin +from beets.plugins import find_plugins +from beetsplug._typing import JSONDict + +_STATUS_PSEUDO = "Pseudo-Release" + + +class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.config.add({"scripts": [], "include_official_releases": False}) + + self._scripts = self.config["scripts"].as_str_seq() + self._mb = mbplugin.MusicBrainzPlugin() + + self._pseudo_release_ids: dict[str, list[str]] = {} + self._intercepted_candidates: dict[str, AlbumInfo] = {} + self._mb_plugin_loaded_before = True + + self.register_listener("pluginload", self._on_plugins_loaded) + self.register_listener("mb_album_extract", self._intercept_mb_releases) + self.register_listener( + "albuminfo_received", self._intercept_mb_candidates + ) + + self._log.debug("Desired scripts: {0}", self._scripts) + + def _on_plugins_loaded(self): + mb_index = None + self_index = -1 + for i, plugin in enumerate(find_plugins()): + if isinstance(plugin, mbplugin.MusicBrainzPlugin): + mb_index = i + elif isinstance(plugin, MusicBrainzPseudoReleasePlugin): + self_index = i + + if mb_index and self_index < mb_index: + self._mb_plugin_loaded_before = False + self._log.warning( + "The mbpseudo plugin was loaded before the musicbrainz plugin" + ", this will result in redundant network calls" + ) + + def _intercept_mb_releases(self, data: JSONDict): + album_id = data["id"] if "id" in data else None + if ( + self._has_desired_script(data) + or not isinstance(album_id, str) + or album_id in self._pseudo_release_ids + ): + return None + + pseudo_release_ids = ( + self._wanted_pseudo_release_id(rel) + for rel in data.get("release-relation-list", []) + ) + pseudo_release_ids = [ + rel for rel in pseudo_release_ids if rel is not None + ] + + if len(pseudo_release_ids) > 0: + self._log.debug("Intercepted release with album id {0}", album_id) + self._pseudo_release_ids[album_id] = pseudo_release_ids + + return None + + def _has_desired_script(self, release: JSONDict) -> bool: + if len(self._scripts) == 0: + return False + elif script := release.get("text-representation", {}).get("script"): + return script in self._scripts + else: + return False + + def _wanted_pseudo_release_id( + self, + relation: JSONDict, + ) -> str | None: + if ( + len(self._scripts) == 0 + or relation.get("type", "") != "transl-tracklisting" + or relation.get("direction", "") != "forward" + or "release" not in relation + ): + return None + + release = relation["release"] + if "id" in release and self._has_desired_script(release): + return release["id"] + else: + return None + + def _intercept_mb_candidates(self, info: AlbumInfo): + if ( + not isinstance(info, PseudoAlbumInfo) + and info.album_id in self._pseudo_release_ids + and info.album_id not in self._intercepted_candidates + ): + self._log.debug( + "Intercepted candidate with album id {0.album_id}", info + ) + self._intercepted_candidates[info.album_id] = info.copy() + + elif info.get("albumstatus", "") == _STATUS_PSEUDO: + self._purge_intercepted_pseudo_releases(info) + + def candidates( + self, + items: Sequence[Item], + artist: str, + album: str, + va_likely: bool, + ) -> Iterable[AlbumInfo]: + """Even though a candidate might have extra and/or missing tracks, the set of paths from the items that + were actually matched (which are stored in the corresponding ``mapping``) must be a subset of the set of + paths from the input items. This helps us figure out which intercepted candidate might be relevant for + the items we get in this call even if other candidates have been concurrently intercepted as well. + """ + + if len(self._scripts) == 0: + return [] + + try: + item_paths = {item.path for item in items} + official_release_id = next( + key + for key, info in self._intercepted_candidates.items() + if "mapping" in info + and all( + mapping_key.path in item_paths + for mapping_key in info.mapping.keys() + ) + ) + pseudo_release_ids = self._pseudo_release_ids[official_release_id] + self._log.debug( + "Processing pseudo-releases for {0}: {1}", + official_release_id, + pseudo_release_ids, + ) + except StopIteration: + official_release_id = None + pseudo_release_ids = [] + + if official_release_id is not None: + pseudo_releases = self._get_pseudo_releases( + items, official_release_id, pseudo_release_ids + ) + del self._pseudo_release_ids[official_release_id] + del self._intercepted_candidates[official_release_id] + return pseudo_releases + + if ( + any( + isinstance(plugin, mbplugin.MusicBrainzPlugin) + for plugin in find_plugins() + ) + and self._mb_plugin_loaded_before + ): + self._log.debug( + "No releases found after main MusicBrainz plugin executed" + ) + return [] + + # musicbrainz plugin isn't enabled + self._log.debug("Searching for official releases") + + try: + existing_album_id = next( + item.mb_albumid for item in items if item.mb_albumid + ) + existing_album_info = self._mb.album_for_id(existing_album_id) + if not isinstance(existing_album_info, AlbumInfo): + official_candidates = list( + self._mb.candidates(items, artist, album, va_likely) + ) + else: + official_candidates = [existing_album_info] + except StopIteration: + official_candidates = list( + self._mb.candidates(items, artist, album, va_likely) + ) + + recursion = self._mb_plugin_simulation_matched( + items, official_candidates + ) + + if recursion and not self.config.get().get("include_official_releases"): + official_candidates = [] + + self._log.debug( + "Emitting {0} official match(es)", len(official_candidates) + ) + if recursion: + self._log.debug("Matches found after search") + return itertools.chain( + self.candidates(items, artist, album, va_likely), + iter(official_candidates), + ) + else: + return iter(official_candidates) + + def _get_pseudo_releases( + self, + items: Sequence[Item], + official_release_id: str, + pseudo_release_ids: list[str], + ) -> list[AlbumInfo]: + pseudo_releases: list[AlbumInfo] = [] + for pr_id in pseudo_release_ids: + if match := self._mb.album_for_id(pr_id): + pseudo_album_info = PseudoAlbumInfo( + pseudo_release=match, + official_release=self._intercepted_candidates[ + official_release_id + ], + data_source=self.data_source, + ) + self._log.debug( + "Using {0} release for distance calculations for album {1}", + pseudo_album_info.determine_best_ref(items), + pr_id, + ) + pseudo_releases.append(pseudo_album_info) + return pseudo_releases + + def _mb_plugin_simulation_matched( + self, + items: Sequence[Item], + official_candidates: list[AlbumInfo], + ) -> bool: + """Simulate how we would have been called if the MusicBrainz plugin had actually executed. + + At this point we already called ``self._mb.candidates()``, + which emits the ``mb_album_extract`` events, + so now we simulate: + + 1. Intercepting the ``AlbumInfo`` candidate that would have come in the ``albuminfo_received`` event. + 2. Intercepting the distance calculation of the aforementioned candidate to store its mapping. + + If the official candidate is already a pseudo-release, we clean up internal state. + This is needed because the MusicBrainz plugin emits official releases even if + it receives a pseudo-release as input, so the chain would actually be: + pseudo-release input -> official release with pseudo emitted -> intercepted -> pseudo-release resolved (again) + + To avoid resolving again in the last step, we remove the pseudo-release's id. + """ + + matched = False + for official_candidate in official_candidates: + if official_candidate.album_id in self._pseudo_release_ids: + self._intercept_mb_candidates(official_candidate) + + if official_candidate.album_id in self._intercepted_candidates: + intercepted = self._intercepted_candidates[ + official_candidate.album_id + ] + intercepted.mapping, _, _ = assign_items( + items, intercepted.tracks + ) + matched = True + + if official_candidate.get("albumstatus", "") == _STATUS_PSEUDO: + self._purge_intercepted_pseudo_releases(official_candidate) + + return matched + + def _purge_intercepted_pseudo_releases(self, official_candidate: AlbumInfo): + rm_keys = [ + album_id + for album_id, pseudo_album_ids in self._pseudo_release_ids.items() + if official_candidate.album_id in pseudo_album_ids + ] + if rm_keys: + self._log.debug( + "No need to resolve {0}, removing", + rm_keys, + ) + for k in rm_keys: + del self._pseudo_release_ids[k] + + @override + def album_distance( + self, + items: Sequence[Item], + album_info: AlbumInfo, + mapping: dict[Item, TrackInfo], + ) -> Distance: + """We use this function more like a listener for the extra details we are injecting. + + For instances of ``PseudoAlbumInfo`` whose corresponding ``mapping`` is _not_ an + instance of ``ImmutableMapping``, we know at this point that all penalties from the + normal auto-tagging flow have been applied, so we can switch to the metadata from + the pseudo-release for the final proposal. + + Other instances of ``AlbumInfo`` must come from other plugins, so we just check if + we intercepted them as candidates with pseudo-releases and store their ``mapping``. + This is needed because the real listeners we use never expose information from the + input ``Item``s, so we intercept that here. + + The paths from the items are used to figure out which pseudo-releases should be + provided for them, which is specially important for concurrent stage execution + where we might have intercepted releases from different import tasks when we run. + """ + + if isinstance(album_info, PseudoAlbumInfo): + if not isinstance(mapping, ImmutableMapping): + self._log.debug( + "Switching {0.album_id} to pseudo-release source for final proposal", + album_info, + ) + album_info.use_pseudo_as_ref() + new_mappings, _, _ = assign_items(items, album_info.tracks) + mapping.update(new_mappings) + + elif album_info.album_id in self._intercepted_candidates: + self._log.debug("Storing mapping for {0.album_id}", album_info) + self._intercepted_candidates[album_info.album_id].mapping = mapping + + return super().album_distance(items, album_info, mapping) + + def album_for_id(self, album_id: str) -> AlbumInfo | None: + pass + + def track_for_id(self, track_id: str) -> TrackInfo | None: + pass + + def item_candidates( + self, + item: Item, + artist: str, + title: str, + ) -> Iterable[TrackInfo]: + return [] + + +class PseudoAlbumInfo(AlbumInfo): + """This is a not-so-ugly hack. + + We want the pseudo-release to result in a distance that is lower or equal to that of the official release, + otherwise it won't qualify as a good candidate. However, if the input is in a script that's different from + the pseudo-release (and we want to translate/transliterate it in the library), it will receive unwanted penalties. + + This class is essentially a view of the ``AlbumInfo`` of both official and pseudo-releases, + where it's possible to change the details that are exposed to other parts of the auto-tagger, + enabling a "fair" distance calculation based on the current input's script but still preferring + the translation/transliteration in the final proposal. + """ + + def __init__( + self, + pseudo_release: AlbumInfo, + official_release: AlbumInfo, + **kwargs, + ): + super().__init__(pseudo_release.tracks, **kwargs) + self.__dict__["_pseudo_source"] = True + self.__dict__["_official_release"] = official_release + for k, v in pseudo_release.items(): + if k not in kwargs: + self[k] = v + + def determine_best_ref(self, items: Sequence[Item]) -> str: + self.use_pseudo_as_ref() + pseudo_dist = self._compute_distance(items) + + self.use_official_as_ref() + official_dist = self._compute_distance(items) + + if official_dist < pseudo_dist: + self.use_official_as_ref() + return "official" + else: + self.use_pseudo_as_ref() + return "pseudo" + + def _compute_distance(self, items: Sequence[Item]) -> Distance: + mapping, _, _ = assign_items(items, self.tracks) + return distance(items, self, ImmutableMapping(mapping)) + + def use_pseudo_as_ref(self): + self.__dict__["_pseudo_source"] = True + + def use_official_as_ref(self): + self.__dict__["_pseudo_source"] = False + + def __getattr__(self, attr: str) -> V: + # ensure we don't duplicate an official release's id by always returning pseudo's + if self.__dict__["_pseudo_source"] or attr == "album_id": + return super().__getattr__(attr) + else: + return self.__dict__["_official_release"].__getattr__(attr) + + +class ImmutableMapping(dict[Item, TrackInfo]): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) From 0d9064902974ad3a7340c2afb8caccb1a254c9b4 Mon Sep 17 00:00:00 2001 From: asardaes Date: Sun, 20 Jul 2025 23:43:39 +0200 Subject: [PATCH 04/13] Fix linting issues --- beetsplug/mbpseudo.py | 91 ++++++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 40 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 76e9ac0cd..19c5317a1 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -15,14 +15,14 @@ """Adds pseudo-releases from MusicBrainz as candidates during import.""" import itertools -from typing import Iterable, Sequence +from typing import Any, Iterable, Sequence from typing_extensions import override import beetsplug.musicbrainz as mbplugin # avoid implicit loading of main plugin -from beets.autotag import AlbumInfo, Distance -from beets.autotag.distance import distance -from beets.autotag.hooks import V, TrackInfo +from beets.autotag import AlbumInfo +from beets.autotag.distance import Distance, distance +from beets.autotag.hooks import TrackInfo from beets.autotag.match import assign_items from beets.library import Item from beets.metadata_plugins import MetadataSourcePlugin @@ -78,12 +78,10 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): ): return None - pseudo_release_ids = ( - self._wanted_pseudo_release_id(rel) - for rel in data.get("release-relation-list", []) - ) pseudo_release_ids = [ - rel for rel in pseudo_release_ids if rel is not None + pr_id + for rel in data.get("release-relation-list", []) + if (pr_id := self._wanted_pseudo_release_id(rel)) is not None ] if len(pseudo_release_ids) > 0: @@ -139,10 +137,12 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): album: str, va_likely: bool, ) -> Iterable[AlbumInfo]: - """Even though a candidate might have extra and/or missing tracks, the set of paths from the items that - were actually matched (which are stored in the corresponding ``mapping``) must be a subset of the set of - paths from the input items. This helps us figure out which intercepted candidate might be relevant for - the items we get in this call even if other candidates have been concurrently intercepted as well. + """Even though a candidate might have extra and/or missing tracks, the set of + paths from the items that were actually matched (which are stored in the + corresponding ``mapping``) must be a subset of the set of paths from the input + items. This helps us figure out which intercepted candidate might be relevant + for the items we get in this call even if other candidates have been + concurrently intercepted as well. """ if len(self._scripts) == 0: @@ -256,19 +256,26 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): items: Sequence[Item], official_candidates: list[AlbumInfo], ) -> bool: - """Simulate how we would have been called if the MusicBrainz plugin had actually executed. + """Simulate how we would have been called if the MusicBrainz plugin had actually + executed. At this point we already called ``self._mb.candidates()``, which emits the ``mb_album_extract`` events, so now we simulate: - 1. Intercepting the ``AlbumInfo`` candidate that would have come in the ``albuminfo_received`` event. - 2. Intercepting the distance calculation of the aforementioned candidate to store its mapping. + 1. Intercepting the ``AlbumInfo`` candidate that would have come in the + ``albuminfo_received`` event. + 2. Intercepting the distance calculation of the aforementioned candidate to + store its mapping. - If the official candidate is already a pseudo-release, we clean up internal state. - This is needed because the MusicBrainz plugin emits official releases even if - it receives a pseudo-release as input, so the chain would actually be: - pseudo-release input -> official release with pseudo emitted -> intercepted -> pseudo-release resolved (again) + If the official candidate is already a pseudo-release, we clean up internal + state. This is needed because the MusicBrainz plugin emits official releases + even if it receives a pseudo-release as input, so the chain would actually be: + + pseudo-release input -> + official release with pseudo emitted -> + intercepted -> + pseudo-release resolved (again) To avoid resolving again in the last step, we remove the pseudo-release's id. """ @@ -313,28 +320,30 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): album_info: AlbumInfo, mapping: dict[Item, TrackInfo], ) -> Distance: - """We use this function more like a listener for the extra details we are injecting. + """We use this function more like a listener for the extra details we are + injecting. For instances of ``PseudoAlbumInfo`` whose corresponding ``mapping`` is _not_ an - instance of ``ImmutableMapping``, we know at this point that all penalties from the - normal auto-tagging flow have been applied, so we can switch to the metadata from - the pseudo-release for the final proposal. + instance of ``ImmutableMapping``, we know at this point that all penalties from + the normal auto-tagging flow have been applied, so we can switch to the metadata + from the pseudo-release for the final proposal. - Other instances of ``AlbumInfo`` must come from other plugins, so we just check if - we intercepted them as candidates with pseudo-releases and store their ``mapping``. - This is needed because the real listeners we use never expose information from the - input ``Item``s, so we intercept that here. + Other instances of ``AlbumInfo`` must come from other plugins, so we just check + if we intercepted them as candidates with pseudo-releases and store their + ``mapping``. This is needed because the real listeners we use never expose + information from the input ``Item``s, so we intercept that here. The paths from the items are used to figure out which pseudo-releases should be provided for them, which is specially important for concurrent stage execution - where we might have intercepted releases from different import tasks when we run. + where we might have already intercepted releases from different import tasks + when we run. """ if isinstance(album_info, PseudoAlbumInfo): if not isinstance(mapping, ImmutableMapping): self._log.debug( - "Switching {0.album_id} to pseudo-release source for final proposal", - album_info, + "Switching {0} to pseudo-release source for final proposal", + album_info.album_id, ) album_info.use_pseudo_as_ref() new_mappings, _, _ = assign_items(items, album_info.tracks) @@ -364,14 +373,16 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): class PseudoAlbumInfo(AlbumInfo): """This is a not-so-ugly hack. - We want the pseudo-release to result in a distance that is lower or equal to that of the official release, - otherwise it won't qualify as a good candidate. However, if the input is in a script that's different from - the pseudo-release (and we want to translate/transliterate it in the library), it will receive unwanted penalties. + We want the pseudo-release to result in a distance that is lower or equal to that of + the official release, otherwise it won't qualify as a good candidate. However, if + the input is in a script that's different from the pseudo-release (and we want to + translate/transliterate it in the library), it will receive unwanted penalties. - This class is essentially a view of the ``AlbumInfo`` of both official and pseudo-releases, - where it's possible to change the details that are exposed to other parts of the auto-tagger, - enabling a "fair" distance calculation based on the current input's script but still preferring - the translation/transliteration in the final proposal. + This class is essentially a view of the ``AlbumInfo`` of both official and + pseudo-releases, where it's possible to change the details that are exposed to other + parts of the auto-tagger, enabling a "fair" distance calculation based on the + current input's script but still preferring the translation/transliteration in the + final proposal. """ def __init__( @@ -411,8 +422,8 @@ class PseudoAlbumInfo(AlbumInfo): def use_official_as_ref(self): self.__dict__["_pseudo_source"] = False - def __getattr__(self, attr: str) -> V: - # ensure we don't duplicate an official release's id by always returning pseudo's + def __getattr__(self, attr: str) -> Any: + # ensure we don't duplicate an official release's id, always return pseudo's if self.__dict__["_pseudo_source"] or attr == "album_id": return super().__getattr__(attr) else: From 79f691832c68b532398fbcf03c4dc45c34e31309 Mon Sep 17 00:00:00 2001 From: asardaes Date: Sat, 9 Aug 2025 15:36:28 +0200 Subject: [PATCH 05/13] Use Optional --- beetsplug/mbpseudo.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 19c5317a1..c49e5e5b6 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -15,7 +15,7 @@ """Adds pseudo-releases from MusicBrainz as candidates during import.""" import itertools -from typing import Any, Iterable, Sequence +from typing import Any, Iterable, Optional, Sequence from typing_extensions import override @@ -101,7 +101,7 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): def _wanted_pseudo_release_id( self, relation: JSONDict, - ) -> str | None: + ) -> Optional[str]: if ( len(self._scripts) == 0 or relation.get("type", "") != "transl-tracklisting" @@ -355,10 +355,10 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): return super().album_distance(items, album_info, mapping) - def album_for_id(self, album_id: str) -> AlbumInfo | None: + def album_for_id(self, album_id: str) -> Optional[AlbumInfo]: pass - def track_for_id(self, track_id: str) -> TrackInfo | None: + def track_for_id(self, track_id: str) -> Optional[TrackInfo]: pass def item_candidates( From ab5705f444a4be8f8bf0d4910dd52c7d6322f173 Mon Sep 17 00:00:00 2001 From: asardaes Date: Sun, 5 Oct 2025 22:00:46 -0600 Subject: [PATCH 06/13] Reimplement mbpseudo plugin inheriting from MusicBrainzPlugin --- beetsplug/mbpseudo.py | 371 +++++++++++---------------------------- beetsplug/musicbrainz.py | 2 +- 2 files changed, 99 insertions(+), 274 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index c49e5e5b6..d544a5624 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -14,82 +14,108 @@ """Adds pseudo-releases from MusicBrainz as candidates during import.""" -import itertools +from copy import deepcopy from typing import Any, Iterable, Optional, Sequence +import musicbrainzngs from typing_extensions import override -import beetsplug.musicbrainz as mbplugin # avoid implicit loading of main plugin -from beets.autotag import AlbumInfo from beets.autotag.distance import Distance, distance -from beets.autotag.hooks import TrackInfo +from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.autotag.match import assign_items from beets.library import Item -from beets.metadata_plugins import MetadataSourcePlugin from beets.plugins import find_plugins +from beets.util.id_extractors import extract_release_id from beetsplug._typing import JSONDict +from beetsplug.musicbrainz import ( + RELEASE_INCLUDES, + MusicBrainzPlugin, + _merge_pseudo_and_actual_album, +) _STATUS_PSEUDO = "Pseudo-Release" -class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.config.add({"scripts": [], "include_official_releases": False}) +class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): + def __init__(self) -> None: + super().__init__() + self.config.add({"scripts": []}) self._scripts = self.config["scripts"].as_str_seq() - self._mb = mbplugin.MusicBrainzPlugin() - - self._pseudo_release_ids: dict[str, list[str]] = {} - self._intercepted_candidates: dict[str, AlbumInfo] = {} - self._mb_plugin_loaded_before = True - - self.register_listener("pluginload", self._on_plugins_loaded) - self.register_listener("mb_album_extract", self._intercept_mb_releases) - self.register_listener( - "albuminfo_received", self._intercept_mb_candidates - ) - self._log.debug("Desired scripts: {0}", self._scripts) + self.register_listener("pluginload", self._on_plugins_loaded) + + # noinspection PyMethodMayBeStatic def _on_plugins_loaded(self): - mb_index = None - self_index = -1 - for i, plugin in enumerate(find_plugins()): - if isinstance(plugin, mbplugin.MusicBrainzPlugin): - mb_index = i - elif isinstance(plugin, MusicBrainzPseudoReleasePlugin): - self_index = i + for plugin in find_plugins(): + if isinstance(plugin, MusicBrainzPlugin) and not isinstance( + plugin, MusicBrainzPseudoReleasePlugin + ): + raise RuntimeError( + "The musicbrainz plugin should not be enabled together with" + " the mbpseudo plugin" + ) - if mb_index and self_index < mb_index: - self._mb_plugin_loaded_before = False - self._log.warning( - "The mbpseudo plugin was loaded before the musicbrainz plugin" - ", this will result in redundant network calls" + @override + def candidates( + self, + items: Sequence[Item], + artist: str, + album: str, + va_likely: bool, + ) -> Iterable[AlbumInfo]: + if len(self._scripts) == 0: + yield from super().candidates(items, artist, album, va_likely) + else: + for album_info in super().candidates( + items, artist, album, va_likely + ): + if isinstance(album_info, PseudoAlbumInfo): + yield album_info.get_official_release() + self._log.debug( + "Using {0} release for distance calculations for album {1}", + album_info.determine_best_ref(items), + album_info.album_id, + ) + + yield album_info + + @override + def album_info(self, release: JSONDict) -> AlbumInfo: + official_release = super().album_info(release) + official_release.data_source = "MusicBrainz" + + if release.get("status") == _STATUS_PSEUDO: + return official_release + elif pseudo_release_ids := self._intercept_mb_release(release): + album_id = self._extract_id(pseudo_release_ids[0]) + raw_pseudo_release = musicbrainzngs.get_release_by_id( + album_id, RELEASE_INCLUDES ) + pseudo_release = super().album_info(raw_pseudo_release["release"]) + return PseudoAlbumInfo( + pseudo_release=_merge_pseudo_and_actual_album( + pseudo_release, official_release + ), + official_release=official_release, + data_source=self.data_source, + ) + else: + return official_release - def _intercept_mb_releases(self, data: JSONDict): + def _intercept_mb_release(self, data: JSONDict) -> list[str]: album_id = data["id"] if "id" in data else None - if ( - self._has_desired_script(data) - or not isinstance(album_id, str) - or album_id in self._pseudo_release_ids - ): - return None + if self._has_desired_script(data) or not isinstance(album_id, str): + return [] - pseudo_release_ids = [ + return [ pr_id for rel in data.get("release-relation-list", []) - if (pr_id := self._wanted_pseudo_release_id(rel)) is not None + if (pr_id := self._wanted_pseudo_release_id(album_id, rel)) + is not None ] - if len(pseudo_release_ids) > 0: - self._log.debug("Intercepted release with album id {0}", album_id) - self._pseudo_release_ids[album_id] = pseudo_release_ids - - return None - def _has_desired_script(self, release: JSONDict) -> bool: if len(self._scripts) == 0: return False @@ -100,6 +126,7 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): def _wanted_pseudo_release_id( self, + album_id: str, relation: JSONDict, ) -> Optional[str]: if ( @@ -112,207 +139,15 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): release = relation["release"] if "id" in release and self._has_desired_script(release): + self._log.debug( + "Adding pseudo-release {0} for main release {1}", + release["id"], + album_id, + ) return release["id"] else: return None - def _intercept_mb_candidates(self, info: AlbumInfo): - if ( - not isinstance(info, PseudoAlbumInfo) - and info.album_id in self._pseudo_release_ids - and info.album_id not in self._intercepted_candidates - ): - self._log.debug( - "Intercepted candidate with album id {0.album_id}", info - ) - self._intercepted_candidates[info.album_id] = info.copy() - - elif info.get("albumstatus", "") == _STATUS_PSEUDO: - self._purge_intercepted_pseudo_releases(info) - - def candidates( - self, - items: Sequence[Item], - artist: str, - album: str, - va_likely: bool, - ) -> Iterable[AlbumInfo]: - """Even though a candidate might have extra and/or missing tracks, the set of - paths from the items that were actually matched (which are stored in the - corresponding ``mapping``) must be a subset of the set of paths from the input - items. This helps us figure out which intercepted candidate might be relevant - for the items we get in this call even if other candidates have been - concurrently intercepted as well. - """ - - if len(self._scripts) == 0: - return [] - - try: - item_paths = {item.path for item in items} - official_release_id = next( - key - for key, info in self._intercepted_candidates.items() - if "mapping" in info - and all( - mapping_key.path in item_paths - for mapping_key in info.mapping.keys() - ) - ) - pseudo_release_ids = self._pseudo_release_ids[official_release_id] - self._log.debug( - "Processing pseudo-releases for {0}: {1}", - official_release_id, - pseudo_release_ids, - ) - except StopIteration: - official_release_id = None - pseudo_release_ids = [] - - if official_release_id is not None: - pseudo_releases = self._get_pseudo_releases( - items, official_release_id, pseudo_release_ids - ) - del self._pseudo_release_ids[official_release_id] - del self._intercepted_candidates[official_release_id] - return pseudo_releases - - if ( - any( - isinstance(plugin, mbplugin.MusicBrainzPlugin) - for plugin in find_plugins() - ) - and self._mb_plugin_loaded_before - ): - self._log.debug( - "No releases found after main MusicBrainz plugin executed" - ) - return [] - - # musicbrainz plugin isn't enabled - self._log.debug("Searching for official releases") - - try: - existing_album_id = next( - item.mb_albumid for item in items if item.mb_albumid - ) - existing_album_info = self._mb.album_for_id(existing_album_id) - if not isinstance(existing_album_info, AlbumInfo): - official_candidates = list( - self._mb.candidates(items, artist, album, va_likely) - ) - else: - official_candidates = [existing_album_info] - except StopIteration: - official_candidates = list( - self._mb.candidates(items, artist, album, va_likely) - ) - - recursion = self._mb_plugin_simulation_matched( - items, official_candidates - ) - - if recursion and not self.config.get().get("include_official_releases"): - official_candidates = [] - - self._log.debug( - "Emitting {0} official match(es)", len(official_candidates) - ) - if recursion: - self._log.debug("Matches found after search") - return itertools.chain( - self.candidates(items, artist, album, va_likely), - iter(official_candidates), - ) - else: - return iter(official_candidates) - - def _get_pseudo_releases( - self, - items: Sequence[Item], - official_release_id: str, - pseudo_release_ids: list[str], - ) -> list[AlbumInfo]: - pseudo_releases: list[AlbumInfo] = [] - for pr_id in pseudo_release_ids: - if match := self._mb.album_for_id(pr_id): - pseudo_album_info = PseudoAlbumInfo( - pseudo_release=match, - official_release=self._intercepted_candidates[ - official_release_id - ], - data_source=self.data_source, - ) - self._log.debug( - "Using {0} release for distance calculations for album {1}", - pseudo_album_info.determine_best_ref(items), - pr_id, - ) - pseudo_releases.append(pseudo_album_info) - return pseudo_releases - - def _mb_plugin_simulation_matched( - self, - items: Sequence[Item], - official_candidates: list[AlbumInfo], - ) -> bool: - """Simulate how we would have been called if the MusicBrainz plugin had actually - executed. - - At this point we already called ``self._mb.candidates()``, - which emits the ``mb_album_extract`` events, - so now we simulate: - - 1. Intercepting the ``AlbumInfo`` candidate that would have come in the - ``albuminfo_received`` event. - 2. Intercepting the distance calculation of the aforementioned candidate to - store its mapping. - - If the official candidate is already a pseudo-release, we clean up internal - state. This is needed because the MusicBrainz plugin emits official releases - even if it receives a pseudo-release as input, so the chain would actually be: - - pseudo-release input -> - official release with pseudo emitted -> - intercepted -> - pseudo-release resolved (again) - - To avoid resolving again in the last step, we remove the pseudo-release's id. - """ - - matched = False - for official_candidate in official_candidates: - if official_candidate.album_id in self._pseudo_release_ids: - self._intercept_mb_candidates(official_candidate) - - if official_candidate.album_id in self._intercepted_candidates: - intercepted = self._intercepted_candidates[ - official_candidate.album_id - ] - intercepted.mapping, _, _ = assign_items( - items, intercepted.tracks - ) - matched = True - - if official_candidate.get("albumstatus", "") == _STATUS_PSEUDO: - self._purge_intercepted_pseudo_releases(official_candidate) - - return matched - - def _purge_intercepted_pseudo_releases(self, official_candidate: AlbumInfo): - rm_keys = [ - album_id - for album_id, pseudo_album_ids in self._pseudo_release_ids.items() - if official_candidate.album_id in pseudo_album_ids - ] - if rm_keys: - self._log.debug( - "No need to resolve {0}, removing", - rm_keys, - ) - for k in rm_keys: - del self._pseudo_release_ids[k] - @override def album_distance( self, @@ -327,16 +162,6 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): instance of ``ImmutableMapping``, we know at this point that all penalties from the normal auto-tagging flow have been applied, so we can switch to the metadata from the pseudo-release for the final proposal. - - Other instances of ``AlbumInfo`` must come from other plugins, so we just check - if we intercepted them as candidates with pseudo-releases and store their - ``mapping``. This is needed because the real listeners we use never expose - information from the input ``Item``s, so we intercept that here. - - The paths from the items are used to figure out which pseudo-releases should be - provided for them, which is specially important for concurrent stage execution - where we might have already intercepted releases from different import tasks - when we run. """ if isinstance(album_info, PseudoAlbumInfo): @@ -349,25 +174,11 @@ class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin): new_mappings, _, _ = assign_items(items, album_info.tracks) mapping.update(new_mappings) - elif album_info.album_id in self._intercepted_candidates: - self._log.debug("Storing mapping for {0.album_id}", album_info) - self._intercepted_candidates[album_info.album_id].mapping = mapping - return super().album_distance(items, album_info, mapping) - def album_for_id(self, album_id: str) -> Optional[AlbumInfo]: - pass - - def track_for_id(self, track_id: str) -> Optional[TrackInfo]: - pass - - def item_candidates( - self, - item: Item, - artist: str, - title: str, - ) -> Iterable[TrackInfo]: - return [] + @override + def _extract_id(self, url: str) -> Optional[str]: + return extract_release_id("MusicBrainz", url) class PseudoAlbumInfo(AlbumInfo): @@ -398,6 +209,9 @@ class PseudoAlbumInfo(AlbumInfo): if k not in kwargs: self[k] = v + def get_official_release(self) -> AlbumInfo: + return self.__dict__["_official_release"] + def determine_best_ref(self, items: Sequence[Item]) -> str: self.use_pseudo_as_ref() pseudo_dist = self._compute_distance(items) @@ -429,6 +243,17 @@ class PseudoAlbumInfo(AlbumInfo): else: return self.__dict__["_official_release"].__getattr__(attr) + 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 + class ImmutableMapping(dict[Item, TrackInfo]): def __init__(self, *args, **kwargs): diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 8e259e94b..cd53c3156 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -323,7 +323,7 @@ def _find_actual_release_from_pseudo_release( def _merge_pseudo_and_actual_album( pseudo: beets.autotag.hooks.AlbumInfo, actual: beets.autotag.hooks.AlbumInfo -) -> beets.autotag.hooks.AlbumInfo | None: +) -> beets.autotag.hooks.AlbumInfo: """ Merges a pseudo release with its actual release. From a42cabb47701677de01e76535ff4415944a7f453 Mon Sep 17 00:00:00 2001 From: asardaes Date: Mon, 13 Oct 2025 16:04:44 -0600 Subject: [PATCH 07/13] Don't use Optional --- beetsplug/mbpseudo.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index d544a5624..faf6cc485 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -14,8 +14,10 @@ """Adds pseudo-releases from MusicBrainz as candidates during import.""" +from __future__ import annotations + from copy import deepcopy -from typing import Any, Iterable, Optional, Sequence +from typing import TYPE_CHECKING, Any, Iterable, Sequence import musicbrainzngs from typing_extensions import override @@ -23,16 +25,19 @@ from typing_extensions import override from beets.autotag.distance import Distance, distance from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.autotag.match import assign_items -from beets.library import Item from beets.plugins import find_plugins from beets.util.id_extractors import extract_release_id -from beetsplug._typing import JSONDict from beetsplug.musicbrainz import ( RELEASE_INCLUDES, MusicBrainzPlugin, _merge_pseudo_and_actual_album, ) +if TYPE_CHECKING: + from beets.autotag import AlbumMatch + from beets.library import Item + from beetsplug._typing import JSONDict + _STATUS_PSEUDO = "Pseudo-Release" @@ -128,7 +133,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): self, album_id: str, relation: JSONDict, - ) -> Optional[str]: + ) -> str | None: if ( len(self._scripts) == 0 or relation.get("type", "") != "transl-tracklisting" @@ -177,7 +182,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): return super().album_distance(items, album_info, mapping) @override - def _extract_id(self, url: str) -> Optional[str]: + def _extract_id(self, url: str) -> str | None: return extract_release_id("MusicBrainz", url) From 229651dcad0dd6d7aef10cfea3f9587b9667b94f Mon Sep 17 00:00:00 2001 From: asardaes Date: Mon, 13 Oct 2025 15:55:24 -0600 Subject: [PATCH 08/13] Update mbpseudo implementation for beets 2.5 --- beets/autotag/match.py | 9 +++- beets/plugins.py | 1 + beetsplug/mbpseudo.py | 94 +++++++++++++++++++++--------------------- 3 files changed, 56 insertions(+), 48 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 8fec844a6..d0f3fd134 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar import lap import numpy as np -from beets import config, logging, metadata_plugins +from beets import config, logging, metadata_plugins, plugins from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch, hooks from beets.util import get_most_common_tags @@ -274,12 +274,17 @@ def tag_album( log.debug("Searching for album ID: {}", search_id) if info := metadata_plugins.album_for_id(search_id): _add_candidate(items, candidates, info) + if opt_candidate := candidates.get(info.album_id): + plugins.send("album_matched", match=opt_candidate) # Use existing metadata or text search. else: # Try search based on current ID. if info := match_by_id(items): _add_candidate(items, candidates, info) + for candidate in candidates.values(): + plugins.send("album_matched", match=candidate) + rec = _recommendation(list(candidates.values())) log.debug("Album ID match recommendation is {}", rec) if candidates and not config["import"]["timid"]: @@ -313,6 +318,8 @@ def tag_album( items, search_artist, search_album, va_likely ): _add_candidate(items, candidates, matched_candidate) + if opt_candidate := candidates.get(matched_candidate.album_id): + plugins.send("album_matched", match=opt_candidate) log.debug("Evaluating {} candidates.", len(candidates)) # Sort and get the recommendation. diff --git a/beets/plugins.py b/beets/plugins.py index e10dcf80c..4fdad9807 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -72,6 +72,7 @@ EventType = Literal[ "album_imported", "album_removed", "albuminfo_received", + "album_matched", "before_choose_candidate", "before_item_moved", "cli_exit", diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index faf6cc485..8a07049b9 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -16,6 +16,7 @@ from __future__ import annotations +import traceback from copy import deepcopy from typing import TYPE_CHECKING, Any, Iterable, Sequence @@ -23,12 +24,13 @@ import musicbrainzngs from typing_extensions import override from beets.autotag.distance import Distance, distance -from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.autotag.hooks import AlbumInfo from beets.autotag.match import assign_items from beets.plugins import find_plugins from beets.util.id_extractors import extract_release_id from beetsplug.musicbrainz import ( RELEASE_INCLUDES, + MusicBrainzAPIError, MusicBrainzPlugin, _merge_pseudo_and_actual_album, ) @@ -50,6 +52,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): self._log.debug("Desired scripts: {0}", self._scripts) self.register_listener("pluginload", self._on_plugins_loaded) + self.register_listener("album_matched", self._adjust_final_album_match) # noinspection PyMethodMayBeStatic def _on_plugins_loaded(self): @@ -77,14 +80,15 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): items, artist, album, va_likely ): if isinstance(album_info, PseudoAlbumInfo): - yield album_info.get_official_release() self._log.debug( "Using {0} release for distance calculations for album {1}", album_info.determine_best_ref(items), album_info.album_id, ) - - yield album_info + yield album_info # first yield pseudo to give it priority + yield album_info.get_official_release() + else: + yield album_info @override def album_info(self, release: JSONDict) -> AlbumInfo: @@ -95,17 +99,27 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): return official_release elif pseudo_release_ids := self._intercept_mb_release(release): album_id = self._extract_id(pseudo_release_ids[0]) - raw_pseudo_release = musicbrainzngs.get_release_by_id( - album_id, RELEASE_INCLUDES - ) - pseudo_release = super().album_info(raw_pseudo_release["release"]) - return PseudoAlbumInfo( - pseudo_release=_merge_pseudo_and_actual_album( - pseudo_release, official_release - ), - official_release=official_release, - data_source=self.data_source, - ) + try: + raw_pseudo_release = musicbrainzngs.get_release_by_id( + album_id, RELEASE_INCLUDES + ) + pseudo_release = super().album_info( + raw_pseudo_release["release"] + ) + return PseudoAlbumInfo( + pseudo_release=_merge_pseudo_and_actual_album( + pseudo_release, official_release + ), + official_release=official_release, + data_source="MusicBrainz", + ) + except musicbrainzngs.MusicBrainzError as exc: + raise MusicBrainzAPIError( + exc, + "get pseudo-release by ID", + album_id, + traceback.format_exc(), + ) else: return official_release @@ -153,33 +167,19 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): else: return None - @override - def album_distance( - self, - items: Sequence[Item], - album_info: AlbumInfo, - mapping: dict[Item, TrackInfo], - ) -> Distance: - """We use this function more like a listener for the extra details we are - injecting. - - For instances of ``PseudoAlbumInfo`` whose corresponding ``mapping`` is _not_ an - instance of ``ImmutableMapping``, we know at this point that all penalties from - the normal auto-tagging flow have been applied, so we can switch to the metadata - from the pseudo-release for the final proposal. - """ - + def _adjust_final_album_match(self, match: AlbumMatch): + album_info = match.info if isinstance(album_info, PseudoAlbumInfo): - if not isinstance(mapping, ImmutableMapping): - self._log.debug( - "Switching {0} to pseudo-release source for final proposal", - album_info.album_id, - ) - album_info.use_pseudo_as_ref() - new_mappings, _, _ = assign_items(items, album_info.tracks) - mapping.update(new_mappings) - - return super().album_distance(items, album_info, mapping) + mapping = match.mapping + self._log.debug( + "Switching {0} to pseudo-release source for final proposal", + album_info.album_id, + ) + album_info.use_pseudo_as_ref() + new_mappings, _, _ = assign_items( + list(mapping.keys()), album_info.tracks + ) + mapping.update(new_mappings) @override def _extract_id(self, url: str) -> str | None: @@ -218,12 +218,17 @@ class PseudoAlbumInfo(AlbumInfo): return self.__dict__["_official_release"] def determine_best_ref(self, items: Sequence[Item]) -> str: + ds = self.data_source + self.data_source = None + self.use_pseudo_as_ref() pseudo_dist = self._compute_distance(items) self.use_official_as_ref() official_dist = self._compute_distance(items) + self.data_source = ds + if official_dist < pseudo_dist: self.use_official_as_ref() return "official" @@ -233,7 +238,7 @@ class PseudoAlbumInfo(AlbumInfo): def _compute_distance(self, items: Sequence[Item]) -> Distance: mapping, _, _ = assign_items(items, self.tracks) - return distance(items, self, ImmutableMapping(mapping)) + return distance(items, self, mapping) def use_pseudo_as_ref(self): self.__dict__["_pseudo_source"] = True @@ -258,8 +263,3 @@ class PseudoAlbumInfo(AlbumInfo): result[k] = deepcopy(v, memo) return result - - -class ImmutableMapping(dict[Item, TrackInfo]): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) From 160297b086d92b1a9e61f27fef1d0e5c1ad46153 Mon Sep 17 00:00:00 2001 From: asardaes Date: Mon, 13 Oct 2025 19:38:00 -0600 Subject: [PATCH 09/13] Add tests for mbpseudo plugin --- beetsplug/mbpseudo.py | 4 +- test/plugins/test_mbpseudo.py | 176 +++++ test/rsrc/mbpseudo/official_release.json | 841 +++++++++++++++++++++++ test/rsrc/mbpseudo/pseudo_release.json | 346 ++++++++++ 4 files changed, 1366 insertions(+), 1 deletion(-) create mode 100644 test/plugins/test_mbpseudo.py create mode 100644 test/rsrc/mbpseudo/official_release.json create mode 100644 test/rsrc/mbpseudo/pseudo_release.json diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 8a07049b9..bb12d4eae 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -47,6 +47,8 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): def __init__(self) -> None: super().__init__() + self._release_getter = musicbrainzngs.get_release_by_id + self.config.add({"scripts": []}) self._scripts = self.config["scripts"].as_str_seq() self._log.debug("Desired scripts: {0}", self._scripts) @@ -100,7 +102,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): elif pseudo_release_ids := self._intercept_mb_release(release): album_id = self._extract_id(pseudo_release_ids[0]) try: - raw_pseudo_release = musicbrainzngs.get_release_by_id( + raw_pseudo_release = self._release_getter( album_id, RELEASE_INCLUDES ) pseudo_release = super().album_info( diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py new file mode 100644 index 000000000..4a39a3952 --- /dev/null +++ b/test/plugins/test_mbpseudo.py @@ -0,0 +1,176 @@ +import json +import pathlib + +import pytest + +from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.library import Item +from beets.test.helper import PluginMixin +from beetsplug._typing import JSONDict +from beetsplug.mbpseudo import ( + _STATUS_PSEUDO, + MusicBrainzPseudoReleasePlugin, + PseudoAlbumInfo, +) + + +class TestPseudoAlbumInfo: + @pytest.fixture + def official_release(self) -> AlbumInfo: + return AlbumInfo( + tracks=[TrackInfo(title="百花繚乱")], + album_id="official", + album="百花繚乱", + ) + + @pytest.fixture + def pseudo_release(self) -> AlbumInfo: + return AlbumInfo( + tracks=[TrackInfo(title="In Bloom")], + album_id="pseudo", + album="In Bloom", + ) + + def test_album_id_always_from_pseudo( + self, official_release: AlbumInfo, pseudo_release: AlbumInfo + ): + info = PseudoAlbumInfo(pseudo_release, official_release) + info.use_official_as_ref() + assert info.album_id == "pseudo" + + def test_get_attr_from_pseudo( + self, official_release: AlbumInfo, pseudo_release: AlbumInfo + ): + info = PseudoAlbumInfo(pseudo_release, official_release) + assert info.album == "In Bloom" + + def test_get_attr_from_official( + self, official_release: AlbumInfo, pseudo_release: AlbumInfo + ): + info = PseudoAlbumInfo(pseudo_release, official_release) + info.use_official_as_ref() + assert info.album == info.get_official_release().album + + def test_determine_best_ref( + self, official_release: AlbumInfo, pseudo_release: AlbumInfo + ): + info = PseudoAlbumInfo( + pseudo_release, official_release, data_source="test" + ) + item = Item() + item["title"] = "百花繚乱" + + assert info.determine_best_ref([item]) == "official" + + info.use_pseudo_as_ref() + assert info.data_source == "test" + + +@pytest.fixture(scope="module") +def rsrc_dir(pytestconfig: pytest.Config): + return pytestconfig.rootpath / "test" / "rsrc" / "mbpseudo" + + +class TestMBPseudoPlugin(PluginMixin): + plugin = "mbpseudo" + + @pytest.fixture(scope="class") + def plugin_config(self): + return {"scripts": ["Latn", "Dummy"]} + + @pytest.fixture(scope="class") + def mbpseudo_plugin(self, plugin_config) -> MusicBrainzPseudoReleasePlugin: + self.config[self.plugin].set(plugin_config) + return MusicBrainzPseudoReleasePlugin() + + @pytest.fixture + def official_release(self, rsrc_dir: pathlib.Path) -> JSONDict: + info_json = (rsrc_dir / "official_release.json").read_text( + encoding="utf-8" + ) + return json.loads(info_json) + + @pytest.fixture + def pseudo_release(self, rsrc_dir: pathlib.Path) -> JSONDict: + info_json = (rsrc_dir / "pseudo_release.json").read_text( + encoding="utf-8" + ) + return json.loads(info_json) + + def test_scripts_init( + self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin + ): + assert mbpseudo_plugin._scripts == ["Latn", "Dummy"] + + @pytest.mark.parametrize( + "album_id", + [ + "a5ce1d11-2e32-45a4-b37f-c1589d46b103", + "-5ce1d11-2e32-45a4-b37f-c1589d46b103", + ], + ) + def test_extract_id_uses_music_brainz_pattern( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + album_id: str, + ): + if album_id.startswith("-"): + assert mbpseudo_plugin._extract_id(album_id) is None + else: + assert mbpseudo_plugin._extract_id(album_id) == album_id + + def test_album_info_for_pseudo_release( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + pseudo_release: JSONDict, + ): + album_info = mbpseudo_plugin.album_info(pseudo_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainz" + assert album_info.albumstatus == _STATUS_PSEUDO + + @pytest.mark.parametrize( + "json_key", + [ + "type", + "direction", + "release", + ], + ) + def test_interception_skip_when_rel_values_dont_match( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + json_key: str, + ): + del official_release["release"]["release-relation-list"][0][json_key] + + album_info = mbpseudo_plugin.album_info(official_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainz" + + def test_interception_skip_when_script_doesnt_match( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + ): + official_release["release"]["release-relation-list"][0]["release"][ + "text-representation" + ]["script"] = "Null" + + album_info = mbpseudo_plugin.album_info(official_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainz" + + def test_interception( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + pseudo_release: JSONDict, + ): + mbpseudo_plugin._release_getter = ( + lambda album_id, includes: pseudo_release + ) + album_info = mbpseudo_plugin.album_info(official_release["release"]) + assert isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainz" diff --git a/test/rsrc/mbpseudo/official_release.json b/test/rsrc/mbpseudo/official_release.json new file mode 100644 index 000000000..63f1d60dd --- /dev/null +++ b/test/rsrc/mbpseudo/official_release.json @@ -0,0 +1,841 @@ +{ + "release": { + "id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", + "title": "百花繚乱", + "status": "Official", + "quality": "normal", + "packaging": "None", + "text-representation": { + "language": "jpn", + "script": "Jpan" + }, + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "release-group": { + "id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1", + "type": "Single", + "title": "百花繚乱", + "first-release-date": "2025-01-10", + "primary-type": "Single", + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "artist-credit-phrase": "幾田りら" + }, + "date": "2025-01-10", + "country": "XW", + "release-event-list": [ + { + "date": "2025-01-10", + "area": { + "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", + "name": "[Worldwide]", + "sort-name": "[Worldwide]", + "iso-3166-1-code-list": [ + "XW" + ] + } + } + ], + "release-event-count": 1, + "barcode": "199066336168", + "asin": "B0DR8Y2YDC", + "cover-art-archive": { + "artwork": "true", + "count": "1", + "front": "true", + "back": "false" + }, + "label-info-list": [ + { + "catalog-number": "Lilas-020", + "label": { + "id": "157afde4-4bf5-4039-8ad2-5a15acc85176", + "type": "Production", + "name": "[no label]", + "sort-name": "[no label]", + "disambiguation": "Special purpose label – white labels, self-published releases and other “no label” releases", + "alias-list": [ + { + "sort-name": "2636621 Records DK", + "alias": "2636621 Records DK" + }, + { + "sort-name": "Auto production", + "type": "Search hint", + "alias": "Auto production" + }, + { + "sort-name": "Auto-Edición", + "type": "Search hint", + "alias": "Auto-Edición" + }, + { + "sort-name": "Auto-Product", + "type": "Search hint", + "alias": "Auto-Product" + }, + { + "sort-name": "Autoedición", + "type": "Search hint", + "alias": "Autoedición" + }, + { + "sort-name": "Autoeditado", + "type": "Search hint", + "alias": "Autoeditado" + }, + { + "sort-name": "Autoproduit", + "type": "Search hint", + "alias": "Autoproduit" + }, + { + "sort-name": "D.I.Y.", + "type": "Search hint", + "alias": "D.I.Y." + }, + { + "sort-name": "Demo", + "type": "Search hint", + "alias": "Demo" + }, + { + "sort-name": "DistroKid", + "type": "Search hint", + "alias": "DistroKid" + }, + { + "sort-name": "Eigenverlag", + "type": "Search hint", + "alias": "Eigenverlag" + }, + { + "sort-name": "Eigenvertrieb", + "type": "Search hint", + "alias": "Eigenvertrieb" + }, + { + "sort-name": "GRIND MODE", + "alias": "GRIND MODE" + }, + { + "sort-name": "INDIPENDANT", + "type": "Search hint", + "alias": "INDIPENDANT" + }, + { + "sort-name": "Indepandant", + "type": "Search hint", + "alias": "Indepandant" + }, + { + "sort-name": "Independant release", + "type": "Search hint", + "alias": "Independant release" + }, + { + "sort-name": "Independent", + "type": "Search hint", + "alias": "Independent" + }, + { + "sort-name": "Independente", + "type": "Search hint", + "alias": "Independente" + }, + { + "sort-name": "Independiente", + "type": "Search hint", + "alias": "Independiente" + }, + { + "sort-name": "Indie", + "type": "Search hint", + "alias": "Indie" + }, + { + "sort-name": "Joost Klein", + "alias": "Joost Klein" + }, + { + "sort-name": "MoroseSound", + "alias": "MoroseSound" + }, + { + "sort-name": "N/A", + "type": "Search hint", + "alias": "N/A" + }, + { + "sort-name": "No Label", + "type": "Search hint", + "alias": "No Label" + }, + { + "sort-name": "None", + "type": "Search hint", + "alias": "None" + }, + { + "sort-name": "Not On A Lebel", + "type": "Search hint", + "alias": "Not On A Lebel" + }, + { + "sort-name": "Not On Label", + "type": "Search hint", + "alias": "Not On Label" + }, + { + "sort-name": "P2019", + "alias": "P2019" + }, + { + "sort-name": "P2020", + "alias": "P2020" + }, + { + "sort-name": "P2021", + "alias": "P2021" + }, + { + "sort-name": "P2022", + "alias": "P2022" + }, + { + "sort-name": "P2023", + "alias": "P2023" + }, + { + "sort-name": "P2024", + "alias": "P2024" + }, + { + "sort-name": "P2025", + "alias": "P2025" + }, + { + "sort-name": "Records DK", + "type": "Search hint", + "alias": "Records DK" + }, + { + "sort-name": "Self Digital", + "type": "Search hint", + "alias": "Self Digital" + }, + { + "sort-name": "Self Release", + "type": "Search hint", + "alias": "Self Release" + }, + { + "sort-name": "Self Released", + "type": "Search hint", + "alias": "Self Released" + }, + { + "sort-name": "Self-release", + "type": "Search hint", + "alias": "Self-release" + }, + { + "sort-name": "Self-released", + "type": "Search hint", + "alias": "Self-released" + }, + { + "sort-name": "Self-released/independent", + "type": "Search hint", + "alias": "Self-released/independent" + }, + { + "sort-name": "Sevdaliza", + "alias": "Sevdaliza" + }, + { + "sort-name": "TOMMY CASH", + "alias": "TOMMY CASH" + }, + { + "sort-name": "Talwiinder", + "alias": "Talwiinder" + }, + { + "sort-name": "Unsigned", + "type": "Search hint", + "alias": "Unsigned" + }, + { + "locale": "fi", + "sort-name": "ei levymerkkiä", + "type": "Label name", + "primary": "primary", + "alias": "[ei levymerkkiä]" + }, + { + "locale": "nl", + "sort-name": "[geen platenmaatschappij]", + "type": "Label name", + "primary": "primary", + "alias": "[geen platenmaatschappij]" + }, + { + "locale": "et", + "sort-name": "[ilma plaadifirmata]", + "type": "Label name", + "alias": "[ilma plaadifirmata]" + }, + { + "locale": "es", + "sort-name": "[nada]", + "type": "Label name", + "primary": "primary", + "alias": "[nada]" + }, + { + "locale": "en", + "sort-name": "[no label]", + "type": "Label name", + "primary": "primary", + "alias": "[no label]" + }, + { + "sort-name": "[nolabel]", + "type": "Search hint", + "alias": "[nolabel]" + }, + { + "sort-name": "[none]", + "type": "Search hint", + "alias": "[none]" + }, + { + "locale": "lt", + "sort-name": "[nėra leidybinės kompanijos]", + "type": "Label name", + "alias": "[nėra leidybinės kompanijos]" + }, + { + "locale": "lt", + "sort-name": "[nėra leidyklos]", + "type": "Label name", + "alias": "[nėra leidyklos]" + }, + { + "locale": "lt", + "sort-name": "[nėra įrašų kompanijos]", + "type": "Label name", + "primary": "primary", + "alias": "[nėra įrašų kompanijos]" + }, + { + "locale": "et", + "sort-name": "[puudub]", + "type": "Label name", + "alias": "[puudub]" + }, + { + "locale": "ru", + "sort-name": "samizdat", + "type": "Label name", + "alias": "[самиздат]" + }, + { + "locale": "ja", + "sort-name": "[レーベルなし]", + "type": "Label name", + "primary": "primary", + "alias": "[レーベルなし]" + }, + { + "sort-name": "auto-release", + "type": "Search hint", + "alias": "auto-release" + }, + { + "sort-name": "autoprod.", + "type": "Search hint", + "alias": "autoprod." + }, + { + "sort-name": "blank", + "type": "Search hint", + "alias": "blank" + }, + { + "sort-name": "d.silvestre", + "alias": "d.silvestre" + }, + { + "sort-name": "independent release", + "type": "Search hint", + "alias": "independent release" + }, + { + "sort-name": "nyamura", + "alias": "nyamura" + }, + { + "sort-name": "pls dnt stp", + "alias": "pls dnt stp" + }, + { + "sort-name": "self", + "type": "Search hint", + "alias": "self" + }, + { + "sort-name": "self issued", + "type": "Search hint", + "alias": "self issued" + }, + { + "sort-name": "self-issued", + "type": "Search hint", + "alias": "self-issued" + }, + { + "sort-name": "white label", + "type": "Search hint", + "alias": "white label" + }, + { + "sort-name": "но лабел", + "type": "Search hint", + "alias": "но лабел" + }, + { + "sort-name": "独立发行", + "type": "Search hint", + "alias": "独立发行" + } + ], + "alias-count": 71, + "tag-list": [ + { + "count": "12", + "name": "special purpose" + }, + { + "count": "18", + "name": "special purpose label" + } + ] + } + } + ], + "label-info-count": 1, + "medium-list": [ + { + "position": "1", + "format": "Digital Media", + "track-list": [ + { + "id": "0bd01e8b-18e1-4708-b0a3-c9603b89ab97", + "position": "1", + "number": "1", + "length": "179239", + "recording": { + "id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e", + "title": "百花繚乱", + "length": "179546", + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "isrc-list": [ + "JPP302400868" + ], + "isrc-count": 1, + "artist-relation-list": [ + { + "type": "arranger", + "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d", + "target": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", + "direction": "backward", + "artist": { + "id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", + "type": "Person", + "name": "KOHD", + "sort-name": "KOHD", + "country": "JP", + "disambiguation": "Japanese composer/arranger/guitarist, agehasprings" + } + }, + { + "type": "phonographic copyright", + "type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "begin": "2025", + "end": "2025", + "ended": "true", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + }, + "target-credit": "Lilas Ikuta" + }, + { + "type": "producer", + "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", + "target": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", + "direction": "backward", + "artist": { + "id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", + "type": "Person", + "name": "山本秀哉", + "sort-name": "Yamamoto, Shuya", + "country": "JP" + } + }, + { + "type": "vocal", + "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "work-relation-list": [ + { + "type": "performance", + "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", + "target": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", + "direction": "forward", + "work": { + "id": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", + "type": "Song", + "title": "百花繚乱", + "language": "jpn", + "artist-relation-list": [ + { + "type": "composer", + "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + }, + { + "type": "lyricist", + "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "url-relation-list": [ + { + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + "target": "https://utaten.com/lyric/tt24121002/", + "direction": "backward" + }, + { + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + "target": "https://www.uta-net.com/song/366579/", + "direction": "backward" + } + ] + } + } + ], + "artist-credit-phrase": "幾田りら" + }, + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "artist-credit-phrase": "幾田りら", + "track_or_recording_length": "179239" + } + ], + "track-count": 1 + } + ], + "medium-count": 1, + "artist-relation-list": [ + { + "type": "copyright", + "type-id": "730b5251-7432-4896-8fc6-e1cba943bfe1", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "begin": "2025", + "end": "2025", + "ended": "true", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + }, + "target-credit": "Lilas Ikuta" + }, + { + "type": "phonographic copyright", + "type-id": "01d3488d-8d2a-4cff-9226-5250404db4dc", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "begin": "2025", + "end": "2025", + "ended": "true", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + }, + "target-credit": "Lilas Ikuta" + } + ], + "release-relation-list": [ + { + "type": "transl-tracklisting", + "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644", + "target": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", + "direction": "forward", + "release": { + "id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", + "title": "In Bloom", + "quality": "normal", + "text-representation": { + "language": "eng", + "script": "Latn" + }, + "artist-credit": [ + { + "name": "Lilas Ikuta", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "medium-list": [], + "medium-count": 0, + "artist-credit-phrase": "Lilas Ikuta" + } + } + ], + "url-relation-list": [ + { + "type": "amazon asin", + "type-id": "4f2e710d-166c-480c-a293-2e2c8d658d87", + "target": "https://www.amazon.co.jp/gp/product/B0DR8Y2YDC", + "direction": "forward" + }, + { + "type": "free streaming", + "type-id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", + "target": "https://open.spotify.com/album/3LDV2xGL9HiqCsQujEPQLb", + "direction": "forward" + }, + { + "type": "free streaming", + "type-id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", + "target": "https://www.deezer.com/album/687686261", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://mora.jp/package/43000011/199066336168/", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://mora.jp/package/43000011/199066336168_HD/", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://mora.jp/package/43000011/199066336168_LL/", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://music.apple.com/jp/album/1786972161", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://ototoy.jp/_/default/p/2501951", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/fl9tx2j78reza", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/l1dnc4xoi6l7a", + "direction": "forward" + }, + { + "type": "streaming", + "type-id": "320adf26-96fa-4183-9045-1f5f32f833cb", + "target": "https://music.amazon.co.jp/albums/B0DR8Y2YDC", + "direction": "forward" + }, + { + "type": "streaming", + "type-id": "320adf26-96fa-4183-9045-1f5f32f833cb", + "target": "https://music.apple.com/jp/album/1786972161", + "direction": "forward" + }, + { + "type": "vgmdb", + "type-id": "6af0134a-df6a-425a-96e2-895f9cd342ba", + "target": "https://vgmdb.net/album/145936", + "direction": "forward" + } + ], + "artist-credit-phrase": "幾田りら" + } +} diff --git a/test/rsrc/mbpseudo/pseudo_release.json b/test/rsrc/mbpseudo/pseudo_release.json new file mode 100644 index 000000000..99fa0b417 --- /dev/null +++ b/test/rsrc/mbpseudo/pseudo_release.json @@ -0,0 +1,346 @@ +{ + "release": { + "id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", + "title": "In Bloom", + "status": "Pseudo-Release", + "quality": "normal", + "text-representation": { + "language": "eng", + "script": "Latn" + }, + "artist-credit": [ + { + "name": "Lilas Ikuta", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "release-group": { + "id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1", + "type": "Single", + "title": "百花繚乱", + "first-release-date": "2025-01-10", + "primary-type": "Single", + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "artist-credit-phrase": "幾田りら" + }, + "cover-art-archive": { + "artwork": "false", + "count": "0", + "front": "false", + "back": "false" + }, + "label-info-list": [], + "label-info-count": 0, + "medium-list": [ + { + "position": "1", + "format": "Digital Media", + "track-list": [ + { + "id": "2018b012-a184-49a2-a464-fb4628a89588", + "position": "1", + "number": "1", + "title": "In Bloom", + "length": "179239", + "artist-credit": [ + { + "name": "Lilas Ikuta", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "recording": { + "id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e", + "title": "百花繚乱", + "length": "179546", + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP", + "alias-list": [ + { + "locale": "en", + "sort-name": "Ikuta, Lilas", + "type": "Artist name", + "primary": "primary", + "alias": "Lilas Ikuta" + } + ], + "alias-count": 1, + "tag-list": [ + { + "count": "1", + "name": "j-pop" + }, + { + "count": "1", + "name": "singer-songwriter" + } + ] + } + } + ], + "isrc-list": [ + "JPP302400868" + ], + "isrc-count": 1, + "artist-relation-list": [ + { + "type": "arranger", + "type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d", + "target": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", + "direction": "backward", + "artist": { + "id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025", + "type": "Person", + "name": "KOHD", + "sort-name": "KOHD", + "country": "JP", + "disambiguation": "Japanese composer/arranger/guitarist, agehasprings" + } + }, + { + "type": "phonographic copyright", + "type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "begin": "2025", + "end": "2025", + "ended": "true", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + }, + "target-credit": "Lilas Ikuta" + }, + { + "type": "producer", + "type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0", + "target": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", + "direction": "backward", + "artist": { + "id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05", + "type": "Person", + "name": "山本秀哉", + "sort-name": "Yamamoto, Shuya", + "country": "JP" + } + }, + { + "type": "vocal", + "type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "work-relation-list": [ + { + "type": "performance", + "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", + "target": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", + "direction": "forward", + "work": { + "id": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed", + "type": "Song", + "title": "百花繚乱", + "language": "jpn", + "artist-relation-list": [ + { + "type": "composer", + "type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + }, + { + "type": "lyricist", + "type-id": "3e48faba-ec01-47fd-8e89-30e81161661c", + "target": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "direction": "backward", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "type": "Person", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "url-relation-list": [ + { + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + "target": "https://utaten.com/lyric/tt24121002/", + "direction": "backward" + }, + { + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + "target": "https://www.uta-net.com/song/366579/", + "direction": "backward" + } + ] + } + } + ], + "artist-credit-phrase": "幾田りら" + }, + "artist-credit-phrase": "Lilas Ikuta", + "track_or_recording_length": "179239" + } + ], + "track-count": 1 + } + ], + "medium-count": 1, + "release-relation-list": [ + { + "type": "transl-tracklisting", + "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644", + "target": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", + "direction": "backward", + "release": { + "id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", + "title": "百花繚乱", + "quality": "normal", + "text-representation": { + "language": "jpn", + "script": "Jpan" + }, + "artist-credit": [ + { + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "date": "2025-01-10", + "country": "XW", + "release-event-list": [ + { + "date": "2025-01-10", + "area": { + "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", + "name": "[Worldwide]", + "sort-name": "[Worldwide]", + "iso-3166-1-code-list": [ + "XW" + ] + } + } + ], + "release-event-count": 1, + "barcode": "199066336168", + "medium-list": [], + "medium-count": 0, + "artist-credit-phrase": "幾田りら" + } + } + ], + "artist-credit-phrase": "Lilas Ikuta" + } +} \ No newline at end of file From defc60231034902d83c0e51449f8eecf82235c43 Mon Sep 17 00:00:00 2001 From: asardaes Date: Mon, 13 Oct 2025 17:23:22 -0600 Subject: [PATCH 10/13] Update docs for mbpseudo plugin --- docs/changelog.rst | 8 ++++++ docs/dev/plugins/events.rst | 7 +++++ docs/plugins/index.rst | 4 +++ docs/plugins/mbpseudo.rst | 56 +++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 docs/plugins/mbpseudo.rst diff --git a/docs/changelog.rst b/docs/changelog.rst index e192259b1..5ebf3f53e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,8 @@ New features: to receive extra verbose logging around last.fm results and how they are resolved. The ``extended_debug`` config setting and ``--debug`` option have been removed. +- :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive + MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. Bug fixes: @@ -28,6 +30,12 @@ Bug fixes: features for all remaining tracks in the session, avoiding unnecessary API calls and rate limit exhaustion. +For plugin developers: + +- A new plugin event, ``album_matched``, is sent when an album that is being + imported has been matched to its metadata and the corresponding distance has + been calculated. + For packagers: Other changes: diff --git a/docs/dev/plugins/events.rst b/docs/dev/plugins/events.rst index 68773db3b..aaab9ccd7 100644 --- a/docs/dev/plugins/events.rst +++ b/docs/dev/plugins/events.rst @@ -178,6 +178,13 @@ registration process in this case: :Parameters: ``info`` (|AlbumInfo|) :Description: Like ``trackinfo_received`` but for album-level metadata. +``album_matched`` + :Parameters: ``match`` (``AlbumMatch``) + :Description: Called after ``Item`` objects from a folder that's being + imported have been matched to an ``AlbumInfo`` and the corresponding + distance has been calculated. Missing and extra tracks, if any, are + included in the match. + ``before_choose_candidate`` :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called before prompting the user during interactive import. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index d1590504d..c211616e4 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -102,6 +102,7 @@ databases. They share the following configuration options: loadext lyrics mbcollection + mbpseudo mbsubmit mbsync metasync @@ -153,6 +154,9 @@ Autotagger Extensions :doc:`musicbrainz ` Search for releases in the MusicBrainz_ database. +:doc:`mbpseudo ` + Search for releases and pseudo-releases in the MusicBrainz_ database. + :doc:`spotify ` Search for releases in the Spotify_ database. diff --git a/docs/plugins/mbpseudo.rst b/docs/plugins/mbpseudo.rst new file mode 100644 index 000000000..ad718eef1 --- /dev/null +++ b/docs/plugins/mbpseudo.rst @@ -0,0 +1,56 @@ +MusicBrainz Pseudo-Release Plugin +================================= + +The `mbpseudo` plugin can be used *instead of* the `musicbrainz` plugin to +search for MusicBrainz pseudo-releases_ during the import process, which are +added to the normal candidates from the MusicBrainz search. + +.. _pseudo-releases: https://musicbrainz.org/doc/Style/Specific_types_of_releases/Pseudo-Releases + +This is useful for releases whose title and track titles are written with a +script_ that can be translated or transliterated into a different one. + +.. _script: https://en.wikipedia.org/wiki/ISO_15924 + +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. + +Configuration +------------- + +Since this plugin first searches for official releases from MusicBrainz, most +options from the `musicbrainz` plugin's :ref:`musicbrainz-config` are supported, +but they must be specified under `mbpseudo` in the configuration file. +Additionally, the configuration expects an array of scripts that are desired for +the pseudo-releases. Therefore, the minimum configuration for this plugin looks +like this: + +.. code-block:: yaml + + plugins: mbpseudo # remove musicbrainz + + mbpseudo: + scripts: + - Latn + +Note that the `search_limit` configuration applies to the initial search for +official releases, and that the `data_source` in the database will be +"MusicBrainz". Because of this, the only configuration that must remain under +`musicbrainz` is `data_source_mismatch_penalty` (see also +:ref:`metadata-source-plugin-configuration`). An example with multiple data +sources may look like this: + +.. code-block:: yaml + + plugins: mbpseudo deezer + + mbpseudo: + scripts: + - Latn + + musicbrainz: + data_source_mismatch_penalty: 0 + + deezer: + data_source_mismatch_penalty: 0.5 From cb758988ed6cc71e37d5ddf15145d1208b1df40a Mon Sep 17 00:00:00 2001 From: asardaes Date: Tue, 14 Oct 2025 12:09:42 -0600 Subject: [PATCH 11/13] Fix data source penalty for mbpseudo --- beetsplug/mbpseudo.py | 12 ++-- docs/plugins/mbpseudo.rst | 12 ++-- test/plugins/test_mbpseudo.py | 105 +++++++++++++++++++++++++--------- 3 files changed, 86 insertions(+), 43 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index bb12d4eae..8aca07366 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -95,7 +95,6 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): @override def album_info(self, release: JSONDict) -> AlbumInfo: official_release = super().album_info(release) - official_release.data_source = "MusicBrainz" if release.get("status") == _STATUS_PSEUDO: return official_release @@ -113,7 +112,6 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): pseudo_release, official_release ), official_release=official_release, - data_source="MusicBrainz", ) except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError( @@ -172,17 +170,20 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): def _adjust_final_album_match(self, match: AlbumMatch): album_info = match.info if isinstance(album_info, PseudoAlbumInfo): - mapping = match.mapping self._log.debug( "Switching {0} to pseudo-release source for final proposal", album_info.album_id, ) album_info.use_pseudo_as_ref() + mapping = match.mapping new_mappings, _, _ = assign_items( list(mapping.keys()), album_info.tracks ) mapping.update(new_mappings) + if album_info.data_source == self.data_source: + album_info.data_source = "MusicBrainz" + @override def _extract_id(self, url: str) -> str | None: return extract_release_id("MusicBrainz", url) @@ -220,17 +221,12 @@ class PseudoAlbumInfo(AlbumInfo): return self.__dict__["_official_release"] def determine_best_ref(self, items: Sequence[Item]) -> str: - ds = self.data_source - self.data_source = None - self.use_pseudo_as_ref() pseudo_dist = self._compute_distance(items) self.use_official_as_ref() official_dist = self._compute_distance(items) - self.data_source = ds - if official_dist < pseudo_dist: self.use_official_as_ref() return "official" diff --git a/docs/plugins/mbpseudo.rst b/docs/plugins/mbpseudo.rst index ad718eef1..186cb5a6f 100644 --- a/docs/plugins/mbpseudo.rst +++ b/docs/plugins/mbpseudo.rst @@ -19,7 +19,7 @@ pseudo-releases with desired scripts. Configuration ------------- -Since this plugin first searches for official releases from MusicBrainz, most +Since this plugin first searches for official releases from MusicBrainz, all options from the `musicbrainz` plugin's :ref:`musicbrainz-config` are supported, but they must be specified under `mbpseudo` in the configuration file. Additionally, the configuration expects an array of scripts that are desired for @@ -36,8 +36,8 @@ like this: Note that the `search_limit` configuration applies to the initial search for official releases, and that the `data_source` in the database will be -"MusicBrainz". Because of this, the only configuration that must remain under -`musicbrainz` is `data_source_mismatch_penalty` (see also +"MusicBrainz". Nevertheless, `data_source_mismatch_penalty` must also be +specified under `mbpseudo` (see also :ref:`metadata-source-plugin-configuration`). An example with multiple data sources may look like this: @@ -46,11 +46,9 @@ sources may look like this: plugins: mbpseudo deezer mbpseudo: + data_source_mismatch_penalty: 0 scripts: - Latn - musicbrainz: - data_source_mismatch_penalty: 0 - deezer: - data_source_mismatch_penalty: 0.5 + data_source_mismatch_penalty: 0.2 diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py index 4a39a3952..b40bdbcc9 100644 --- a/test/plugins/test_mbpseudo.py +++ b/test/plugins/test_mbpseudo.py @@ -3,6 +3,8 @@ import pathlib import pytest +from beets.autotag import AlbumMatch +from beets.autotag.distance import Distance from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.library import Item from beets.test.helper import PluginMixin @@ -14,48 +16,50 @@ from beetsplug.mbpseudo import ( ) +@pytest.fixture(scope="module") +def official_release_info() -> AlbumInfo: + return AlbumInfo( + tracks=[TrackInfo(title="百花繚乱")], + album_id="official", + album="百花繚乱", + ) + + +@pytest.fixture(scope="module") +def pseudo_release_info() -> AlbumInfo: + return AlbumInfo( + tracks=[TrackInfo(title="In Bloom")], + album_id="pseudo", + album="In Bloom", + ) + + class TestPseudoAlbumInfo: - @pytest.fixture - def official_release(self) -> AlbumInfo: - return AlbumInfo( - tracks=[TrackInfo(title="百花繚乱")], - album_id="official", - album="百花繚乱", - ) - - @pytest.fixture - def pseudo_release(self) -> AlbumInfo: - return AlbumInfo( - tracks=[TrackInfo(title="In Bloom")], - album_id="pseudo", - album="In Bloom", - ) - def test_album_id_always_from_pseudo( - self, official_release: AlbumInfo, pseudo_release: AlbumInfo + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo ): - info = PseudoAlbumInfo(pseudo_release, official_release) + info = PseudoAlbumInfo(pseudo_release_info, official_release_info) info.use_official_as_ref() assert info.album_id == "pseudo" def test_get_attr_from_pseudo( - self, official_release: AlbumInfo, pseudo_release: AlbumInfo + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo ): - info = PseudoAlbumInfo(pseudo_release, official_release) + info = PseudoAlbumInfo(pseudo_release_info, official_release_info) assert info.album == "In Bloom" def test_get_attr_from_official( - self, official_release: AlbumInfo, pseudo_release: AlbumInfo + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo ): - info = PseudoAlbumInfo(pseudo_release, official_release) + info = PseudoAlbumInfo(pseudo_release_info, official_release_info) info.use_official_as_ref() assert info.album == info.get_official_release().album def test_determine_best_ref( - self, official_release: AlbumInfo, pseudo_release: AlbumInfo + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo ): info = PseudoAlbumInfo( - pseudo_release, official_release, data_source="test" + pseudo_release_info, official_release_info, data_source="test" ) item = Item() item["title"] = "百花繚乱" @@ -126,7 +130,7 @@ class TestMBPseudoPlugin(PluginMixin): ): album_info = mbpseudo_plugin.album_info(pseudo_release["release"]) assert not isinstance(album_info, PseudoAlbumInfo) - assert album_info.data_source == "MusicBrainz" + assert album_info.data_source == "MusicBrainzPseudoRelease" assert album_info.albumstatus == _STATUS_PSEUDO @pytest.mark.parametrize( @@ -147,7 +151,7 @@ class TestMBPseudoPlugin(PluginMixin): album_info = mbpseudo_plugin.album_info(official_release["release"]) assert not isinstance(album_info, PseudoAlbumInfo) - assert album_info.data_source == "MusicBrainz" + assert album_info.data_source == "MusicBrainzPseudoRelease" def test_interception_skip_when_script_doesnt_match( self, @@ -160,7 +164,7 @@ class TestMBPseudoPlugin(PluginMixin): album_info = mbpseudo_plugin.album_info(official_release["release"]) assert not isinstance(album_info, PseudoAlbumInfo) - assert album_info.data_source == "MusicBrainz" + assert album_info.data_source == "MusicBrainzPseudoRelease" def test_interception( self, @@ -173,4 +177,49 @@ class TestMBPseudoPlugin(PluginMixin): ) album_info = mbpseudo_plugin.album_info(official_release["release"]) assert isinstance(album_info, PseudoAlbumInfo) - assert album_info.data_source == "MusicBrainz" + assert album_info.data_source == "MusicBrainzPseudoRelease" + + def test_final_adjustment_skip( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + ): + match = AlbumMatch( + distance=Distance(), + info=AlbumInfo(tracks=[], data_source="mb"), + mapping={}, + extra_items=[], + extra_tracks=[], + ) + + mbpseudo_plugin._adjust_final_album_match(match) + assert match.info.data_source == "mb" + + def test_final_adjustment( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release_info: AlbumInfo, + pseudo_release_info: AlbumInfo, + ): + pseudo_album_info = PseudoAlbumInfo( + pseudo_release=pseudo_release_info, + official_release=official_release_info, + data_source=mbpseudo_plugin.data_source, + ) + pseudo_album_info.use_official_as_ref() + + item = Item() + item["title"] = "百花繚乱" + + match = AlbumMatch( + distance=Distance(), + info=pseudo_album_info, + mapping={item: pseudo_album_info.tracks[0]}, + extra_items=[], + extra_tracks=[], + ) + + mbpseudo_plugin._adjust_final_album_match(match) + + assert match.info.data_source == "MusicBrainz" + assert match.info.album_id == "pseudo" + assert match.info.album == "In Bloom" From 040b2dd940ff5db03496e615626268bcc638e052 Mon Sep 17 00:00:00 2001 From: asardaes Date: Mon, 20 Oct 2025 14:05:28 -0600 Subject: [PATCH 12/13] Add custom_tags_only mode for mbpseudo plugin --- beetsplug/mbpseudo.py | 76 +++++++++++++++++++++++++++++++---- docs/plugins/mbpseudo.rst | 55 +++++++++++++++++++++++-- test/plugins/test_mbpseudo.py | 42 +++++++++++++++++++ 3 files changed, 163 insertions(+), 10 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 8aca07366..e55847f81 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -20,6 +20,7 @@ import traceback from copy import deepcopy from typing import TYPE_CHECKING, Any, Iterable, Sequence +import mediafile import musicbrainzngs from typing_extensions import override @@ -49,10 +50,49 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): self._release_getter = musicbrainzngs.get_release_by_id - self.config.add({"scripts": []}) + self.config.add( + { + "scripts": [], + "custom_tags_only": False, + "album_custom_tags": { + "album_transl": "album", + "album_artist_transl": "artist", + }, + "track_custom_tags": { + "title_transl": "title", + "artist_transl": "artist", + }, + } + ) + self._scripts = self.config["scripts"].as_str_seq() self._log.debug("Desired scripts: {0}", self._scripts) + album_custom_tags = self.config["album_custom_tags"].get().keys() + track_custom_tags = self.config["track_custom_tags"].get().keys() + self._log.debug( + "Custom tags for albums and tracks: {0} + {1}", + album_custom_tags, + track_custom_tags, + ) + for custom_tag in album_custom_tags | track_custom_tags: + if not isinstance(custom_tag, str): + continue + + media_field = mediafile.MediaField( + mediafile.MP3DescStorageStyle(custom_tag), + mediafile.MP4StorageStyle( + f"----:com.apple.iTunes:{custom_tag}" + ), + mediafile.StorageStyle(custom_tag), + mediafile.ASFStorageStyle(custom_tag), + ) + try: + self.add_media_field(custom_tag, media_field) + except ValueError: + # ignore errors due to duplicates + pass + self.register_listener("pluginload", self._on_plugins_loaded) self.register_listener("album_matched", self._adjust_final_album_match) @@ -107,12 +147,17 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): pseudo_release = super().album_info( raw_pseudo_release["release"] ) - return PseudoAlbumInfo( - pseudo_release=_merge_pseudo_and_actual_album( - pseudo_release, official_release - ), - official_release=official_release, - ) + + if self.config["custom_tags_only"].get(bool): + self._add_custom_tags(official_release, pseudo_release) + return official_release + else: + return PseudoAlbumInfo( + pseudo_release=_merge_pseudo_and_actual_album( + pseudo_release, official_release + ), + official_release=official_release, + ) except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError( exc, @@ -167,6 +212,23 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): else: return None + def _add_custom_tags( + self, + official_release: AlbumInfo, + pseudo_release: AlbumInfo, + ): + for tag_key, pseudo_key in ( + self.config["album_custom_tags"].get().items() + ): + official_release[tag_key] = pseudo_release[pseudo_key] + + track_custom_tags = self.config["track_custom_tags"].get().items() + for track, pseudo_track in zip( + official_release.tracks, pseudo_release.tracks + ): + for tag_key, pseudo_key in track_custom_tags: + track[tag_key] = pseudo_track[pseudo_key] + def _adjust_final_album_match(self, match: AlbumMatch): album_info = match.info if isinstance(album_info, PseudoAlbumInfo): diff --git a/docs/plugins/mbpseudo.rst b/docs/plugins/mbpseudo.rst index 186cb5a6f..56658db26 100644 --- a/docs/plugins/mbpseudo.rst +++ b/docs/plugins/mbpseudo.rst @@ -23,13 +23,18 @@ Since this plugin first searches for official releases from MusicBrainz, all options from the `musicbrainz` plugin's :ref:`musicbrainz-config` are supported, but they must be specified under `mbpseudo` in the configuration file. Additionally, the configuration expects an array of scripts that are desired for -the pseudo-releases. Therefore, the minimum configuration for this plugin looks -like this: +the pseudo-releases. For ``artist`` in particular, keep in mind that even +pseudo-releases might specify it with the original script, so you should also +configure import :ref:`languages` to give artist aliases more priority. +Therefore, the minimum configuration for this plugin looks like this: .. code-block:: yaml plugins: mbpseudo # remove musicbrainz + import: + languages: en + mbpseudo: scripts: - Latn @@ -37,7 +42,7 @@ like this: Note that the `search_limit` configuration applies to the initial search for official releases, and that the `data_source` in the database will be "MusicBrainz". Nevertheless, `data_source_mismatch_penalty` must also be -specified under `mbpseudo` (see also +specified under `mbpseudo` if desired (see also :ref:`metadata-source-plugin-configuration`). An example with multiple data sources may look like this: @@ -45,6 +50,9 @@ sources may look like this: plugins: mbpseudo deezer + import: + languages: en + mbpseudo: data_source_mismatch_penalty: 0 scripts: @@ -52,3 +60,44 @@ sources may look like this: deezer: data_source_mismatch_penalty: 0.2 + +By default, the data from the pseudo-release will be used to create a proposal +that is independent from the official release and sets all properties in its +metadata. It's possible to change the configuration so that some information +from the pseudo-release is instead added as custom tags, keeping the metadata +from the official release: + +.. code-block:: yaml + + mbpseudo: + # other config not shown + custom_tags_only: yes + +The default custom tags with this configuration are specified as mappings where +the keys define the tag names and the values define the pseudo-release property +that will be used to set the tag's value: + +.. code-block:: yaml + + mbpseudo: + album_custom_tags: + album_transl: album + album_artist_transl: artist + track_custom_tags: + title_transl: title + artist_transl: artist + +Note that the information for each set of custom tags corresponds to different +metadata levels (album or track level), which is why ``artist`` appears twice +even though it effectively references album artist and track artist +respectively. + +If you want to modify any mapping under ``album_custom_tags`` or +``track_custom_tags``, you must specify *everything* for that set of tags in +your configuration file because any customization replaces the whole dictionary +of mappings for that level. + +.. note:: + + These custom tags are also added to the music files, not only to the + database. diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py index b40bdbcc9..8046dd0e6 100644 --- a/test/plugins/test_mbpseudo.py +++ b/test/plugins/test_mbpseudo.py @@ -223,3 +223,45 @@ class TestMBPseudoPlugin(PluginMixin): assert match.info.data_source == "MusicBrainz" assert match.info.album_id == "pseudo" assert match.info.album == "In Bloom" + + +class TestMBPseudoPluginCustomTagsOnly(PluginMixin): + plugin = "mbpseudo" + + @pytest.fixture(scope="class") + def mbpseudo_plugin(self) -> MusicBrainzPseudoReleasePlugin: + self.config["import"]["languages"] = ["en", "jp"] + self.config[self.plugin]["scripts"] = ["Latn"] + self.config[self.plugin]["custom_tags_only"] = True + return MusicBrainzPseudoReleasePlugin() + + @pytest.fixture(scope="class") + def official_release(self, rsrc_dir: pathlib.Path) -> JSONDict: + info_json = (rsrc_dir / "official_release.json").read_text( + encoding="utf-8" + ) + return json.loads(info_json) + + @pytest.fixture(scope="class") + def pseudo_release(self, rsrc_dir: pathlib.Path) -> JSONDict: + info_json = (rsrc_dir / "pseudo_release.json").read_text( + encoding="utf-8" + ) + return json.loads(info_json) + + def test_custom_tags( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + pseudo_release: JSONDict, + ): + mbpseudo_plugin._release_getter = ( + lambda album_id, includes: pseudo_release + ) + album_info = mbpseudo_plugin.album_info(official_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + assert album_info["album_transl"] == "In Bloom" + assert album_info["album_artist_transl"] == "Lilas Ikuta" + assert album_info.tracks[0]["title_transl"] == "In Bloom" + assert album_info.tracks[0]["artist_transl"] == "Lilas Ikuta" From c087851770f21e374692b0182a0ba779e15fc907 Mon Sep 17 00:00:00 2001 From: asardaes Date: Wed, 22 Oct 2025 11:14:30 -0600 Subject: [PATCH 13/13] Prefer alias if import languages not defined --- beetsplug/mbpseudo.py | 47 ++++++++++++++++++++++++++++++++--- beetsplug/musicbrainz.py | 17 +++++++++---- test/plugins/test_mbpseudo.py | 21 +++++++++++++++- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index e55847f81..448aef365 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -16,6 +16,7 @@ from __future__ import annotations +import itertools import traceback from copy import deepcopy from typing import TYPE_CHECKING, Any, Iterable, Sequence @@ -24,6 +25,7 @@ import mediafile import musicbrainzngs from typing_extensions import override +from beets import config from beets.autotag.distance import Distance, distance from beets.autotag.hooks import AlbumInfo from beets.autotag.match import assign_items @@ -34,6 +36,7 @@ from beetsplug.musicbrainz import ( MusicBrainzAPIError, MusicBrainzPlugin, _merge_pseudo_and_actual_album, + _preferred_alias, ) if TYPE_CHECKING: @@ -143,12 +146,13 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): try: raw_pseudo_release = self._release_getter( album_id, RELEASE_INCLUDES - ) - pseudo_release = super().album_info( - raw_pseudo_release["release"] - ) + )["release"] + pseudo_release = super().album_info(raw_pseudo_release) if self.config["custom_tags_only"].get(bool): + self._replace_artist_with_alias( + raw_pseudo_release, pseudo_release + ) self._add_custom_tags(official_release, pseudo_release) return official_release else: @@ -212,6 +216,41 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): else: return None + def _replace_artist_with_alias( + self, + raw_pseudo_release: JSONDict, + pseudo_release: AlbumInfo, + ): + """Use the pseudo-release's language to search for artist + alias if the user hasn't configured import languages.""" + + if len(config["import"]["languages"].as_str_seq()) > 0: + return + + lang = raw_pseudo_release.get("text-representation", {}).get("language") + artist_credits = raw_pseudo_release.get("release-group", {}).get( + "artist-credit", [] + ) + aliases = [ + artist_credit.get("artist", {}).get("alias-list", []) + for artist_credit in artist_credits + ] + + if lang and len(lang) >= 2 and len(aliases) > 0: + locale = lang[0:2] + aliases_flattened = list(itertools.chain.from_iterable(aliases)) + self._log.debug( + "Using locale '{0}' to search aliases {1}", + locale, + aliases_flattened, + ) + if alias_dict := _preferred_alias(aliases_flattened, [locale]): + if alias := alias_dict.get("alias"): + self._log.debug("Got alias '{0}'", alias) + pseudo_release.artist = alias + for track in pseudo_release.tracks: + track.artist = alias + def _add_custom_tags( self, official_release: AlbumInfo, diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index cd53c3156..29bbc26d0 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -118,13 +118,15 @@ BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 -def _preferred_alias(aliases: list[JSONDict]): - """Given an list of alias structures for an artist credit, select - and return the user's preferred alias alias or None if no matching +def _preferred_alias( + aliases: list[JSONDict], languages: list[str] | None = None +) -> JSONDict | None: + """Given a list of alias structures for an artist credit, select + and return the user's preferred alias or None if no matching alias is found. """ if not aliases: - return + return None # Only consider aliases that have locales set. valid_aliases = [a for a in aliases if "locale" in a] @@ -134,7 +136,10 @@ def _preferred_alias(aliases: list[JSONDict]): ignored_alias_types = [a.lower() for a in ignored_alias_types] # Search configured locales in order. - for locale in config["import"]["languages"].as_str_seq(): + if languages is None: + languages = config["import"]["languages"].as_str_seq() + + for locale in languages: # Find matching primary aliases for this locale that are not # being ignored matches = [] @@ -152,6 +157,8 @@ def _preferred_alias(aliases: list[JSONDict]): return matches[0] + return None + def _multi_artist_credit( credit: list[JSONDict], include_join_phrase: bool diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py index 8046dd0e6..621e08950 100644 --- a/test/plugins/test_mbpseudo.py +++ b/test/plugins/test_mbpseudo.py @@ -3,6 +3,7 @@ import pathlib import pytest +from beets import config from beets.autotag import AlbumMatch from beets.autotag.distance import Distance from beets.autotag.hooks import AlbumInfo, TrackInfo @@ -230,7 +231,6 @@ class TestMBPseudoPluginCustomTagsOnly(PluginMixin): @pytest.fixture(scope="class") def mbpseudo_plugin(self) -> MusicBrainzPseudoReleasePlugin: - self.config["import"]["languages"] = ["en", "jp"] self.config[self.plugin]["scripts"] = ["Latn"] self.config[self.plugin]["custom_tags_only"] = True return MusicBrainzPseudoReleasePlugin() @@ -255,6 +255,25 @@ class TestMBPseudoPluginCustomTagsOnly(PluginMixin): official_release: JSONDict, pseudo_release: JSONDict, ): + config["import"]["languages"] = [] + mbpseudo_plugin._release_getter = ( + lambda album_id, includes: pseudo_release + ) + album_info = mbpseudo_plugin.album_info(official_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + assert album_info["album_transl"] == "In Bloom" + assert album_info["album_artist_transl"] == "Lilas Ikuta" + assert album_info.tracks[0]["title_transl"] == "In Bloom" + assert album_info.tracks[0]["artist_transl"] == "Lilas Ikuta" + + def test_custom_tags_with_import_languages( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + pseudo_release: JSONDict, + ): + config["import"]["languages"] = ["en", "jp"] mbpseudo_plugin._release_getter = ( lambda album_id, includes: pseudo_release )