mirror of
https://github.com/beetbox/beets.git
synced 2026-02-08 16:34:12 +01:00
Merge 3d06b0aeae into cdfb813910
This commit is contained in:
commit
2433ffdd13
20 changed files with 1043 additions and 789 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
|
@ -4,4 +4,3 @@
|
|||
# Specific ownerships:
|
||||
/beets/metadata_plugins.py @semohr
|
||||
/beetsplug/titlecase.py @henry-oberholtzer
|
||||
/beetsplug/mbpseudo.py @asardaes
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ EventType = Literal[
|
|||
"album_imported",
|
||||
"album_removed",
|
||||
"albuminfo_received",
|
||||
"album_info_received",
|
||||
"album_matched",
|
||||
"before_choose_candidate",
|
||||
"before_item_moved",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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``)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
380
test/plugins/test_musicbrainz_pseudo.py
Normal file
380
test/plugins/test_musicbrainz_pseudo.py
Normal 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"
|
||||
|
|
@ -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",
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",)),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue