This commit is contained in:
Alexis Sardá 2026-02-02 17:43:00 +01:00 committed by GitHub
commit 2433ffdd13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1043 additions and 789 deletions

1
.github/CODEOWNERS vendored
View file

@ -4,4 +4,3 @@
# Specific ownerships:
/beets/metadata_plugins.py @semohr
/beetsplug/titlecase.py @henry-oberholtzer
/beetsplug/mbpseudo.py @asardaes

View file

@ -120,7 +120,7 @@ def match_by_id(items: Iterable[Item]) -> AlbumInfo | None:
return None
# If all album IDs are equal, look up the album.
log.debug("Searching for discovered album ID: {}", first)
return metadata_plugins.album_for_id(first)
return metadata_plugins.album_for_id(first, items)
def _recommendation(
@ -275,7 +275,7 @@ def tag_album(
if search_ids:
for search_id in search_ids:
log.debug("Searching for album ID: {}", search_id)
if info := metadata_plugins.album_for_id(search_id):
if info := metadata_plugins.album_for_id(search_id, items):
_add_candidate(items, candidates, info)
if opt_candidate := candidates.get(info.album_id):
plugins.send("album_matched", match=opt_candidate)

View file

@ -21,12 +21,13 @@ from beets import config, logging
from beets.util import cached_classproperty
from beets.util.id_extractors import extract_release_id
from .plugins import BeetsPlugin, find_plugins, notify_info_yielded
from .plugins import BeetsPlugin, find_plugins, notify_info_yielded, send
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Iterator, Sequence
from .autotag.hooks import AlbumInfo, Item, TrackInfo
from .autotag.hooks import AlbumInfo, TrackInfo
from .library.models import Item
Ret = TypeVar("Ret")
@ -95,8 +96,10 @@ def tracks_for_ids(*args, **kwargs) -> Iterator[TrackInfo]:
yield from ()
def album_for_id(_id: str) -> AlbumInfo | None:
return next(albums_for_ids([_id]), None)
def album_for_id(_id: str, items: Iterable[Item]) -> AlbumInfo | None:
album_info = next(albums_for_ids([_id]), None)
send("album_info_received", items=items, album_info=album_info)
return album_info
def track_for_id(_id: str) -> TrackInfo | None:

View file

@ -70,6 +70,7 @@ EventType = Literal[
"album_imported",
"album_removed",
"albuminfo_received",
"album_info_received",
"album_matched",
"before_choose_candidate",
"before_item_moved",

View file

@ -13,7 +13,7 @@ from __future__ import annotations
import operator
from dataclasses import dataclass, field
from functools import cached_property, singledispatchmethod, wraps
from itertools import groupby
from itertools import chain, groupby
from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypedDict, TypeVar
from requests_ratelimiter import LimiterMixin
@ -185,6 +185,8 @@ class MusicBrainzAPI(RequestHandler):
self,
entity: Entity,
filters: dict[str, str],
advanced: bool = True,
*args: str,
**kwargs: Unpack[SearchKwargs],
) -> list[JSONDict]:
"""Search for MusicBrainz entities matching the given filters.
@ -195,11 +197,23 @@ class MusicBrainzAPI(RequestHandler):
- 'value' is empty, in which case the filter is ignored
* Values are lowercased and stripped of whitespace.
"""
query = " AND ".join(
":".join(filter(None, (k, f'"{_v}"')))
for k, v in filters.items()
if (_v := v.lower().strip())
)
if advanced:
query = " AND ".join(
f'{k}:"{_v}"'
for k, v in filters.items()
if (_v := v.lower().strip())
)
else:
params = chain(
(str(arg) for arg in args),
(
f'{k}:"{_v}"'
for k, v in filters.items()
if (_v := v.lower().strip())
),
)
query = " ".join(params)
log.debug("Searching for MusicBrainz {}s with: {!r}", entity, query)
kwargs["query"] = query
return self._get_resource(entity, **kwargs)[f"{entity}s"]

View file

@ -1,350 +0,0 @@
# 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."""
from __future__ import annotations
import itertools
from copy import deepcopy
from typing import TYPE_CHECKING, Any
import mediafile
from typing_extensions import override
from beets import config
from beets.autotag.distance import distance
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 (
MusicBrainzPlugin,
_merge_pseudo_and_actual_album,
_preferred_alias,
)
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beets.autotag import AlbumMatch
from beets.autotag.distance import Distance
from beets.library import Item
from beetsplug._typing import JSONDict
_STATUS_PSEUDO = "Pseudo-Release"
class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
def __init__(self) -> None:
super().__init__()
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)
# noinspection PyMethodMayBeStatic
def _on_plugins_loaded(self):
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"
)
@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):
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 # 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:
official_release = super().album_info(release)
if release.get("status") == _STATUS_PSEUDO:
return official_release
if (ids := self._intercept_mb_release(release)) and (
album_id := self._extract_id(ids[0])
):
raw_pseudo_release = self.mb_api.get_release(album_id)
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:
return PseudoAlbumInfo(
pseudo_release=_merge_pseudo_and_actual_album(
pseudo_release, official_release
),
official_release=official_release,
)
else:
return official_release
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):
return []
return [
pr_id
for rel in data.get("release-relations", [])
if (pr_id := self._wanted_pseudo_release_id(album_id, rel))
is not 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,
album_id: str,
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):
self._log.debug(
"Adding pseudo-release {0} for main release {1}",
release["id"],
album_id,
)
return release["id"]
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("aliases", [])
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("name"):
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,
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):
self._log.debug(
"Switching {0} to pseudo-release source for final proposal",
album_info.album_id,
)
album_info.use_pseudo_as_ref()
new_pairs, *_ = assign_items(match.items, album_info.tracks)
album_info.mapping = dict(new_pairs)
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)
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 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)
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, 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) -> 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:
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

