mirror of
https://github.com/beetbox/beets.git
synced 2026-07-22 02:06:50 -04:00
814 lines
28 KiB
Python
814 lines
28 KiB
Python
"""Searches for albums in the MusicBrainz database."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from contextlib import suppress
|
|
from functools import cached_property
|
|
from itertools import product
|
|
from typing import TYPE_CHECKING, Literal, TypedDict
|
|
from urllib.parse import urljoin
|
|
|
|
from confuse.exceptions import NotFoundError
|
|
from typing_extensions import NotRequired
|
|
|
|
from beets import config, plugins, util
|
|
from beets.autotag import AlbumInfo, TrackInfo
|
|
from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin
|
|
from beets.util.deprecation import deprecate_for_user
|
|
from beets.util.id_extractors import extract_release_id
|
|
|
|
from ._utils.musicbrainz import MusicBrainzAPIMixin
|
|
from ._utils.requests import HTTPNotFoundError
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable, Sequence
|
|
|
|
from beets.library import Item
|
|
from beets.metadata_plugins import QueryType, SearchParams
|
|
|
|
from ._utils.musicbrainz import (
|
|
Alias,
|
|
ArtistCredit,
|
|
ArtistRelation,
|
|
LabelInfo,
|
|
Medium,
|
|
Recording,
|
|
Release,
|
|
ReleaseGroup,
|
|
UrlRelation,
|
|
WorkRelation,
|
|
)
|
|
|
|
VARIOUS_ARTISTS_ID = "89ad4ac3-39f7-470e-963a-56509c546377"
|
|
|
|
BASE_URL = "https://musicbrainz.org/"
|
|
|
|
FIELDS_TO_MB_KEYS = {
|
|
"barcode": "barcode",
|
|
"catalognum": "catno",
|
|
"country": "country",
|
|
"label": "label",
|
|
"media": "format",
|
|
"year": "date",
|
|
"tracks": "tracks",
|
|
"alias": "alias",
|
|
}
|
|
|
|
BROWSE_INCLUDES = [
|
|
"artist-credits",
|
|
"aliases",
|
|
"work-rels",
|
|
"artist-rels",
|
|
"recording-rels",
|
|
"release-rels",
|
|
]
|
|
BROWSE_CHUNKSIZE = 100
|
|
BROWSE_MAXTRACKS = 500
|
|
|
|
|
|
UrlSource = Literal[
|
|
"discogs", "bandcamp", "spotify", "deezer", "tidal", "beatport"
|
|
]
|
|
|
|
|
|
class ArtistInfo(TypedDict):
|
|
artist: str
|
|
artist_id: str
|
|
artist_sort: str
|
|
artist_credit: str
|
|
artists: list[str]
|
|
artists_ids: list[str]
|
|
artists_sort: list[str]
|
|
artists_credit: list[str]
|
|
|
|
|
|
class ReleaseGroupInfo(TypedDict):
|
|
albumtype: str | None
|
|
albumtypes: list[str]
|
|
releasegroup_id: str
|
|
release_group_title: str | None
|
|
releasegroupdisambig: str | None
|
|
original_year: int | None
|
|
original_month: int | None
|
|
original_day: int | None
|
|
|
|
|
|
class LabelInfoInfo(TypedDict):
|
|
label: str | None
|
|
catalognum: str | None
|
|
|
|
|
|
class ExternalIdsInfo(TypedDict):
|
|
discogs_album_id: NotRequired[str | None]
|
|
bandcamp_album_id: NotRequired[str | None]
|
|
spotify_album_id: NotRequired[str | None]
|
|
deezer_album_id: NotRequired[str | None]
|
|
tidal_album_id: NotRequired[str | None]
|
|
beatport_album_id: NotRequired[str | None]
|
|
|
|
|
|
class WorkRelationsInfo(TypedDict):
|
|
work: str | None
|
|
mb_workid: str | None
|
|
lyricists: list[str] | None
|
|
lyricists_ids: list[str] | None
|
|
composers: list[str] | None
|
|
composers_ids: list[str] | None
|
|
composer_sort: str | None
|
|
|
|
|
|
class ArtistRelationsInfo(TypedDict):
|
|
arrangers: list[str] | None
|
|
arrangers_ids: list[str] | None
|
|
remixers: list[str] | None
|
|
remixers_ids: list[str] | None
|
|
|
|
|
|
def _preferred_alias(
|
|
aliases: list[Alias], languages: list[str] | None = None
|
|
) -> Alias | None:
|
|
"""Select the most appropriate alias based on user preferences."""
|
|
if not aliases:
|
|
return None
|
|
|
|
# Get any ignored alias types and lower case them to prevent case issues
|
|
ignored_alias_types = {
|
|
a.lower() for a in config["import"]["ignored_alias_types"].as_str_seq()
|
|
}
|
|
|
|
# Search configured locales in order.
|
|
languages = languages or config["import"]["languages"].as_str_seq()
|
|
|
|
matches = (
|
|
al
|
|
for locale in languages
|
|
for al in aliases
|
|
# Find matching primary aliases for this locale that are not
|
|
# being ignored
|
|
if (
|
|
al["locale"] == locale
|
|
and al["primary"]
|
|
and (al["type"] or "").lower() not in ignored_alias_types
|
|
)
|
|
)
|
|
return next(matches, None)
|
|
|
|
|
|
def _key_with_preferred_alias(
|
|
obj: ReleaseGroup | Release | Recording, key: Literal["title"]
|
|
) -> str:
|
|
alias = _preferred_alias(obj.get("aliases", []))
|
|
return alias["name"] if alias else obj[key]
|
|
|
|
|
|
def _preferred_release_event(release: Release) -> tuple[str | None, str | None]:
|
|
"""Select the most relevant release country and date for matching.
|
|
|
|
Fall back to the default release event if a preferred event is not found.
|
|
"""
|
|
preferred_countries = config["match"]["preferred"]["countries"].as_str_seq()
|
|
|
|
for country in preferred_countries:
|
|
for event in release.get("release_events", []):
|
|
if (area := event["area"]) and country in area["iso_3166_1_codes"]:
|
|
return country, event["date"]
|
|
|
|
return release.get("country"), release.get("date")
|
|
|
|
|
|
def _get_date(date_str: str) -> tuple[int | None, int | None, int | None]:
|
|
"""Parse a partial `YYYY-MM-DD` string into numeric date parts.
|
|
|
|
Missing components are returned as `None`.
|
|
"""
|
|
if not date_str:
|
|
return None, None, None
|
|
|
|
def _parse_part(part: str) -> int | None:
|
|
try:
|
|
return int(part)
|
|
except ValueError:
|
|
return None
|
|
|
|
parts = list(map(_parse_part, date_str.split("-")))
|
|
|
|
return (
|
|
parts[0] if len(parts) > 0 else None,
|
|
parts[1] if len(parts) > 1 else None,
|
|
parts[2] if len(parts) > 2 else None,
|
|
)
|
|
|
|
|
|
def _merge_pseudo_and_actual_album(
|
|
pseudo: AlbumInfo, actual: AlbumInfo
|
|
) -> AlbumInfo:
|
|
"""
|
|
Merges a pseudo release with its actual release.
|
|
|
|
This implementation is naive, it doesn't overwrite fields,
|
|
like status or ids.
|
|
|
|
According to the ticket PICARD-145, the main release id should be used.
|
|
But the ticket has been in limbo since over a decade now.
|
|
It also suggests the introduction of the tag `musicbrainz_pseudoreleaseid`,
|
|
but as of this field can't be found in any official Picard docs,
|
|
hence why we did not implement that for now.
|
|
"""
|
|
merged = pseudo.copy()
|
|
from_actual = {
|
|
k: actual[k]
|
|
for k in [
|
|
"media",
|
|
"mediums",
|
|
"country",
|
|
"catalognum",
|
|
"year",
|
|
"month",
|
|
"day",
|
|
"original_year",
|
|
"original_month",
|
|
"original_day",
|
|
"label",
|
|
"barcode",
|
|
"asin",
|
|
"style",
|
|
"genres",
|
|
]
|
|
}
|
|
merged.update(from_actual)
|
|
return merged
|
|
|
|
|
|
class MusicBrainzPlugin(
|
|
MusicBrainzAPIMixin, SearchApiMetadataSourcePlugin[IDResponse]
|
|
):
|
|
@cached_property
|
|
def genres_field(self) -> Literal["genres", "tags"]:
|
|
choices: list[Literal["genre", "tag"]] = ["genre", "tag"]
|
|
choice = self.config["genres_tag"].as_choice(choices)
|
|
if choice == "genre":
|
|
return "genres"
|
|
return "tags"
|
|
|
|
@cached_property
|
|
def ignored_media(self) -> set[str]:
|
|
return set(config["match"]["ignored_media"].as_str_seq())
|
|
|
|
@cached_property
|
|
def ignore_data_tracks(self) -> bool:
|
|
return config["match"]["ignore_data_tracks"].get(bool)
|
|
|
|
@cached_property
|
|
def ignore_video_tracks(self) -> bool:
|
|
return config["match"]["ignore_video_tracks"].get(bool)
|
|
|
|
@cached_property
|
|
def aliases_as_credits(self) -> bool:
|
|
return self.config["aliases_as_credits"].get(bool)
|
|
|
|
def __init__(self) -> None:
|
|
"""Set up the python-musicbrainz-ngs module according to settings
|
|
from the beets configuration. This should be called at startup.
|
|
"""
|
|
super().__init__()
|
|
self.config.add(
|
|
{
|
|
"genres": False,
|
|
"genres_tag": "genre",
|
|
"external_ids": {
|
|
"discogs": False,
|
|
"bandcamp": False,
|
|
"spotify": False,
|
|
"deezer": False,
|
|
"tidal": False,
|
|
},
|
|
"extra_tags": [],
|
|
"aliases_as_credits": False,
|
|
}
|
|
)
|
|
# TODO: Remove in 3.0.0
|
|
with suppress(NotFoundError):
|
|
self.config["search_limit"] = self.config["match"][
|
|
"searchlimit"
|
|
].get()
|
|
deprecate_for_user(
|
|
self._log,
|
|
"'musicbrainz.searchlimit' configuration option",
|
|
"'musicbrainz.search_limit'",
|
|
)
|
|
|
|
def _parse_artist_credits(
|
|
self, artist_credits: list[ArtistCredit]
|
|
) -> ArtistInfo:
|
|
"""Normalize MusicBrainz artist-credit data into tag-friendly fields.
|
|
|
|
MusicBrainz represents credits as a sequence of credited artists, each
|
|
with a display name and a `joinphrase` (for example `' & '`, `' feat.
|
|
'`, or `''`). This helper converts that structured representation into
|
|
both:
|
|
|
|
- Single string values suitable for common tags (concatenated names with
|
|
joinphrases preserved).
|
|
- Parallel lists that keep the per-artist granularity for callers that
|
|
need to reason about individual credited artists.
|
|
|
|
When available, a preferred alias is used for the canonical artist name,
|
|
sort name and the credit name.
|
|
"""
|
|
artist_parts: list[str] = []
|
|
artist_sort_parts: list[str] = []
|
|
artist_credit_parts: list[str] = []
|
|
artists: list[str] = []
|
|
artists_sort: list[str] = []
|
|
artists_credit: list[str] = []
|
|
artists_ids: list[str] = []
|
|
|
|
for el in artist_credits:
|
|
artists_ids.append(el["artist"]["id"])
|
|
alias = _preferred_alias(el["artist"].get("aliases", []))
|
|
artist_object = alias or el["artist"]
|
|
credit_artist_object = (
|
|
alias if (alias and self.aliases_as_credits) else el
|
|
)
|
|
|
|
joinphrase = el["joinphrase"]
|
|
for name, parts, multi in (
|
|
(artist_object["name"], artist_parts, artists),
|
|
(artist_object["sort_name"], artist_sort_parts, artists_sort),
|
|
(
|
|
credit_artist_object["name"],
|
|
artist_credit_parts,
|
|
artists_credit,
|
|
),
|
|
):
|
|
parts.extend([name, joinphrase])
|
|
multi.append(name)
|
|
|
|
return {
|
|
"artist": "".join(artist_parts),
|
|
"artist_id": artists_ids[0],
|
|
"artist_sort": "".join(artist_sort_parts),
|
|
"artist_credit": "".join(artist_credit_parts),
|
|
"artists": artists,
|
|
"artists_ids": artists_ids,
|
|
"artists_sort": artists_sort,
|
|
"artists_credit": artists_credit,
|
|
}
|
|
|
|
@staticmethod
|
|
def _parse_work_relations(
|
|
relations: list[WorkRelation],
|
|
) -> WorkRelationsInfo:
|
|
"""Extract composer and lyricist credits from work relations.
|
|
|
|
Traverses performance-type relations to collect associated artist
|
|
credits, separating them into composers and lyricists along with
|
|
their MusicBrainz IDs and sort names.
|
|
"""
|
|
lyricists: list[str] = []
|
|
lyricists_ids: list[str] = []
|
|
composers: list[str] = []
|
|
composers_ids: list[str] = []
|
|
composer_sort: list[str] = []
|
|
|
|
artist_relations = [
|
|
ar
|
|
for r in relations
|
|
if r["type"] == "performance"
|
|
for ar in r["work"].get("artist_relations", [])
|
|
]
|
|
for artist_relation in artist_relations:
|
|
rel_type = artist_relation["type"]
|
|
if rel_type == "lyricist":
|
|
lyricists.append(artist_relation["artist"]["name"])
|
|
lyricists_ids.append(artist_relation["artist"]["id"])
|
|
elif rel_type == "composer":
|
|
composers.append(artist_relation["artist"]["name"])
|
|
composers_ids.append(artist_relation["artist"]["id"])
|
|
composer_sort.append(artist_relation["artist"]["sort_name"])
|
|
|
|
return {
|
|
# TODO: double-check if we should really use the last work here
|
|
"work": relations[-1]["work"]["title"] if relations else None,
|
|
"mb_workid": relations[-1]["work"]["id"] if relations else None,
|
|
"lyricists": lyricists or None,
|
|
"lyricists_ids": lyricists_ids or None,
|
|
"composers": composers or None,
|
|
"composers_ids": composers_ids or None,
|
|
"composer_sort": ", ".join(composer_sort) or None,
|
|
}
|
|
|
|
@staticmethod
|
|
def _parse_artist_relations(
|
|
relations: list[ArtistRelation],
|
|
) -> ArtistRelationsInfo:
|
|
"""Extract arranger and remixer credits from artist relations.
|
|
|
|
Traverses recording-level artist relations to collect associated artist
|
|
credits, separating them into arrangers and remixers along with their
|
|
MusicBrainz IDs.
|
|
"""
|
|
arrangers: list[str] = []
|
|
arrangers_ids: list[str] = []
|
|
remixers: list[str] = []
|
|
remixers_ids: list[str] = []
|
|
|
|
for artist_relation in relations:
|
|
rel_type = artist_relation["type"]
|
|
if rel_type == "arranger":
|
|
arrangers.append(artist_relation["artist"]["name"])
|
|
arrangers_ids.append(artist_relation["artist"]["id"])
|
|
elif rel_type == "remixer":
|
|
remixers.append(artist_relation["artist"]["name"])
|
|
remixers_ids.append(artist_relation["artist"]["id"])
|
|
|
|
return {
|
|
"arrangers": arrangers or None,
|
|
"arrangers_ids": arrangers_ids or None,
|
|
"remixers": remixers or None,
|
|
"remixers_ids": remixers_ids or None,
|
|
}
|
|
|
|
def track_info(self, recording: Recording) -> TrackInfo:
|
|
"""Build a `TrackInfo` object from a MusicBrainz recording payload.
|
|
|
|
This is the main translation layer between MusicBrainz's recording model
|
|
and beets' internal autotag representation. It gathers core identifying
|
|
metadata (title, MBIDs, URLs), timing information, and artist-credit
|
|
fields, then enriches the result with relationship-derived roles (such
|
|
as remixers and arrangers) and work-level credits (such as lyricists and
|
|
composers).
|
|
"""
|
|
title = _key_with_preferred_alias(recording, key="title")
|
|
|
|
info = TrackInfo(
|
|
title=title,
|
|
track_id=recording["id"],
|
|
data_source=self.data_source,
|
|
data_url=urljoin(BASE_URL, f"recording/{recording['id']}"),
|
|
length=(
|
|
length / 1000.0 if (length := recording["length"]) else None
|
|
),
|
|
trackdisambig=recording["disambiguation"] or None,
|
|
isrc=(
|
|
";".join(isrcs) if (isrcs := recording.get("isrcs")) else None
|
|
),
|
|
**self._parse_artist_credits(recording["artist_credit"]),
|
|
**self._parse_work_relations(recording.get("work_relations", [])),
|
|
**self._parse_artist_relations(
|
|
recording.get("artist_relations", [])
|
|
),
|
|
)
|
|
|
|
# Supplementary fields provided by plugins
|
|
extra_trackdatas = plugins.send("mb_track_extract", data=recording)
|
|
for extra_trackdata in extra_trackdatas:
|
|
info.update(extra_trackdata)
|
|
|
|
return info
|
|
|
|
@staticmethod
|
|
def _parse_release_group(release_group: ReleaseGroup) -> ReleaseGroupInfo:
|
|
albumtype = None
|
|
albumtypes = []
|
|
if reltype := release_group["primary_type"]:
|
|
albumtype = reltype.lower()
|
|
albumtypes.append(albumtype)
|
|
|
|
year, month, day = _get_date(release_group["first_release_date"])
|
|
return ReleaseGroupInfo(
|
|
albumtype=albumtype,
|
|
albumtypes=[
|
|
*albumtypes,
|
|
*(st.lower() for st in release_group["secondary_types"]),
|
|
],
|
|
releasegroup_id=release_group["id"],
|
|
release_group_title=_key_with_preferred_alias(
|
|
release_group, key="title"
|
|
),
|
|
releasegroupdisambig=release_group["disambiguation"] or None,
|
|
original_year=year,
|
|
original_month=month,
|
|
original_day=day,
|
|
)
|
|
|
|
@staticmethod
|
|
def _parse_label_infos(label_infos: list[LabelInfo]) -> LabelInfoInfo:
|
|
catalognum = label = None
|
|
if label_infos:
|
|
label_info = label_infos[0]
|
|
catalognum = label_info["catalog_number"]
|
|
if (_label := label_info["label"]) and (
|
|
label_name := _label["name"]
|
|
) != "[no label]":
|
|
label = label_name
|
|
|
|
return {"label": label, "catalognum": catalognum}
|
|
|
|
def _parse_genres(self, release: Release) -> list[str] | None:
|
|
if self.config["genres"] and (
|
|
genres := [
|
|
*release["release_group"][self.genres_field],
|
|
*release[self.genres_field],
|
|
]
|
|
):
|
|
count_by_genre: dict[str, int] = defaultdict(int)
|
|
for genre in genres:
|
|
count_by_genre[genre["name"]] += genre["count"]
|
|
|
|
return [
|
|
g
|
|
for g, _ in sorted(count_by_genre.items(), key=lambda g: -g[1])
|
|
]
|
|
|
|
return None
|
|
|
|
def _parse_external_ids(
|
|
self, url_relations: list[UrlRelation]
|
|
) -> ExternalIdsInfo:
|
|
"""Extract configured external release ids from MusicBrainz URLs.
|
|
|
|
MusicBrainz releases can include `url_relations` pointing to third-party
|
|
sites (for example Bandcamp or Discogs). This helper filters those URL
|
|
relations to only the sources enabled in configuration, then derives a
|
|
stable external identifier from each matching URL.
|
|
"""
|
|
external_ids = self.config["external_ids"].get()
|
|
wanted_sources: set[UrlSource] = {
|
|
site for site, wanted in external_ids.items() if wanted
|
|
}
|
|
url_by_source: dict[UrlSource, str] = {}
|
|
for source, url_relation in product(wanted_sources, url_relations):
|
|
if f"{source}.com" in (target := url_relation["url"]["resource"]):
|
|
url_by_source[source] = target
|
|
self._log.debug(
|
|
"Found link to {} release via MusicBrainz",
|
|
source.capitalize(),
|
|
)
|
|
|
|
return {
|
|
f"{source}_album_id": extract_release_id(source, url)
|
|
for source, url in url_by_source.items()
|
|
} # type: ignore[return-value]
|
|
|
|
def get_tracks_from_medium(self, medium: Medium) -> Iterable[TrackInfo]:
|
|
all_tracks = []
|
|
if pregap := medium.get("pregap"):
|
|
all_tracks.append(pregap)
|
|
|
|
all_tracks.extend(medium.get("tracks", []))
|
|
|
|
if not self.ignore_data_tracks:
|
|
all_tracks.extend(medium.get("data_tracks", []))
|
|
|
|
medium_data = {
|
|
"medium": medium["position"],
|
|
"medium_total": medium["track_count"],
|
|
"disctitle": medium["title"],
|
|
"media": medium["format"],
|
|
}
|
|
valid_tracks = [
|
|
t
|
|
for t in all_tracks
|
|
if (
|
|
# skip data tracks without titles
|
|
t["recording"]["title"] != "[data track]"
|
|
# and video tracks if we're configured to ignore them
|
|
and not (self.ignore_video_tracks and t["recording"]["video"])
|
|
)
|
|
]
|
|
for track in valid_tracks:
|
|
# make a copy since we need to modify it with track-level overrides
|
|
recording = track["recording"].copy()
|
|
# Prefer track data, where present, over recording data.
|
|
recording["length"] = track["length"] or recording["length"]
|
|
recording["artist_credit"] = (
|
|
track["artist_credit"] or recording["artist_credit"]
|
|
)
|
|
if track["title"] and not _preferred_alias(recording["aliases"]):
|
|
recording["title"] = track["title"]
|
|
|
|
ti = self.track_info(recording)
|
|
ti.update(
|
|
medium_index=int(track["position"]),
|
|
release_track_id=track["id"],
|
|
track_alt=track["number"],
|
|
**medium_data,
|
|
)
|
|
|
|
yield ti
|
|
|
|
def _ensure_complete_recordings(self, release: Release) -> None:
|
|
"""Patch a release's tracks with full recording data from the API.
|
|
|
|
The MusicBrainz API silently omits relation data for releases
|
|
exceeding a track threshold. This method detects that case and
|
|
re-fetches recordings in paginated chunks, then mutates the
|
|
release in-place so callers always see complete data.
|
|
"""
|
|
track_count = sum(len(m.get("tracks", [])) for m in release["media"])
|
|
if track_count > BROWSE_MAXTRACKS:
|
|
self._log.debug("Album {} has too many tracks", release["id"])
|
|
recordings: list[Recording] = []
|
|
for i in range(0, track_count, BROWSE_CHUNKSIZE):
|
|
self._log.debug("Retrieving tracks starting at {}", i)
|
|
recordings.extend(
|
|
self.mb_api.browse_recordings(
|
|
release=release["id"],
|
|
limit=BROWSE_CHUNKSIZE,
|
|
includes=BROWSE_INCLUDES,
|
|
offset=i,
|
|
)
|
|
)
|
|
recording_by_id = {r["id"]: r for r in recordings}
|
|
for medium in release["media"]:
|
|
for track in medium["tracks"]:
|
|
track["recording"] = recording_by_id[
|
|
track["recording"]["id"]
|
|
]
|
|
|
|
def album_info(self, release: Release) -> AlbumInfo:
|
|
"""Takes a MusicBrainz release result dictionary and returns a beets
|
|
AlbumInfo object containing the interesting data about that release.
|
|
"""
|
|
self._ensure_complete_recordings(release)
|
|
|
|
# Basic info.
|
|
valid_media = [
|
|
m for m in release["media"] if m["format"] not in self.ignored_media
|
|
]
|
|
track_infos: list[TrackInfo] = []
|
|
for medium in valid_media:
|
|
track_infos.extend(self.get_tracks_from_medium(medium))
|
|
|
|
for index, track_info in enumerate(track_infos, 1):
|
|
track_info.index = index
|
|
|
|
release_title = _key_with_preferred_alias(release, key="title")
|
|
info = AlbumInfo(
|
|
**self._parse_artist_credits(release["artist_credit"]),
|
|
album=release_title,
|
|
album_id=release["id"],
|
|
tracks=track_infos,
|
|
media=(
|
|
medias.pop()
|
|
if len(medias := {t.media for t in track_infos}) == 1
|
|
else "Media"
|
|
),
|
|
mediums=len(release["media"]),
|
|
data_source=self.data_source,
|
|
data_url=urljoin(BASE_URL, f"release/{release['id']}"),
|
|
barcode=release.get("barcode"),
|
|
genres=self._parse_genres(release),
|
|
script=release["text_representation"]["script"],
|
|
language=release["text_representation"]["language"],
|
|
asin=release["asin"],
|
|
albumstatus=release["status"],
|
|
albumdisambig=release["disambiguation"] or None,
|
|
**self._parse_release_group(release["release_group"]),
|
|
**self._parse_label_infos(release["label_info"]),
|
|
**self._parse_external_ids(release.get("url_relations", [])),
|
|
)
|
|
info.va = info.artist_id == VARIOUS_ARTISTS_ID
|
|
if info.va:
|
|
va_name = config["va_name"].as_str()
|
|
info.artist = va_name
|
|
info.artist_sort = va_name
|
|
info.artists = [va_name]
|
|
info.artists_sort = [va_name]
|
|
info.artist_credit = va_name
|
|
info.artists_credit = [va_name]
|
|
|
|
# Release events.
|
|
info.country, release_date = _preferred_release_event(release)
|
|
info.year, info.month, info.day = (
|
|
_get_date(release_date)
|
|
if release_date
|
|
else (info.original_year, info.original_month, info.original_day)
|
|
)
|
|
|
|
extra_albumdatas = plugins.send("mb_album_extract", data=release)
|
|
for extra_albumdata in extra_albumdatas:
|
|
info.update(extra_albumdata)
|
|
|
|
return info
|
|
|
|
@cached_property
|
|
def extra_mb_field_by_tag(self) -> dict[str, str]:
|
|
"""Map configured extra tags to their MusicBrainz API field names.
|
|
|
|
Process user configuration to determine which additional MusicBrainz
|
|
fields should be included in search queries.
|
|
"""
|
|
mb_field_by_tag = {
|
|
t: FIELDS_TO_MB_KEYS[t]
|
|
for t in self.config["extra_tags"].as_str_seq()
|
|
if t in FIELDS_TO_MB_KEYS
|
|
}
|
|
if mb_field_by_tag:
|
|
self._log.debug("Additional search terms: {}", mb_field_by_tag)
|
|
|
|
return mb_field_by_tag
|
|
|
|
def get_album_criteria(
|
|
self, items: Sequence[Item], artist: str, album: str, va_likely: bool
|
|
) -> dict[str, str]:
|
|
criteria = {"release": album} | (
|
|
{"arid": VARIOUS_ARTISTS_ID} if va_likely else {"artist": artist}
|
|
)
|
|
|
|
for tag, mb_field in self.extra_mb_field_by_tag.items():
|
|
if tag == "tracks":
|
|
value = str(len(items))
|
|
elif tag == "alias":
|
|
value = album
|
|
else:
|
|
most_common, _ = util.plurality(i.get(tag) for i in items)
|
|
value = str(most_common)
|
|
if tag == "catalognum":
|
|
value = value.replace(" ", "")
|
|
|
|
criteria[mb_field] = value
|
|
|
|
return criteria
|
|
|
|
def get_search_query_with_filters(
|
|
self,
|
|
query_type: QueryType,
|
|
items: Sequence[Item],
|
|
artist: str,
|
|
name: str,
|
|
va_likely: bool,
|
|
) -> tuple[str, dict[str, str]]:
|
|
"""Build MusicBrainz criteria filters for album and recording search."""
|
|
|
|
if query_type == "album":
|
|
criteria = self.get_album_criteria(items, artist, name, va_likely)
|
|
else:
|
|
criteria = {"artist": artist, "recording": name, "alias": name}
|
|
|
|
return "", {
|
|
k: _v for k, v in criteria.items() if (_v := v.lower().strip())
|
|
}
|
|
|
|
def get_search_response(self, params: SearchParams) -> Sequence[IDResponse]:
|
|
"""Search MusicBrainz and return release or recording result mappings."""
|
|
|
|
mb_entity: Literal["release", "recording"] = (
|
|
"release" if params.query_type == "album" else "recording"
|
|
)
|
|
return self.mb_api.search(
|
|
mb_entity, dict(params.filters), limit=params.limit
|
|
)
|
|
|
|
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.
|
|
"""
|
|
self._log.debug("Requesting MusicBrainz release {}", album_id)
|
|
if not (albumid := self._extract_id(album_id)):
|
|
self._log.debug("Invalid MBID ({}).", album_id)
|
|
return None
|
|
|
|
# A 404 error here is fine. e.g. re-importing a release that has
|
|
# been deleted on MusicBrainz.
|
|
try:
|
|
original_release = self.mb_api.get_release(albumid)
|
|
except HTTPNotFoundError:
|
|
self._log.debug("Release {} not found on MusicBrainz.", albumid)
|
|
return None
|
|
|
|
album = self.album_info(original_release)
|
|
|
|
if original_release["status"] == "Pseudo-Release":
|
|
linked_releases = (
|
|
rel
|
|
for rel in original_release.get("release_relations", [])
|
|
if (
|
|
rel["type"] == "transl-tracklisting"
|
|
and rel["direction"] == "backward"
|
|
)
|
|
)
|
|
if rel := next(linked_releases, None):
|
|
actual_release = self.mb_api.get_release(rel["release"]["id"])
|
|
album = _merge_pseudo_and_actual_album(
|
|
album, self.album_info(actual_release)
|
|
)
|
|
|
|
return album
|
|
|
|
def track_for_id(self, track_id: str) -> TrackInfo | None:
|
|
"""Fetches a track by its MusicBrainz ID. Returns a TrackInfo object
|
|
or None if no track is found. May raise a MusicBrainzAPIError.
|
|
"""
|
|
if not (trackid := self._extract_id(track_id)):
|
|
self._log.debug("Invalid MBID ({}).", track_id)
|
|
return None
|
|
|
|
with suppress(HTTPNotFoundError):
|
|
return self.track_info(self.mb_api.get_recording(trackid))
|
|
|
|
return None
|