View file

@ -102,7 +102,9 @@ class MBSyncPlugin(BeetsPlugin):
continue
if not (
album_info := metadata_plugins.album_for_id(album.mb_albumid)
album_info := metadata_plugins.album_for_id(
album.mb_albumid, album.items()
)
):
self._log.info(
"Release ID {0.mb_albumid} not found for album {0}", album

View file

@ -230,7 +230,9 @@ class MissingPlugin(MusicBrainzAPIMixin, BeetsPlugin):
item_mbids = {x.mb_trackid for x in album.items()}
# fetch missing items
# TODO: Implement caching that without breaking other stuff
if album_info := metadata_plugins.album_for_id(album.mb_albumid):
if album_info := metadata_plugins.album_for_id(
album.mb_albumid, album.items()
):
for track_info in album_info.tracks:
if track_info.track_id not in item_mbids:
self._log.debug(

View file

@ -18,16 +18,21 @@ from __future__ import annotations
from collections import Counter
from contextlib import suppress
from copy import deepcopy
from functools import cached_property
from itertools import product
from itertools import chain, product
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin
import mediafile
from confuse.exceptions import NotFoundError
import beets
import beets.autotag.hooks
from beets import config, plugins, util
from beets.autotag.distance import distance
from beets.autotag.hooks import AlbumInfo
from beets.autotag.match import assign_items
from beets.metadata_plugins import MetadataSourcePlugin
from beets.util.deprecation import deprecate_for_user
from beets.util.id_extractors import extract_release_id
@ -39,6 +44,8 @@ if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from typing import Literal
from beets.autotag import AlbumMatch
from beets.autotag.distance import Distance
from beets.library import Item
from ._typing import JSONDict
@ -96,6 +103,8 @@ BROWSE_INCLUDES = [
BROWSE_CHUNKSIZE = 100
BROWSE_MAXTRACKS = 500
_STATUS_PSEUDO = "Pseudo-Release"
def _preferred_alias(
aliases: list[JSONDict], languages: list[str] | None = None
@ -257,7 +266,7 @@ def _preferred_release_event(
def _set_date_str(
info: beets.autotag.hooks.AlbumInfo,
info: AlbumInfo,
date_str: str,
original: bool = False,
):
@ -281,8 +290,8 @@ def _set_date_str(
def _merge_pseudo_and_actual_album(
pseudo: beets.autotag.hooks.AlbumInfo, actual: beets.autotag.hooks.AlbumInfo
) -> beets.autotag.hooks.AlbumInfo:
pseudo: AlbumInfo, actual: AlbumInfo
) -> AlbumInfo:
"""
Merges a pseudo release with its actual release.
@ -342,8 +351,22 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
"tidal": False,
},
"extra_tags": [],
"pseudo_releases": {
"scripts": [],
"custom_tags_only": False,
"multiple_allowed": False,
"album_custom_tags": {
"album_transl": "album",
"album_artist_transl": "artist",
},
"track_custom_tags": {
"title_transl": "title",
"artist_transl": "artist",
},
},
},
)
self._apply_pseudo_release_config()
# TODO: Remove in 3.0.0
with suppress(NotFoundError):
self.config["search_limit"] = self.config["match"][
@ -355,6 +378,63 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
"'musicbrainz.search_limit'",
)
def _apply_pseudo_release_config(self):
self._scripts = self.config["pseudo_releases"]["scripts"].as_str_seq()
self._log.debug("Desired pseudo-release scripts: {0}", self._scripts)
album_custom_tags = (
self.config["pseudo_releases"]["album_custom_tags"].get().keys()
)
track_custom_tags = (
self.config["pseudo_releases"]["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(
"album_info_received", self._determine_pseudo_album_info_ref
)
self.register_listener("album_matched", self._adjust_final_album_match)
def _determine_pseudo_album_info_ref(
self,
items: Iterable[Item],
album_info: AlbumInfo,
):
if isinstance(album_info, PseudoAlbumInfo):
for item in items:
# particularly relevant for reimport but could also happen during import
if "mb_albumid" in item:
del item["mb_albumid"]
if "mb_trackid" in item:
del item["mb_trackid"]
self._log.debug(
"Using {0} release for distance calculations for album {1}",
album_info.determine_best_ref(list(items)),
album_info.album_id,
)
def track_info(
self,
recording: JSONDict,
@ -458,7 +538,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
return info
def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo:
def album_info(self, release: JSONDict) -> AlbumInfo:
"""Takes a MusicBrainz release result dictionary and returns a beets
AlbumInfo object containing the interesting data about that release.
"""
@ -572,7 +652,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
track_infos.append(ti)
album_artist_ids = _artist_ids(release["artist-credit"])
info = beets.autotag.hooks.AlbumInfo(
info = AlbumInfo(
album=release["title"],
album_id=release["id"],
artist=artist_name,
@ -741,15 +821,20 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
self,
query_type: Literal["recording", "release"],
filters: dict[str, str],
advanced: bool = True,
*args: str,
) -> list[JSONDict]:
"""Perform MusicBrainz API search and return results.
Execute a search against the MusicBrainz API for recordings or releases
using the provided criteria. Handles API errors by converting them into
MusicBrainzAPIError exceptions with contextual information.
using the provided criteria.
"""
return self.mb_api.search(
query_type, filters, limit=self.config["search_limit"].get()
query_type,
filters,
advanced,
*args,
limit=self.config["search_limit"].get(),
)
def candidates(
@ -758,13 +843,30 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
artist: str,
album: str,
va_likely: bool,
) -> Iterable[beets.autotag.hooks.AlbumInfo]:
) -> Iterable[AlbumInfo]:
criteria = self.get_album_criteria(items, artist, album, va_likely)
release_ids = (r["id"] for r in self._search_api("release", criteria))
release_ids = [r["id"] for r in self._search_api("release", criteria)]
if len(release_ids) == 0 and "artist" in criteria:
# try a less advanced search if va_likely is False
del criteria["artist"]
release_ids = [
r["id"]
for r in self._search_api("release", criteria, False, artist)
]
for id_ in release_ids:
with suppress(HTTPNotFoundError):
if album_info := self.album_for_id(id_):
album_info = self.album_for_id(id_)
# always yield pseudo first to give it priority
if isinstance(album_info, MultiPseudoAlbumInfo):
yield from album_info.unwrap()
yield album_info
elif isinstance(album_info, PseudoAlbumInfo):
self._determine_pseudo_album_info_ref(items, album_info)
yield album_info
yield album_info.get_official_release()
elif isinstance(album_info, AlbumInfo):
yield album_info
def item_candidates(
@ -776,12 +878,9 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
None, map(self.track_info, self._search_api("recording", criteria))
)
def album_for_id(
self, album_id: str
) -> beets.autotag.hooks.AlbumInfo | None:
def album_for_id(self, album_id: str) -> AlbumInfo | None:
"""Fetches an album by its MusicBrainz ID and returns an AlbumInfo
object or None if the album is not found. May raise a
MusicBrainzAPIError.
object or None if the album is not found.
"""
self._log.debug("Requesting MusicBrainz release {}", album_id)
if not (albumid := self._extract_id(album_id)):
@ -796,31 +895,102 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
self._log.debug("Release {} not found on MusicBrainz.", albumid)
return None
# resolve linked release relations
actual_res = None
if res.get("status") == "Pseudo-Release" and (
relations := res.get("release-relations")
):
for rel in relations:
if (
rel["type"] == "transl-tracklisting"
and rel["direction"] == "backward"
):
actual_res = self.mb_api.get_release(
rel["release"]["id"], includes=RELEASE_INCLUDES
)
# release is potentially a pseudo release
release = self.album_info(res)
# should be None unless we're dealing with a pseudo release
if actual_res is not None:
actual_release = self.album_info(actual_res)
return _merge_pseudo_and_actual_album(release, actual_release)
if res.get("status") == _STATUS_PSEUDO:
return self._handle_main_pseudo_release(res, release)
elif pseudo_release_ids := self._intercept_mb_release(res):
return self._handle_intercepted_pseudo_releases(
release, pseudo_release_ids
)
else:
return release
def _handle_main_pseudo_release(
self,
pseudo_release: dict[str, Any],
pseudo_album_info: AlbumInfo,
) -> AlbumInfo:
actual_res = None
for rel in pseudo_release.get("release-relations", []):
if (
rel["type"] == "transl-tracklisting"
and rel["direction"] == "backward"
):
actual_res = self.mb_api.get_release(
rel["release"]["id"], includes=RELEASE_INCLUDES
)
if actual_res:
break
if actual_res is None:
return pseudo_album_info
actual_release = self.album_info(actual_res)
merged_release = _merge_pseudo_and_actual_album(
pseudo_album_info, actual_release
)
if self._has_desired_script(pseudo_release):
return PseudoAlbumInfo(
pseudo_release=merged_release,
official_release=actual_release,
)
else:
return merged_release
def _handle_intercepted_pseudo_releases(
self,
release: AlbumInfo,
pseudo_release_ids: list[str],
) -> AlbumInfo:
languages = list(config["import"]["languages"].as_str_seq())
pseudo_config = self.config["pseudo_releases"]
custom_tags_only = pseudo_config["custom_tags_only"].get(bool)
if len(pseudo_release_ids) == 1 or len(languages) == 0:
# only 1 pseudo-release or no language preference specified
album_info = self.mb_api.get_release(
pseudo_release_ids[0], includes=RELEASE_INCLUDES
)
return self._resolve_pseudo_album_info(
release, custom_tags_only, languages, album_info
)
pseudo_releases = [
self.mb_api.get_release(i, includes=RELEASE_INCLUDES)
for i in pseudo_release_ids
]
# sort according to the desired languages specified in the config
def sort_fun(rel: JSONDict) -> int:
lang = rel.get("text-representation", {}).get("language", "")
# noinspection PyBroadException
try:
return languages.index(lang[0:2])
except Exception:
return len(languages)
pseudo_releases.sort(key=sort_fun)
multiple_allowed = pseudo_config["multiple_allowed"].get(bool)
if custom_tags_only or not multiple_allowed:
return self._resolve_pseudo_album_info(
release,
custom_tags_only,
languages,
pseudo_releases[0],
)
pseudo_album_infos = [
self._resolve_pseudo_album_info(
release, custom_tags_only, languages, i
)
for i in pseudo_releases
]
return MultiPseudoAlbumInfo(
*pseudo_album_infos, official_release=release
)
def track_for_id(
self, track_id: str
) -> beets.autotag.hooks.TrackInfo | None:
@ -837,3 +1007,246 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
)
return None
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):
return []
ans = [
self._extract_id(pr_id)
for rel in data.get("release-relations", [])
if (pr_id := self._wanted_pseudo_release_id(album_id, rel))
is not None
]
return list(filter(None, ans))
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,
album_id: str,
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):
self._log.debug(
"Adding pseudo-release {0} for main release {1}",
release["id"],
album_id,
)
return release["id"]
else:
return None
def _resolve_pseudo_album_info(
self,
official_release: AlbumInfo,
custom_tags_only: bool,
languages: list[str],
raw_pseudo_release: JSONDict,
) -> AlbumInfo:
pseudo_release = self.album_info(raw_pseudo_release)
if custom_tags_only:
self._replace_artist_with_alias(
languages, raw_pseudo_release, pseudo_release
)
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,
)
def _replace_artist_with_alias(
self,
languages: list[str],
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 languages:
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("aliases", [])
for artist_credit in artist_credits
]
if lang and len(lang) >= 2 and len(aliases) > 0:
locale = lang[0:2]
aliases_flattened = list(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("name"):
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,
pseudo_release: AlbumInfo,
):
for tag_key, pseudo_key in (
self.config["pseudo_releases"]["album_custom_tags"].get().items()
):
official_release[tag_key] = pseudo_release[pseudo_key]
track_custom_tags = (
self.config["pseudo_releases"]["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):
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)
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"] = False
self.__dict__["_official_release"] = official_release
for k, v in pseudo_release.items():
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)
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, 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) -> 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:
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 MultiPseudoAlbumInfo(AlbumInfo):
"""For releases that have multiple pseudo-releases"""
def __init__(
self,
*args,
official_release: AlbumInfo,
**kwargs,
):
super().__init__(official_release.tracks, **kwargs)
self.__dict__["_pseudo_album_infos"] = [
arg for arg in args if isinstance(arg, PseudoAlbumInfo)
]
for k, v in official_release.items():
if k not in kwargs:
self[k] = v
def unwrap(self) -> list[PseudoAlbumInfo]:
return self.__dict__["_pseudo_album_infos"]
def __deepcopy__(self, memo):
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
result.__dict__.update(self.__dict__)
for k, v in self.items():
result[k] = deepcopy(v, memo)
return result

View file

@ -9,8 +9,16 @@ Unreleased
New features:
- :doc:`plugins/musicbrainz`: Additional functionality for pseudo-releases.
Bug fixes:
For plugin developers:
- The ``albuminfo_received`` event has been deprecated in favor of the new
``album_info_received`` event, which includes information about the ``Item``
set being imported.
For packagers:
Other changes:
@ -35,6 +43,7 @@ New features:
- :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``.
- :doc:`plugins/musicbrainz`: Allow selecting tags or genres to populate the
genres tag.
- :doc:`plugins/musicbrainz`: Additional functionality for pseudo-releases.
- :doc:`plugins/ftintitle`: Added argument to skip the processing of artist and
album artist are the same in ftintitle.
- :doc:`plugins/play`: Added `$playlist` marker to precisely edit the playlist
@ -46,8 +55,6 @@ New features:
- :doc:`plugins/importsource`: Added new plugin that tracks original import
paths and optionally suggests removing source files when items are removed
from the library.
- :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive
MusicBrainz pseudo-releases as recommendations during import.
- Added support for Python 3.13.
- :doc:`/plugins/convert`: ``force`` can be passed to override checks like
no_convert, never_convert_lossy_files, same format, and max_bitrate
@ -139,6 +146,9 @@ 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.
- The ``albuminfo_received`` event has been deprecated in favor of the new
``album_info_received`` event, which includes information about the ``Item``
set being imported.
- Added a reusable requests handler which can be used by plugins to make HTTP
requests with built-in retry and backoff logic. It uses beets user-agent and
configures timeouts. See :class:`~beetsplug._utils.requests.RequestHandler`
@ -148,7 +158,6 @@ For plugin developers:
- :doc:`plugins/listenbrainz`
- :doc:`plugins/mbcollection`
- :doc:`plugins/mbpseudo`
- :doc:`plugins/missing`
- :doc:`plugins/musicbrainz`
- :doc:`plugins/parentwork`

View file

@ -176,7 +176,14 @@ registration process in this case:
``albuminfo_received``
:Parameters: ``info`` (|AlbumInfo|)
:Description: Like ``trackinfo_received`` but for album-level metadata.
:Description: Deprecated. Like ``trackinfo_received`` but for album-level
metadata.
``album_info_received``
:Parameters: ``items`` (``Sequence`` of |Item|), ``album_info``
(|AlbumInfo|)
:Description: After searching based on the given ``items``, the specified
``album_info`` was received.
``album_matched``
:Parameters: ``match`` (``AlbumMatch``)

View file

@ -101,7 +101,6 @@ databases. They share the following configuration options:
loadext
lyrics
mbcollection
mbpseudo
mbsubmit
mbsync
metasync
@ -154,9 +153,6 @@ Autotagger Extensions
:doc:`musicbrainz <musicbrainz>`
Search for releases in the MusicBrainz_ database.
:doc:`mbpseudo <mbpseudo>`
Search for releases and pseudo-releases in the MusicBrainz_ database.
:doc:`spotify <spotify>`
Search for releases in the Spotify_ database.

View file

@ -1,103 +0,0 @@
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, 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. 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
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` if desired (see also
:ref:`metadata-source-plugin-configuration`). An example with multiple data
sources may look like this:
.. code-block:: yaml
plugins: mbpseudo deezer
import:
languages: en
mbpseudo:
data_source_mismatch_penalty: 0
scripts:
- Latn
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.

View file

@ -42,6 +42,16 @@ Default
tidal: no
data_source_mismatch_penalty: 0.5
search_limit: 5
pseudo_releases:
scripts: []
custom_tags_only: no
multiple_allowed: no
album_custom_tags:
album_transl: album
album_artist_transl: artist
track_custom_tags:
title_transl: title
artist_transl: artist
.. conf:: host
:default: musicbrainz.org
@ -149,3 +159,102 @@ Default
.. _limited: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting
.. _main server: https://musicbrainz.org/
Pseudo-Releases
---------------
This plugin can also 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
The configuration expects an array of scripts that are desired for 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 to enable this functionality looks like
this:
.. code-block:: yaml
import:
languages: en
musicbrainz:
# other config not shown
pseudo_releases:
scripts:
- Latn
Pseudo-releases will only be included if the initial search in MusicBrainz
returns releases whose script is *not* desired and whose relationships include
pseudo-releases with desired scripts.
A release may have multiple pseudo-releases, for example when there is both a
transliteration and a translation available. By default, only 1 pseudo-release
per original release is emitted as a candidate, using the languages from the
configuration to decide which one has most priority. If you're importing in
timid mode and you would like to receive all valid pseudo-releases as additional
candidates, you can add the following to the configuration:
.. code-block:: yaml
musicbrainz:
pseudo_releases:
# other config not shown
multiple_allowed: yes
.. note::
A limitation of reimporting in particular is that it will *not* give you a
pseudo-release proposal if multiple candidates exist and are allowed, so you
should disallow multiple in that scenario.
By default, the data from the pseudo-release will be used to create a proposal
that is independent from the original release and sets all properties in its
metadata. It's possible to change the configuration so that some information
from the pseudo-release is instead added as custom tags, keeping the metadata
from the original release:
.. code-block:: yaml
musicbrainz:
pseudo_releases:
# 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
musicbrainz:
pseudo_releases:
# other config not shown
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.

View file

@ -1,273 +0,0 @@
from __future__ import annotations
import json
from copy import deepcopy
from typing import TYPE_CHECKING
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
from beetsplug.mbpseudo import (
_STATUS_PSEUDO,
MusicBrainzPseudoReleasePlugin,
PseudoAlbumInfo,
)
if TYPE_CHECKING:
import pathlib
from beetsplug._typing import JSONDict
@pytest.fixture(scope="module")
def rsrc_dir(pytestconfig: pytest.Config):
return pytestconfig.rootpath / "test" / "rsrc" / "mbpseudo"
@pytest.fixture
def official_release(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(rsrc_dir: pathlib.Path) -> JSONDict:
info_json = (rsrc_dir / "pseudo_release.json").read_text(encoding="utf-8")
return json.loads(info_json)
@pytest.fixture
def official_release_info() -> AlbumInfo:
return AlbumInfo(
tracks=[TrackInfo(title="百花繚乱")],
album_id="official",
album="百花繚乱",
)
@pytest.fixture
def pseudo_release_info() -> AlbumInfo:
return AlbumInfo(
tracks=[TrackInfo(title="In Bloom")],
album_id="pseudo",
album="In Bloom",
)
@pytest.mark.usefixtures("config")
class TestPseudoAlbumInfo:
def test_album_id_always_from_pseudo(
self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo
):
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_info: AlbumInfo, pseudo_release_info: AlbumInfo
):
info = PseudoAlbumInfo(pseudo_release_info, official_release_info)
assert info.album == "In Bloom"
def test_get_attr_from_official(
self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo
):
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_info: AlbumInfo, pseudo_release_info: AlbumInfo
):
info = PseudoAlbumInfo(
pseudo_release_info, official_release_info, data_source="test"
)
item = Item(title="百花繚乱")
assert info.determine_best_ref([item]) == "official"
info.use_pseudo_as_ref()
assert info.data_source == "test"
class TestMBPseudoMixin(PluginMixin):
plugin = "mbpseudo"
@pytest.fixture(autouse=True)
def patch_get_release(self, monkeypatch, pseudo_release: JSONDict):
monkeypatch.setattr(
"beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release",
lambda _, album_id: deepcopy(
{pseudo_release["id"]: pseudo_release}[album_id]
),
)
@pytest.fixture(scope="class")
def plugin_config(self):
return {"scripts": ["Latn", "Dummy"]}
@pytest.fixture
def mbpseudo_plugin(self, plugin_config) -> MusicBrainzPseudoReleasePlugin:
self.config[self.plugin].set(plugin_config)
return MusicBrainzPseudoReleasePlugin()
class TestMBPseudoPlugin(TestMBPseudoMixin):
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)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainzPseudoRelease"
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-relations"][0][json_key]
album_info = mbpseudo_plugin.album_info(official_release)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainzPseudoRelease"
def test_interception_skip_when_script_doesnt_match(
self,
mbpseudo_plugin: MusicBrainzPseudoReleasePlugin,
official_release: JSONDict,
):
official_release["release-relations"][0]["release"][
"text-representation"
]["script"] = "Null"
album_info = mbpseudo_plugin.album_info(official_release)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainzPseudoRelease"
def test_interception(
self,
mbpseudo_plugin: MusicBrainzPseudoReleasePlugin,
official_release: JSONDict,
):
album_info = mbpseudo_plugin.album_info(official_release)
assert isinstance(album_info, PseudoAlbumInfo)
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"
class TestMBPseudoPluginCustomTagsOnly(TestMBPseudoMixin):
@pytest.fixture(scope="class")
def plugin_config(self):
return {"scripts": ["Latn", "Dummy"], "custom_tags_only": True}
def test_custom_tags(
self,
config,
mbpseudo_plugin: MusicBrainzPseudoReleasePlugin,
official_release: JSONDict,
):
config["import"]["languages"] = ["en", "jp"]
album_info = mbpseudo_plugin.album_info(official_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,
config,
mbpseudo_plugin: MusicBrainzPseudoReleasePlugin,
official_release: JSONDict,
):
config["import"]["languages"] = []
album_info = mbpseudo_plugin.album_info(official_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"

View file

@ -0,0 +1,380 @@
from __future__ import annotations
import json
from copy import deepcopy
from typing import TYPE_CHECKING
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
from beetsplug.musicbrainz import (
MultiPseudoAlbumInfo,
MusicBrainzPlugin,
PseudoAlbumInfo,
)
if TYPE_CHECKING:
import pathlib
from beetsplug._typing import JSONDict
@pytest.fixture(scope="module")
def rsrc_dir(pytestconfig: pytest.Config):
return pytestconfig.rootpath / "test" / "rsrc" / "musicbrainz"
@pytest.fixture
def official_release(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(rsrc_dir: pathlib.Path) -> JSONDict:
info_json = (rsrc_dir / "pseudo_release.json").read_text(encoding="utf-8")
return json.loads(info_json)
@pytest.fixture
def official_release_info() -> AlbumInfo:
return AlbumInfo(
tracks=[TrackInfo(title="百花繚乱")],
album_id="official",
album="百花繚乱",
)
@pytest.fixture
def pseudo_release_info() -> AlbumInfo:
return AlbumInfo(
tracks=[TrackInfo(title="In Bloom")],
album_id="pseudo",
album="In Bloom",
)
@pytest.mark.usefixtures("config")
class TestPseudoAlbumInfo:
def test_album_id_always_from_pseudo(
self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo
):
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_info: AlbumInfo, pseudo_release_info: AlbumInfo
):
info = PseudoAlbumInfo(pseudo_release_info, official_release_info)
info.use_pseudo_as_ref()
assert info.album == "In Bloom"
def test_get_attr_from_official(
self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo
):
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_info: AlbumInfo, pseudo_release_info: AlbumInfo
):
info = PseudoAlbumInfo(
pseudo_release_info, official_release_info, data_source="test"
)
item = Item(title="百花繚乱")
assert info.determine_best_ref([item]) == "official"
info.use_pseudo_as_ref()
assert info.data_source == "test"
class TestMBPseudoMixin(PluginMixin):
plugin = "musicbrainz"
@pytest.fixture(autouse=True)
def patch_get_release(
self,
monkeypatch,
official_release: JSONDict,
pseudo_release: JSONDict,
):
def mock_get_release(_, album_id: str, **kwargs):
if album_id == official_release["id"]:
return deepcopy(official_release)
else:
return deepcopy(pseudo_release)
monkeypatch.setattr(
"beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release",
mock_get_release,
)
@pytest.fixture(scope="class")
def plugin_config(self):
return {"pseudo_releases": {"scripts": ["Latn", "Dummy"]}}
@pytest.fixture
def musicbrainz_plugin(self, plugin_config) -> MusicBrainzPlugin:
self.config[self.plugin].set(plugin_config)
return MusicBrainzPlugin()
@staticmethod
def get_album_info(
musicbrainz_plugin: MusicBrainzPlugin,
raw: JSONDict,
) -> AlbumInfo:
if info := musicbrainz_plugin.album_for_id(raw["id"]):
return info
else:
raise AssertionError("AlbumInfo is None")
class TestMBPseudoReleases(TestMBPseudoMixin):
def test_scripts_init(self, musicbrainz_plugin: MusicBrainzPlugin):
assert musicbrainz_plugin._scripts == ["Latn", "Dummy"]
def test_reimport_logic(
self,
musicbrainz_plugin: MusicBrainzPlugin,
official_release_info: AlbumInfo,
pseudo_release_info: AlbumInfo,
):
pseudo_info = PseudoAlbumInfo(
pseudo_release_info, official_release_info
)
item = Item()
item["title"] = "百花繚乱"
# if items don't have mb_*, they are not modified
musicbrainz_plugin._determine_pseudo_album_info_ref([item], pseudo_info)
assert pseudo_info.album == item.title
pseudo_info.use_pseudo_as_ref()
assert pseudo_info.album == "In Bloom"
item["mb_albumid"] = "mb_aid"
item["mb_trackid"] = "mb_tid"
assert item.get("mb_albumid") == "mb_aid"
assert item.get("mb_trackid") == "mb_tid"
# if items have mb_*, they are deleted
musicbrainz_plugin._determine_pseudo_album_info_ref([item], pseudo_info)
assert pseudo_info.album == item.title
assert item.get("mb_albumid") == ""
assert item.get("mb_trackid") == ""
def test_album_info_for_pseudo_release(
self,
musicbrainz_plugin: MusicBrainzPlugin,
pseudo_release: JSONDict,
):
album_info = self.get_album_info(musicbrainz_plugin, pseudo_release)
assert isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainz"
assert album_info.albumstatus == "Official"
@pytest.mark.parametrize(
"json_key",
[
"type",
"direction",
"release",
],
)
def test_interception_skip_when_rel_values_dont_match(
self,
musicbrainz_plugin: MusicBrainzPlugin,
official_release: JSONDict,
json_key: str,
):
for r in official_release["release-relations"]:
del r[json_key]
album_info = self.get_album_info(musicbrainz_plugin, official_release)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainz"
def test_interception_skip_when_script_doesnt_match(
self,
musicbrainz_plugin: MusicBrainzPlugin,
official_release: JSONDict,
):
for r in official_release["release-relations"]:
r["release"]["text-representation"]["script"] = "Null"
album_info = self.get_album_info(musicbrainz_plugin, official_release)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainz"
def test_interception_skip_when_relations_missing(
self,
musicbrainz_plugin: MusicBrainzPlugin,
official_release: JSONDict,
):
del official_release["release-relations"]
album_info = self.get_album_info(musicbrainz_plugin, official_release)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainz"
def test_interception(
self,
musicbrainz_plugin: MusicBrainzPlugin,
official_release: JSONDict,
):
album_info = self.get_album_info(musicbrainz_plugin, official_release)
assert isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainz"
def test_final_adjustment_skip(
self,
musicbrainz_plugin: MusicBrainzPlugin,
):
match = AlbumMatch(
distance=Distance(),
info=AlbumInfo(tracks=[], data_source="mb"),
mapping={},
extra_items=[],
extra_tracks=[],
)
musicbrainz_plugin._adjust_final_album_match(match)
def test_final_adjustment(
self,
musicbrainz_plugin: MusicBrainzPlugin,
official_release_info: AlbumInfo,
pseudo_release_info: AlbumInfo,
):
pseudo_album_info = PseudoAlbumInfo(
pseudo_release=pseudo_release_info,
official_release=official_release_info,
data_source=musicbrainz_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=[],
)
musicbrainz_plugin._adjust_final_album_match(match)
assert match.info.data_source == "MusicBrainz"
assert match.info.album_id == "pseudo"
assert match.info.album == "In Bloom"
class TestMBMultiplePseudoReleases(PluginMixin):
plugin = "musicbrainz"
@pytest.fixture(autouse=True)
def patch_get_release(
self,
monkeypatch,
official_release: JSONDict,
pseudo_release: JSONDict,
):
def mock_get_release(_, album_id: str, **kwargs):
if album_id == official_release["id"]:
return official_release
elif album_id == pseudo_release["id"]:
return pseudo_release
else:
clone = deepcopy(pseudo_release)
clone["id"] = album_id
clone["text-representation"]["language"] = "jpn"
return clone
monkeypatch.setattr(
"beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release",
mock_get_release,
)
@pytest.fixture(scope="class")
def plugin_config(self):
return {
"pseudo_releases": {
"scripts": ["Latn", "Dummy"],
"multiple_allowed": True,
}
}
@pytest.fixture
def musicbrainz_plugin(self, config, plugin_config) -> MusicBrainzPlugin:
self.config[self.plugin].set(plugin_config)
config["import"]["languages"] = ["jp", "en"]
return MusicBrainzPlugin()
def test_multiple_releases(
self,
musicbrainz_plugin: MusicBrainzPlugin,
official_release: JSONDict,
pseudo_release: JSONDict,
):
album_info = musicbrainz_plugin.album_for_id(official_release["id"])
assert isinstance(album_info, MultiPseudoAlbumInfo)
assert album_info.data_source == "MusicBrainz"
assert len(album_info.unwrap()) == 2
assert (
album_info.unwrap()[0].album_id
== "mockedid-0bc1-49eb-b8c4-34473d279a43"
)
assert (
album_info.unwrap()[1].album_id
== "dc3ee2df-0bc1-49eb-b8c4-34473d279a43"
)
class TestMBPseudoReleasesCustomTagsOnly(TestMBPseudoMixin):
@pytest.fixture(scope="class")
def plugin_config(self):
return {
"pseudo_releases": {
"scripts": ["Latn", "Dummy"],
"custom_tags_only": True,
}
}
def test_custom_tags(
self,
config,
musicbrainz_plugin: MusicBrainzPlugin,
official_release: JSONDict,
):
config["import"]["languages"] = []
album_info = self.get_album_info(musicbrainz_plugin, official_release)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainz"
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,
config,
musicbrainz_plugin: MusicBrainzPlugin,
official_release: JSONDict,
):
config["import"]["languages"] = []
config["import"]["languages"] = ["en", "jp"]
album_info = self.get_album_info(musicbrainz_plugin, official_release)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainz"
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"

View file

@ -1642,6 +1642,51 @@
"target-credit": "",
"type": "transl-tracklisting",
"type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644"
},
{
"attribute-ids": {},
"attribute-values": {},
"attributes": [],
"begin": null,
"direction": "forward",
"end": null,
"ended": false,
"release": {
"artist-credit": [
{
"artist": {
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"type": null,
"type-id": null
},
"joinphrase": "",
"name": "Lilas Ikuta"
}
],
"barcode": null,
"disambiguation": "",
"id": "mockedid-0bc1-49eb-b8c4-34473d279a43",
"media": [],
"packaging": null,
"packaging-id": null,
"quality": "normal",
"release-group": null,
"status": null,
"status-id": null,
"text-representation": {
"language": "jpn",
"script": "Latn"
},
"title": "Title Desu"
},
"source-credit": "",
"target-credit": "",
"type": "transl-tracklisting",
"type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644"
}
],
"status": "Official",

View file

@ -1528,7 +1528,7 @@ class ImportPretendTest(IOMixin, AutotagImportTestCase):
assert self.__run(importer) == [f"No files imported from {empty_path}"]
def mocked_get_album_by_id(id_):
def mocked_get_album_by_id(id_, _):
"""Return album candidate for the given id.
The two albums differ only in the release title and artist name, so that

View file

@ -53,7 +53,7 @@ class TestMetadataPluginsException(PluginMixin):
("tracks_for_ids", "tracks_for_ids", (["some_id"],)),
# Currently, singular methods call plural ones internally and log
# errors from there
("album_for_id", "albums_for_ids", ("some_id",)),
("album_for_id", "albums_for_ids", ("some_id", [])),
("track_for_id", "tracks_for_ids", ("some_id",)),
],
)
@ -72,7 +72,7 @@ class TestMetadataPluginsException(PluginMixin):
[
("candidates", ()),
("item_candidates", ()),
("album_for_id", ("some_id",)),
("album_for_id", ("some_id", [])),
("track_for_id", ("some_id",)),
],
)