This commit is contained in:
Šarūnas Nejus 2026-02-01 16:15:07 +00:00 committed by GitHub
commit d63c82d16c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2176 additions and 1728 deletions

View file

@ -86,6 +86,406 @@ P = ParamSpec("P")
R = TypeVar("R")
class _Period(TypedDict):
begin: str | None
end: str | None
ended: bool
class Alias(_Period):
locale: str | None
name: str
primary: bool | None
sort_name: str
type: (
Literal[
"Artist name",
"Label name",
"Legal name",
"Recording name",
"Release name",
"Release group name",
"Search hint",
]
| None
)
type_id: str | None
class Artist(TypedDict):
country: str | None
disambiguation: str
id: str
name: str
sort_name: str
type: (
Literal["Character", "Choir", "Group", "Orchestra", "Other", "Person"]
| None
)
type_id: str | None
aliases: NotRequired[list[Alias]]
genres: NotRequired[list[Genre]]
tags: NotRequired[list[Tag]]
class ArtistCredit(TypedDict):
artist: Artist
joinphrase: str
name: str
class Genre(TypedDict):
count: int
disambiguation: str
id: str
name: str
class Tag(TypedDict):
count: int
name: str
ReleaseStatus = Literal[
"Bootleg",
"Cancelled",
"Expunged",
"Official",
"Promotion",
"Pseudo-Release",
"Withdrawn",
]
ReleasePackaging = Literal[
"Book",
"Box",
"Cardboard/Paper Sleeve",
"Cassette Case",
"Clamshell Case",
"Digibook",
"Digifile",
"Digipak",
"Discbox Slider",
"Fatbox",
"Gatefold Cover",
"Jewel Case",
"None",
"Keep Case",
"Longbox",
"Metal Tin",
"Other",
"Plastic Sleeve",
"Slidepack",
"Slipcase",
"Snap Case",
"SnapPack",
"Slim Jewel Case",
"Super Jewel Box",
]
ReleaseQuality = Literal["high", "low", "normal"]
class ReleaseGroup(TypedDict):
aliases: list[Alias]
artist_credit: list[ArtistCredit]
disambiguation: str
first_release_date: str
genres: list[Genre]
id: str
primary_type: Literal["Album", "Broadcast", "EP", "Other", "Single"] | None
primary_type_id: str | None
secondary_type_ids: list[str]
secondary_types: list[
Literal[
"Audiobook",
"Audio drama",
"Compilation",
"DJ-mix",
"Demo",
"Field recording",
"Interview",
"Live",
"Mixtape/Street",
"Remix",
"Soundtrack",
"Spokenword",
]
]
tags: list[Tag]
title: str
class CoverArtArchive(TypedDict):
artwork: bool
back: bool
count: int
darkened: bool
front: bool
class TextRepresentation(TypedDict):
language: str | None
script: str | None
class Area(TypedDict):
disambiguation: str
id: str
iso_3166_1_codes: list[str]
iso_3166_2_codes: NotRequired[list[str]]
name: str
sort_name: str
type: None
type_id: None
class ReleaseEvent(TypedDict):
area: Area | None
date: str
class Label(TypedDict):
aliases: list[Alias]
disambiguation: str
genres: list[Genre]
id: str
label_code: int | None
name: str
sort_name: str
tags: list[Tag]
type: (
Literal[
"Bootleg Production",
"Broadcaster",
"Distributor",
"Holding",
"Imprint",
"Manufacturer",
"Original Production",
"Publisher",
"Reissue Production",
"Rights Society",
]
| None
)
type_id: str | None
class LabelInfo(TypedDict):
catalog_number: str | None
label: Label
class Url(TypedDict):
id: str
resource: str
class RelationBase(_Period):
attribute_ids: dict[str, str]
attribute_values: dict[str, str]
attributes: list[str]
direction: Literal["backward", "forward"]
source_credit: str
target_credit: str
type_id: str
ArtistRelationType = Literal[
"arranger",
"art direction",
"artwork",
"composer",
"conductor",
"copyright",
"design",
"design/illustration",
"editor",
"engineer",
"graphic design",
"illustration",
"instrument",
"instrument arranger",
"liner notes",
"lyricist",
"mastering",
"misc",
"mix",
"mix-DJ",
"performer",
"phonographic copyright",
"photography",
"previous attribution",
"producer",
"programming",
"recording",
"remixer",
"sound",
"vocal",
"vocal arranger",
"writer",
]
class ArtistRelation(RelationBase):
type: ArtistRelationType
artist: Artist
attribute_credits: NotRequired[dict[str, str]]
class UrlRelation(RelationBase):
type: Literal[
"IMDB samples",
"IMDb",
"allmusic",
"amazon asin",
"discography entry",
"discogs",
"download for free",
"fanpage",
"free streaming",
"lyrics",
"other databases",
"purchase for download",
"purchase for mail-order",
"secondhandsongs",
"show notes",
"songfacts",
"streaming",
"wikidata",
"wikipedia",
]
url: Url
class WorkRelation(RelationBase):
type: Literal[
"adaptation",
"arrangement",
"based on",
"included works",
"lyrical quotation",
"medley",
"musical quotation",
"named after work",
"orchestration",
"other version",
"parts",
"revision of",
]
ordering_key: NotRequired[int]
work: Work
class Work(TypedDict):
attributes: list[str]
disambiguation: str
id: str
iswcs: list[str]
language: str | None
languages: list[str]
title: str
type: str | None
type_id: str | None
artist_relations: NotRequired[list[ArtistRelation]]
url_relations: NotRequired[list[UrlRelation]]
work_relations: NotRequired[list[WorkRelation]]
class Recording(TypedDict):
aliases: list[Alias]
artist_credit: list[ArtistCredit]
disambiguation: str
id: str
isrcs: list[str]
length: int | None
title: str
video: bool
artist_relations: NotRequired[list[ArtistRelation]]
first_release_date: NotRequired[str]
genres: NotRequired[list[Genre]]
tags: NotRequired[list[Tag]]
url_relations: NotRequired[list[UrlRelation]]
work_relations: NotRequired[list[WorkRelation]]
class Track(TypedDict):
artist_credit: list[ArtistCredit]
id: str
length: int | None
number: str
position: int
recording: Recording
title: str
class Medium(TypedDict):
format: str | None
format_id: str | None
id: str
position: int
title: str
track_count: int
data_tracks: NotRequired[list[Track]]
pregap: NotRequired[Track]
track_offset: NotRequired[int]
tracks: NotRequired[list[Track]]
class ReleaseRelationRelease(TypedDict):
artist_credit: list[ArtistCredit]
barcode: str | None
country: str | None
date: str
disambiguation: str
id: str
media: list[Medium]
packaging: ReleasePackaging | None
packaging_id: str | None
quality: ReleaseQuality
release_events: list[ReleaseEvent]
release_group: ReleaseGroup
status: ReleaseStatus | None
status_id: str | None
text_representation: TextRepresentation
title: str
class ReleaseRelation(RelationBase):
type: Literal["remaster", "transl-tracklisting", "replaced by"]
release: ReleaseRelationRelease
class Release(TypedDict):
aliases: list[Alias]
artist_credit: list[ArtistCredit]
asin: str | None
barcode: str | None
cover_art_archive: CoverArtArchive
disambiguation: str
genres: list[Genre]
id: str
label_info: list[LabelInfo]
media: list[Medium]
packaging: ReleasePackaging | None
packaging_id: str | None
quality: ReleaseQuality
release_group: ReleaseGroup
status: ReleaseStatus | None
status_id: str | None
tags: list[Tag]
text_representation: TextRepresentation
title: str
artist_relations: NotRequired[list[ArtistRelation]]
country: NotRequired[str | None]
date: NotRequired[str]
release_events: NotRequired[list[ReleaseEvent]]
release_relations: NotRequired[list[ReleaseRelation]]
url_relations: NotRequired[list[UrlRelation]]
def require_one_of(*keys: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
required = frozenset(keys)
@ -169,16 +569,16 @@ class MusicBrainzAPI(RequestHandler):
if includes:
kwargs["inc"] = "+".join(includes)
return self._group_relations(
return self._normalize_data(
self.get_json(f"{self.api_root}/{resource}", params=kwargs)
)
def _lookup(
self, entity: Entity, id_: str, **kwargs: Unpack[LookupKwargs]
) -> JSONDict:
) -> Any:
return self._get_resource(f"{entity}/{id_}", **kwargs)
def _browse(self, entity: Entity, **kwargs) -> list[JSONDict]:
def _browse(self, entity: Entity, **kwargs) -> list[Any]:
return self._get_resource(entity, **kwargs).get(f"{entity}s", [])
def search(
@ -204,24 +604,24 @@ class MusicBrainzAPI(RequestHandler):
kwargs["query"] = query
return self._get_resource(entity, **kwargs)[f"{entity}s"]
def get_release(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict:
def get_release(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> Release:
"""Retrieve a release by its MusicBrainz ID."""
return self._lookup("release", id_, **kwargs)
def get_recording(
self, id_: str, **kwargs: Unpack[LookupKwargs]
) -> JSONDict:
) -> Recording:
"""Retrieve a recording by its MusicBrainz ID."""
return self._lookup("recording", id_, **kwargs)
def get_work(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict:
def get_work(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> Work:
"""Retrieve a work by its MusicBrainz ID."""
return self._lookup("work", id_, **kwargs)
@require_one_of("artist", "collection", "release", "work")
def browse_recordings(
self, **kwargs: Unpack[BrowseRecordingsKwargs]
) -> list[JSONDict]:
) -> list[Recording]:
"""Browse recordings related to the given entities.
At least one of artist, collection, release, or work must be provided.
@ -231,7 +631,7 @@ class MusicBrainzAPI(RequestHandler):
@require_one_of("artist", "collection", "release")
def browse_release_groups(
self, **kwargs: Unpack[BrowseReleaseGroupsKwargs]
) -> list[JSONDict]:
) -> list[ReleaseGroup]:
"""Browse release groups related to the given entities.
At least one of artist, collection, or release must be provided.
@ -240,29 +640,39 @@ class MusicBrainzAPI(RequestHandler):
@singledispatchmethod
@classmethod
def _group_relations(cls, data: Any) -> Any:
"""Normalize MusicBrainz 'relations' into type-keyed fields recursively.
def _normalize_data(cls, data: Any) -> Any:
"""Normalize MusicBrainz relation structures into easier-to-use shapes.
This helper rewrites payloads that use a generic 'relations' list into
a structure that is easier to consume downstream. When a mapping
contains 'relations', those entries are regrouped by their 'target-type'
and stored under keys like '<target-type>-relations'. The original
'relations' key is removed to avoid ambiguous access patterns.
The transformation is applied recursively so that nested objects and
sequences are normalized consistently, while non-container values are
left unchanged.
This default handler is a no-op that returns non-container values
unchanged. Specialized handlers for sequences and mappings perform the
actual transformations described below.
"""
return data
@_group_relations.register(list)
@_normalize_data.register(list)
@classmethod
def _(cls, data: list[Any]) -> list[Any]:
return [cls._group_relations(i) for i in data]
"""Apply normalization to each element of a sequence recursively.
@_group_relations.register(dict)
Sequences received from the MusicBrainz API may contain nested mappings
that require transformation. This handler maps the normalization step
over the sequence and preserves order.
"""
return [cls._normalize_data(i) for i in data]
@_normalize_data.register(dict)
@classmethod
def _(cls, data: JSONDict) -> JSONDict:
"""Transform mappings by regrouping relationships and normalizing keys.
When a mapping contains a generic 'relations' list, entries are grouped
by their 'target-type' and placed under keys like
'<target-type>_relations' with the 'target-type' field removed from each
entry. All other mapping keys have hyphens converted to underscores and
their values are normalized recursively to ensure a consistent shape
throughout the payload.
"""
output_data = {}
for k, v in list(data.items()):
if k == "relations":
get_target_type = operator.methodcaller("get", "target-type")
@ -273,13 +683,12 @@ class MusicBrainzAPI(RequestHandler):
{k: v for k, v in item.items() if k != "target-type"}
for item in group
]
data[f"{target_type}-relations"] = cls._group_relations(
relations
output_data[f"{target_type}_relations"] = (
cls._normalize_data(relations)
)
data.pop("relations")
else:
data[k] = cls._group_relations(v)
return data
output_data[k.replace("-", "_")] = cls._normalize_data(v)
return output_data
class MusicBrainzAPIMixin:

View file

@ -43,6 +43,12 @@ if TYPE_CHECKING:
from beets.library import Item
from beetsplug._typing import JSONDict
from ._utils.musicbrainz import (
Release,
ReleaseRelation,
ReleaseRelationRelease,
)
_STATUS_PSEUDO = "Pseudo-Release"
@ -133,7 +139,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
yield album_info
@override
def album_info(self, release: JSONDict) -> AlbumInfo:
def album_info(self, release: Release) -> AlbumInfo:
official_release = super().album_info(release)
if release.get("status") == _STATUS_PSEUDO:
@ -161,22 +167,22 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
else:
return official_release
def _intercept_mb_release(self, data: JSONDict) -> list[str]:
def _intercept_mb_release(self, data: Release) -> 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", [])
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:
def _has_desired_script(self, release: Release) -> bool:
if len(self._scripts) == 0:
return False
elif script := release.get("text-representation", {}).get("script"):
elif script := release.get("text_representation", {}).get("script"):
return script in self._scripts
else:
return False
@ -184,7 +190,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
def _wanted_pseudo_release_id(
self,
album_id: str,
relation: JSONDict,
relation: ReleaseRelation,
) -> str | None:
if (
len(self._scripts) == 0
@ -216,9 +222,9 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
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", []
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", [])

View file

@ -16,14 +16,15 @@
from __future__ import annotations
from collections import Counter
from collections import defaultdict
from contextlib import suppress
from functools import cached_property
from itertools import product
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Literal, TypedDict
from urllib.parse import urljoin
from confuse.exceptions import NotFoundError
from typing_extensions import NotRequired
import beets
import beets.autotag.hooks
@ -37,18 +38,27 @@ from ._utils.requests import HTTPNotFoundError
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from typing import Literal
from beets.library import Item
from ._typing import JSONDict
from ._utils.musicbrainz import (
Alias,
ArtistCredit,
ArtistRelation,
ArtistRelationType,
LabelInfo,
Medium,
Recording,
Release,
ReleaseGroup,
UrlRelation,
)
VARIOUS_ARTISTS_ID = "89ad4ac3-39f7-470e-963a-56509c546377"
BASE_URL = "https://musicbrainz.org/"
SKIPPED_TRACKS = ["[data track]"]
FIELDS_TO_MB_KEYS = {
"barcode": "barcode",
"catalognum": "catno",
@ -97,187 +107,116 @@ 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]
def _preferred_alias(
aliases: list[JSONDict], languages: list[str] | None = None
) -> JSONDict | None:
"""Given a list of alias structures for an artist credit, select
and return the user's preferred alias or None if no matching
"""
aliases: list[Alias], languages: list[str] | None = None
) -> Alias | None:
"""Select the most appropriate alias based on user preferences."""
if not aliases:
return None
# Only consider aliases that have locales set.
valid_aliases = [a for a in aliases if "locale" in a]
# Get any ignored alias types and lower case them to prevent case issues
ignored_alias_types = config["import"]["ignored_alias_types"].as_str_seq()
ignored_alias_types = [a.lower() for a in ignored_alias_types]
ignored_alias_types = {
a.lower() for a in config["import"]["ignored_alias_types"].as_str_seq()
}
# Search configured locales in order.
if languages is None:
languages = config["import"]["languages"].as_str_seq()
languages = languages or config["import"]["languages"].as_str_seq()
for locale in languages:
matches = (
al
for locale in languages
for al in aliases
# Find matching primary aliases for this locale that are not
# being ignored
matches = []
for alias in valid_aliases:
if (
alias["locale"] == locale
and alias.get("primary")
and (alias.get("type") or "").lower() not in ignored_alias_types
):
matches.append(alias)
# Skip to the next locale if we have no matches
if not matches:
continue
return matches[0]
return None
if (
al["locale"] == locale
and al["primary"]
and (al["type"] or "").lower() not in ignored_alias_types
)
)
return next(matches, None)
def _multi_artist_credit(
credit: list[JSONDict], include_join_phrase: bool
) -> tuple[list[str], list[str], list[str]]:
"""Given a list representing an ``artist-credit`` block, accumulate
data into a triple of joined artist name lists: canonical, sort, and
credit.
"""
artist_parts = []
artist_sort_parts = []
artist_credit_parts = []
for el in credit:
alias = _preferred_alias(el["artist"].get("aliases", ()))
# An artist.
if alias:
cur_artist_name = alias["name"]
else:
cur_artist_name = el["artist"]["name"]
artist_parts.append(cur_artist_name)
# Artist sort name.
if alias:
artist_sort_parts.append(alias["sort-name"])
elif "sort-name" in el["artist"]:
artist_sort_parts.append(el["artist"]["sort-name"])
else:
artist_sort_parts.append(cur_artist_name)
# Artist credit.
if "name" in el:
artist_credit_parts.append(el["name"])
else:
artist_credit_parts.append(cur_artist_name)
if include_join_phrase and (joinphrase := el.get("joinphrase")):
artist_parts.append(joinphrase)
artist_sort_parts.append(joinphrase)
artist_credit_parts.append(joinphrase)
return (
artist_parts,
artist_sort_parts,
artist_credit_parts,
def _get_related_artist_names(
relations: list[ArtistRelation], relation_type: ArtistRelationType
) -> str:
"""Return a comma-separated list of artist names for a relation type."""
return ", ".join(
r["artist"]["name"] for r in relations if r["type"] == relation_type
)
def track_url(trackid: str) -> str:
return urljoin(BASE_URL, f"recording/{trackid}")
def _preferred_release_event(release: Release) -> tuple[str | None, str | None]:
"""Select the most relevant release country and date for matching.
def _flatten_artist_credit(credit: list[JSONDict]) -> tuple[str, str, str]:
"""Given a list representing an ``artist-credit`` block, flatten the
data into a triple of joined artist name strings: canonical, sort, and
credit.
Fall back to the default release event if a preferred event is not found.
"""
artist_parts, artist_sort_parts, artist_credit_parts = _multi_artist_credit(
credit, include_join_phrase=True
)
return (
"".join(artist_parts),
"".join(artist_sort_parts),
"".join(artist_credit_parts),
)
def _artist_ids(credit: list[JSONDict]) -> list[str]:
"""
Given a list representing an ``artist-credit``,
return a list of artist IDs
"""
artist_ids: list[str] = []
for el in credit:
if isinstance(el, dict):
artist_ids.append(el["artist"]["id"])
return artist_ids
def _get_related_artist_names(relations, relation_type):
"""Given a list representing the artist relationships extract the names of
the remixers and concatenate them.
"""
related_artists = []
for relation in relations:
if relation["type"] == relation_type:
related_artists.append(relation["artist"]["name"])
return ", ".join(related_artists)
def album_url(albumid: str) -> str:
return urljoin(BASE_URL, f"release/{albumid}")
def _preferred_release_event(
release: dict[str, Any],
) -> tuple[str | None, str | None]:
"""Given a release, select and return the user's preferred release
event as a tuple of (country, release_date). Fall back to the
default release event if a preferred event is not found.
"""
preferred_countries: Sequence[str] = config["match"]["preferred"][
"countries"
].as_str_seq()
preferred_countries = config["match"]["preferred"]["countries"].as_str_seq()
for country in preferred_countries:
for event in release.get("release-events", {}):
try:
if area := event.get("area"):
if country in area["iso-3166-1-codes"]:
return country, event["date"]
except KeyError:
pass
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 _set_date_str(
info: beets.autotag.hooks.AlbumInfo,
date_str: str,
original: bool = False,
):
"""Given a (possibly partial) YYYY-MM-DD string and an AlbumInfo
object, set the object's release date fields appropriately. If
`original`, then set the original_year, etc., fields.
"""
if date_str:
date_parts = date_str.split("-")
for key in ("year", "month", "day"):
if date_parts:
date_part = date_parts.pop(0)
try:
date_num = int(date_part)
except ValueError:
continue
def _get_date(date_str: str) -> tuple[int | None, int | None, int | None]:
"""Parse a partial `YYYY-MM-DD` string into numeric date parts.
if original:
key = f"original_{key}"
setattr(info, key, date_num)
Missing components are returned as `None`. Invalid components are ignored.
"""
if not date_str:
return None, None, None
parts = list(map(int, 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(
@ -322,10 +261,14 @@ def _merge_pseudo_and_actual_album(
class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
@cached_property
def genres_field(self) -> str:
return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}s"
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"
def __init__(self):
def __init__(self) -> None:
"""Set up the python-musicbrainz-ngs module according to settings
from the beets configuration. This should be called at startup.
"""
@ -355,102 +298,119 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
"'musicbrainz.search_limit'",
)
def track_info(
self,
recording: JSONDict,
index: int | None = None,
medium: int | None = None,
medium_index: int | None = None,
medium_total: int | None = None,
) -> beets.autotag.hooks.TrackInfo:
"""Translates a MusicBrainz recording result dictionary into a beets
``TrackInfo`` object. Three parameters are optional and are used
only for tracks that appear on releases (non-singletons): ``index``,
the overall track number; ``medium``, the disc number;
``medium_index``, the track's index on its medium; ``medium_total``,
the number of tracks on the medium. Each number is a 1-based index.
@staticmethod
def _parse_artist_credits(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
and sort name, while the credit name preserves the exact credited text
from the release.
"""
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"]
joinphrase = el["joinphrase"]
for name, parts, multi in (
(artist_object["name"], artist_parts, artists),
(artist_object["sort_name"], artist_sort_parts, artists_sort),
(el["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,
}
def track_info(self, recording: Recording) -> beets.autotag.hooks.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).
"""
info = beets.autotag.hooks.TrackInfo(
title=recording["title"],
track_id=recording["id"],
index=index,
medium=medium,
medium_index=medium_index,
medium_total=medium_total,
data_source=self.data_source,
data_url=track_url(recording["id"]),
data_url=urljoin(BASE_URL, f"recording/{recording['id']}"),
length=(
int(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"]),
)
if recording.get("artist-credit"):
# Get the artist names.
(
info.artist,
info.artist_sort,
info.artist_credit,
) = _flatten_artist_credit(recording["artist-credit"])
if artist_relations := recording.get("artist_relations"):
if remixer := _get_related_artist_names(
artist_relations, "remixer"
):
info.remixer = remixer
if arranger := _get_related_artist_names(
artist_relations, "arranger"
):
info.arranger = arranger
(
info.artists,
info.artists_sort,
info.artists_credit,
) = _multi_artist_credit(
recording["artist-credit"], include_join_phrase=False
)
info.artists_ids = _artist_ids(recording["artist-credit"])
info.artist_id = info.artists_ids[0]
if recording.get("artist-relations"):
info.remixer = _get_related_artist_names(
recording["artist-relations"], relation_type="remixer"
)
if recording.get("length"):
info.length = int(recording["length"]) / 1000.0
info.trackdisambig = recording.get("disambiguation")
if recording.get("isrcs"):
info.isrc = ";".join(recording["isrcs"])
lyricist = []
composer = []
composer_sort = []
for work_relation in recording.get("work-relations", ()):
lyricist: list[str] = []
composer: list[str] = []
composer_sort: list[str] = []
for work_relation in recording.get("work_relations", ()):
if work_relation["type"] != "performance":
continue
info.work = work_relation["work"]["title"]
info.mb_workid = work_relation["work"]["id"]
if "disambiguation" in work_relation["work"]:
info.work_disambig = work_relation["work"]["disambiguation"]
for artist_relation in work_relation["work"].get(
"artist-relations", ()
):
if "type" in artist_relation:
type = artist_relation["type"]
if type == "lyricist":
lyricist.append(artist_relation["artist"]["name"])
elif type == "composer":
composer.append(artist_relation["artist"]["name"])
composer_sort.append(
artist_relation["artist"]["sort-name"]
)
work = work_relation["work"]
info.work = work["title"]
info.mb_workid = work["id"]
if "disambiguation" in work:
info.work_disambig = work["disambiguation"]
for artist_relation in work.get("artist_relations", ()):
if (rel_type := artist_relation["type"]) == "lyricist":
lyricist.append(artist_relation["artist"]["name"])
elif rel_type == "composer":
composer.append(artist_relation["artist"]["name"])
composer_sort.append(artist_relation["artist"]["sort_name"])
if lyricist:
info.lyricist = ", ".join(lyricist)
if composer:
info.composer = ", ".join(composer)
info.composer_sort = ", ".join(composer_sort)
arranger = []
for artist_relation in recording.get("artist-relations", ()):
if "type" in artist_relation:
type = artist_relation["type"]
if type == "arranger":
arranger.append(artist_relation["artist"]["name"])
if arranger:
info.arranger = ", ".join(arranger)
# Supplementary fields provided by plugins
extra_trackdatas = plugins.send("mb_track_extract", data=recording)
for extra_trackdata in extra_trackdatas:
@ -458,23 +418,141 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
return info
def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo:
@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=release_group["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"]["name"]) != "[no label]":
label = _label
return {"label": label, "catalognum": catalognum}
def _parse_genre(self, release: Release) -> str | None:
if self.config["genres"]:
genres = [
*release["release_group"][self.genres_field],
*release.get(self.genres_field, []),
]
count_by_genre: dict[str, int] = defaultdict(int)
for genre in genres:
count_by_genre[genre["name"]] += int(genre["count"])
return "; ".join(
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]
@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)
def get_tracks_from_medium(
self, medium: Medium
) -> Iterable[beets.autotag.hooks.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 t["recording"]["title"] != "[data track]"
and not (self.ignore_video_tracks and t["recording"]["video"])
]
for track in valid_tracks:
recording = track["recording"]
# Prefer track data, where present, over recording data.
for key in ("title", "artist_credit", "length"):
recording[key] = track[key] or recording[key]
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 album_info(self, release: Release) -> beets.autotag.hooks.AlbumInfo:
"""Takes a MusicBrainz release result dictionary and returns a beets
AlbumInfo object containing the interesting data about that release.
"""
# Get artist name using join phrases.
artist_name, artist_sort_name, artist_credit_name = (
_flatten_artist_credit(release["artist-credit"])
)
(
artists_names,
artists_sort_names,
artists_credit_names,
) = _multi_artist_credit(
release["artist-credit"], include_join_phrase=False
)
ntracks = sum(len(m["tracks"]) for m in release["media"])
# The MusicBrainz API omits 'relations'
@ -482,7 +560,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
# on chunks of tracks to recover the same information in this case.
if ntracks > BROWSE_MAXTRACKS:
self._log.debug("Album {} has too many tracks", release["id"])
recording_list = []
recording_list: list[Recording] = []
for i in range(0, ntracks, BROWSE_CHUNKSIZE):
self._log.debug("Retrieving tracks starting at {}", i)
recording_list.extend(
@ -490,207 +568,63 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
release=release["id"], offset=i
)
)
track_map = {r["id"]: r for r in recording_list}
recording_by_id = {r["id"]: r for r in recording_list}
for medium in release["media"]:
for recording in medium["tracks"]:
recording_info = track_map[recording["recording"]["id"]]
recording["recording"] = recording_info
for track in medium["tracks"]:
track["recording"] = recording_by_id[
track["recording"]["id"]
]
# Basic info.
track_infos = []
index = 0
for medium in release["media"]:
disctitle = medium.get("title")
format = medium.get("format")
valid_media = [
m for m in release["media"] if m["format"] not in self.ignored_media
]
track_infos: list[beets.autotag.hooks.TrackInfo] = []
for medium in valid_media:
track_infos.extend(self.get_tracks_from_medium(medium))
if format in config["match"]["ignored_media"].as_str_seq():
continue
for index, track_info in enumerate(track_infos, 1):
track_info.index = index
all_tracks = medium["tracks"]
if (
"data-tracks" in medium
and not config["match"]["ignore_data_tracks"]
):
all_tracks += medium["data-tracks"]
track_count = len(all_tracks)
if "pregap" in medium:
all_tracks.insert(0, medium["pregap"])
for track in all_tracks:
if (
"title" in track["recording"]
and track["recording"]["title"] in SKIPPED_TRACKS
):
continue
if (
"video" in track["recording"]
and track["recording"]["video"]
and config["match"]["ignore_video_tracks"]
):
continue
# Basic information from the recording.
index += 1
ti = self.track_info(
track["recording"],
index,
int(medium["position"]),
int(track["position"]),
track_count,
)
ti.release_track_id = track["id"]
ti.disctitle = disctitle
ti.media = format
ti.track_alt = track["number"]
# Prefer track data, where present, over recording data.
if track.get("title"):
ti.title = track["title"]
if track.get("artist-credit"):
# Get the artist names.
(
ti.artist,
ti.artist_sort,
ti.artist_credit,
) = _flatten_artist_credit(track["artist-credit"])
(
ti.artists,
ti.artists_sort,
ti.artists_credit,
) = _multi_artist_credit(
track["artist-credit"], include_join_phrase=False
)
ti.artists_ids = _artist_ids(track["artist-credit"])
ti.artist_id = ti.artists_ids[0]
if track.get("length"):
ti.length = int(track["length"]) / (1000.0)
track_infos.append(ti)
album_artist_ids = _artist_ids(release["artist-credit"])
info = beets.autotag.hooks.AlbumInfo(
**self._parse_artist_credits(release["artist_credit"]),
album=release["title"],
album_id=release["id"],
artist=artist_name,
artist_id=album_artist_ids[0],
artists=artists_names,
artists_ids=album_artist_ids,
tracks=track_infos,
media=(
medias.pop()
if len(medias := {t.media for t in track_infos}) == 1
else "Media"
),
mediums=len(release["media"]),
artist_sort=artist_sort_name,
artists_sort=artists_sort_names,
artist_credit=artist_credit_name,
artists_credit=artists_credit_names,
data_source=self.data_source,
data_url=album_url(release["id"]),
data_url=urljoin(BASE_URL, f"release/{release['id']}"),
barcode=release.get("barcode"),
genre=genre if (genre := self._parse_genre(release)) else None,
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:
info.artist = config["va_name"].as_str()
info.asin = release.get("asin")
info.releasegroup_id = release["release-group"]["id"]
info.albumstatus = release.get("status")
if release["release-group"].get("title"):
info.release_group_title = release["release-group"].get("title")
# Get the disambiguation strings at the release and release group level.
if release["release-group"].get("disambiguation"):
info.releasegroupdisambig = release["release-group"].get(
"disambiguation"
)
if release.get("disambiguation"):
info.albumdisambig = release.get("disambiguation")
if reltype := release["release-group"].get("primary-type"):
info.albumtype = reltype.lower()
# Set the new-style "primary" and "secondary" release types.
albumtypes = []
if "primary-type" in release["release-group"]:
rel_primarytype = release["release-group"]["primary-type"]
if rel_primarytype:
albumtypes.append(rel_primarytype.lower())
if "secondary-types" in release["release-group"]:
if release["release-group"]["secondary-types"]:
for sec_type in release["release-group"]["secondary-types"]:
albumtypes.append(sec_type.lower())
info.albumtypes = albumtypes
# Release events.
info.country, release_date = _preferred_release_event(release)
release_group_date = release["release-group"].get("first-release-date")
if not release_date:
# Fall back if release-specific date is not available.
release_date = release_group_date
if release_date:
_set_date_str(info, release_date, False)
_set_date_str(info, release_group_date, True)
# Label name.
if release.get("label-info"):
label_info = release["label-info"][0]
if label_info.get("label"):
label = label_info["label"]["name"]
if label != "[no label]":
info.label = label
info.catalognum = label_info.get("catalog-number")
# Text representation data.
if release.get("text-representation"):
rep = release["text-representation"]
info.script = rep.get("script")
info.language = rep.get("language")
# Media (format).
if release["media"]:
# If all media are the same, use that medium name
if len({m.get("format") for m in release["media"]}) == 1:
info.media = release["media"][0].get("format")
# Otherwise, let's just call it "Media"
else:
info.media = "Media"
if self.config["genres"]:
sources = [
release["release-group"].get(self.genres_field, []),
release.get(self.genres_field, []),
]
genres: Counter[str] = Counter()
for source in sources:
for genreitem in source:
genres[genreitem["name"]] += int(genreitem["count"])
info.genre = "; ".join(
genre
for genre, _count in sorted(genres.items(), key=lambda g: -g[1])
info.year, info.month, info.day = (
_get_date(release_date)
if release_date
else (
info.original_year,
info.original_month,
info.original_day,
)
# We might find links to external sources (Discogs, Bandcamp, ...)
external_ids = self.config["external_ids"].get()
wanted_sources = {
site for site, wanted in external_ids.items() if wanted
}
if wanted_sources and (url_rels := release.get("url-relations")):
urls = {}
for source, url in product(wanted_sources, url_rels):
if f"{source}.com" in (target := url["url"]["resource"]):
urls[source] = target
self._log.debug(
"Found link to {} release via MusicBrainz",
source.capitalize(),
)
for source, url in urls.items():
setattr(
info, f"{source}_album_id", extract_release_id(source, url)
)
)
extra_albumdatas = plugins.send("mb_album_extract", data=release)
for extra_albumdata in extra_albumdatas:
@ -771,10 +705,9 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
self, item: Item, artist: str, title: str
) -> Iterable[beets.autotag.hooks.TrackInfo]:
criteria = {"artist": artist, "recording": title, "alias": title}
ids = (r["id"] for r in self._search_api("recording", criteria))
yield from filter(
None, map(self.track_info, self._search_api("recording", criteria))
)
return filter(None, map(self.track_for_id, ids))
def album_for_id(
self, album_id: str
@ -800,7 +733,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
actual_res = None
if res.get("status") == "Pseudo-Release" and (
relations := res.get("release-relations")
relations := res.get("release_relations")
):
for rel in relations:
if (

77
poetry.lock generated
View file

@ -1000,6 +1000,41 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "factory-boy"
version = "3.3.3"
description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby."
optional = false
python-versions = ">=3.8"
files = [
{file = "factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc"},
{file = "factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03"},
]
[package.dependencies]
Faker = ">=0.7.0"
[package.extras]
dev = ["Django", "Pillow", "SQLAlchemy", "coverage", "flake8", "isort", "mongoengine", "mongomock", "mypy", "tox", "wheel (>=0.32.0)", "zest.releaser[recommended]"]
doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"]
[[package]]
name = "faker"
version = "40.1.2"
description = "Faker is a Python package that generates fake data for you."
optional = false
python-versions = ">=3.10"
files = [
{file = "faker-40.1.2-py3-none-any.whl", hash = "sha256:93503165c165d330260e4379fd6dc07c94da90c611ed3191a0174d2ab9966a42"},
{file = "faker-40.1.2.tar.gz", hash = "sha256:b76a68163aa5f171d260fc24827a8349bc1db672f6a665359e8d0095e8135d30"},
]
[package.dependencies]
tzdata = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras]
tzdata = ["tzdata"]
[[package]]
name = "filelock"
version = "3.20.2"
@ -1228,6 +1263,17 @@ check = ["check-manifest", "flake8", "flake8-black", "flake8-deprecated", "flake
docs = ["docutils", "sphinx (>=5.0)"]
test = ["pytest"]
[[package]]
name = "inflection"
version = "0.5.1"
description = "A port of Ruby on Rails inflector to Python"
optional = false
python-versions = ">=3.5"
files = [
{file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"},
{file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"},
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@ -2894,6 +2940,24 @@ pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-factoryboy"
version = "2.8.1"
description = "Factory Boy support for pytest."
optional = false
python-versions = ">=3.9"
files = [
{file = "pytest_factoryboy-2.8.1-py3-none-any.whl", hash = "sha256:91c762cb236bf34b11efdf2e54bafae33114488235621e8b2c4bd9fd77838784"},
{file = "pytest_factoryboy-2.8.1.tar.gz", hash = "sha256:2221d48b31b8b8ccaa739c6a162fb50a43a4de6dff6043f249d2807a3462548d"},
]
[package.dependencies]
factory_boy = ">=2.10.0"
inflection = "*"
packaging = "*"
pytest = ">=7.0"
typing_extensions = "*"
[[package]]
name = "pytest-flask"
version = "1.3.0"
@ -4482,6 +4546,17 @@ files = [
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "tzdata"
version = "2025.3"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
files = [
{file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"},
{file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"},
]
[[package]]
name = "unidecode"
version = "1.4.0"
@ -4583,4 +4658,4 @@ web = ["flask", "flask-cors"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<4"
content-hash = "f8ce55ae74c5e3c5d1d330582f83dae30ef963a0b8dd8c8b79f16c3bcfdb525a"
content-hash = "4217bac555b65078de110e4a112dec4124d129a87aa28a7239ad0db00ef80be0"

View file

@ -104,6 +104,7 @@ langdetect = "*"
pylast = "*"
pytest = "*"
pytest-cov = "*"
pytest-factoryboy = ">=2.8.1"
pytest-flask = "*"
python-mpd2 = "*"
python3-discogs-client = ">=2.3.15"

View file

@ -53,3 +53,12 @@ check_untyped_defs = true
[[mypy-beets.metadata_plugins]]
disallow_untyped_decorators = true
check_untyped_defs = true
[[mypy-beetsplug.musicbrainz]]
strict = true
[[mypy-beetsplug.mbpseudo]]
strict = true
[[mypy-beetsplug._utils]]
strict = true

View file

@ -0,0 +1,260 @@
import factory
from factory.fuzzy import FuzzyChoice
from beetsplug._utils.musicbrainz import ArtistRelationType
class _SortNameFactory(factory.DictFactory):
name: str
sort_name = factory.LazyAttribute(lambda o: f"{o.name}, The")
class _PeriodFactory(factory.DictFactory):
begin: str | None = None
end: str | None = None
ended = factory.LazyAttribute(lambda obj: obj.end is not None)
class _IdFactory(factory.DictFactory):
class Params:
id_base = 0
index = 1
id = factory.LazyAttribute(
lambda o: f"00000000-0000-0000-0000-{o.id_base + o.index:012d}"
)
class AliasFactory(_SortNameFactory, _PeriodFactory):
class Params:
suffix = ""
locale: str | None = None
name = factory.LazyAttribute(lambda o: f"Alias {o.suffix}")
primary = False
type = "Artist name"
type_id = factory.LazyAttribute(
lambda o: {
"Artist name": "894afba6-2816-3c24-8072-eadb66bd04bc",
"Label name": "3a1a0c48-d885-3b89-87b2-9e8a483c5675",
"Legal name": "d4dcd0c0-b341-3612-a332-c0ce797b25cf",
"Recording name": "5d564c8f-97de-3572-94bb-7f40ad661499",
"Release group name": "156e24ca-8746-3cfc-99ae-0a867c765c67",
"Release name": "df187855-059b-3514-9d5e-d240de0b4228",
"Search hint": "abc2db8a-7386-354d-82f4-252c0213cbe4",
}[o.type]
)
class ArtistFactory(_SortNameFactory, _IdFactory):
class Params:
id_base = 0
country: str | None = None
disambiguation = ""
name = "Artist"
type = "Person"
type_id = "b6e035f4-3ce9-331c-97df-83397230b0df"
class ArtistCreditFactory(factory.DictFactory):
artist = factory.SubFactory(ArtistFactory)
joinphrase = ""
name = factory.LazyAttribute(lambda o: f"{o.artist['name']} Credit")
class ArtistRelationFactory(_PeriodFactory):
artist = factory.SubFactory(
ArtistFactory,
name=factory.LazyAttribute(
lambda o: f"{o.factory_parent.type.capitalize()} Artist"
),
)
attribute_ids = factory.Dict({})
attribute_credits = factory.Dict({})
attributes = factory.List([])
direction = "backward"
source_credit = ""
target_credit = ""
type = FuzzyChoice(ArtistRelationType.__args__) # type: ignore[attr-defined]
type_id = factory.Faker("uuid4")
class AreaFactory(factory.DictFactory):
disambiguation = ""
id = factory.Faker("uuid4")
iso_3166_1_codes = factory.List([])
iso_3166_2_codes = factory.List([])
name = "Area"
sort_name = "Area, The"
type: None = None
type_id: None = None
class ReleaseEventFactory(factory.DictFactory):
area = factory.SubFactory(AreaFactory)
date = factory.Faker("date")
class ReleaseGroupFactory(_IdFactory):
class Params:
id_base = 100
aliases = factory.List(
[factory.SubFactory(AliasFactory, type="Release group name")]
)
artist_credit = factory.List([factory.SubFactory(ArtistCreditFactory)])
disambiguation = factory.LazyAttribute(
lambda o: f"{o.title} Disambiguation"
)
first_release_date = factory.Faker("date")
genres = factory.List([])
primary_type = "Album"
primary_type_id = "f529b476-6e62-324f-b0aa-1f3e33d313fc"
secondary_type_ids = factory.List([])
secondary_types = factory.List([])
tags = factory.List([])
title = "Release Group"
class GenreFactory(factory.DictFactory):
id = factory.Faker("uuid4")
count = 1
disambiguation = ""
name = "Genre"
class TagFactory(GenreFactory):
name = "Tag"
class LabelFactory(_SortNameFactory, _IdFactory):
aliases = factory.List([])
disambiguation = ""
genres = factory.List([])
label_code: str | None = None
name = "Label"
tags = factory.List([])
type = "Imprint"
type_id = "b6285b2a-3514-3d43-80df-fcf528824ded"
class LabelInfoFactory(factory.DictFactory):
catalog_number = factory.LazyAttribute(
lambda o: f"{o.label['name'][:3].upper()}123"
)
label = factory.SubFactory(LabelFactory)
class TextRepresentationFactory(factory.DictFactory):
language = "eng"
script = "Latn"
class RecordingFactory(_IdFactory):
class Params:
id_base = 1000
aliases = factory.List([])
artist_credit = factory.List(
[
factory.SubFactory(
ArtistCreditFactory, artist__name="Recording Artist"
)
]
)
disambiguation = ""
isrcs = factory.List([])
length = 360
title = "Recording"
video = False
genres = factory.List([])
tags = factory.List([])
class TrackFactory(_IdFactory):
class Params:
id_base = 10000
artist_credit = factory.List([])
length = factory.LazyAttribute(lambda o: o.recording["length"])
number = "A1"
position = 1
recording = factory.SubFactory(RecordingFactory)
title = factory.LazyAttribute(
lambda o: f"{'Video: ' if o.recording['video'] else ''}{o.recording['title']}" # noqa: E501
)
class MediumFactory(_IdFactory):
class Params:
id_base = 100000
format = "Digital Media"
format_id = "907a28d9-b3b2-3ef6-89a8-7b18d91d4794"
position = 1
title = "Medium"
track_count = 1
data_tracks = factory.List([])
track_offset: int | None = None
@factory.post_generation
def tracks(self, create, _tracks, **kwargs):
if not create:
return
if not _tracks:
_tracks = [TrackFactory() for _ in range(kwargs.get("count", 1))]
for index, track in enumerate(_tracks, 1):
track["position"] = index
self["tracks"] = _tracks # type: ignore[index]
class ReleaseFactory(_IdFactory):
class Params:
id_base = 1000000
aliases = factory.List([])
artist_credit = factory.List(
[factory.SubFactory(ArtistCreditFactory, artist__id_base=10)]
)
asin = factory.LazyAttribute(lambda o: f"{o.title} Asin")
barcode = "0000000000000"
cover_art_archive = factory.Dict(
{
"artwork": False,
"back": False,
"count": 0,
"darkened": False,
"front": False,
}
)
disambiguation = factory.LazyAttribute(
lambda o: f"{o.title} Disambiguation"
)
genres = factory.List([factory.SubFactory(GenreFactory)])
label_info = factory.List([factory.SubFactory(LabelInfoFactory)])
media = factory.List([factory.SubFactory(MediumFactory)])
packaging: str | None = None
packaging_id: str | None = None
quality = "normal"
release_events = factory.List(
[
factory.SubFactory(
ReleaseEventFactory, area=None, date="2021-03-26"
),
factory.SubFactory(
ReleaseEventFactory,
area__iso_3166_1_codes=["US"],
date="2020-01-01",
),
]
)
release_group = factory.SubFactory(ReleaseGroupFactory)
status = "Official"
status_id = "4e304316-386d-3409-af2e-78857eec5cfe"
tags = factory.List([factory.SubFactory(TagFactory)])
text_representation = factory.SubFactory(TextRepresentationFactory)
title = "Album"

View file

@ -163,7 +163,7 @@ class TestMBPseudoPlugin(TestMBPseudoMixin):
official_release: JSONDict,
json_key: str,
):
del official_release["release-relations"][0][json_key]
del official_release["release_relations"][0][json_key]
album_info = mbpseudo_plugin.album_info(official_release)
assert not isinstance(album_info, PseudoAlbumInfo)
@ -174,8 +174,8 @@ class TestMBPseudoPlugin(TestMBPseudoMixin):
mbpseudo_plugin: MusicBrainzPseudoReleasePlugin,
official_release: JSONDict,
):
official_release["release-relations"][0]["release"][
"text-representation"
official_release["release_relations"][0]["release"][
"text_representation"
]["script"] = "Null"
album_info = mbpseudo_plugin.album_info(official_release)

File diff suppressed because it is too large Load diff

View file

@ -1,26 +1,44 @@
from beetsplug._utils.musicbrainz import MusicBrainzAPI
def test_group_relations():
def test_normalize_data():
raw_release = {
"id": "r1",
"relations": [
{"target-type": "artist", "type": "vocal", "name": "A"},
{"target-type": "url", "type": "streaming", "url": "http://s"},
{"target-type": "url", "type": "purchase", "url": "http://p"},
{
"target-type": "artist",
"type": "vocal",
"type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa",
"name": "A",
},
{
"target-type": "url",
"type": "streaming",
"type-id": "b5f3058a-666c-406f-aafb-f9249fc7b122",
"url": "http://s",
},
{
"target-type": "url",
"type": "purchase for download",
"type-id": "92777657-504c-4acb-bd33-51a201bd57e1",
"url": "http://p",
},
{
"target-type": "work",
"type": "performance",
"type-id": "a3005666-a872-32c3-ad06-98af558e99b0",
"work": {
"relations": [
{
"artist": {"name": "幾田りら"},
"target-type": "artist",
"type": "composer",
"type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f",
},
{
"target-type": "url",
"type": "lyrics",
"type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
"url": {
"resource": "https://utaten.com/lyric/tt24121002/"
},
@ -29,10 +47,12 @@ def test_group_relations():
"artist": {"name": "幾田りら"},
"target-type": "artist",
"type": "lyricist",
"type-id": "3e48faba-ec01-47fd-8e89-30e81161661c",
},
{
"target-type": "url",
"type": "lyrics",
"type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
"url": {
"resource": "https://www.uta-net.com/song/366579/"
},
@ -45,30 +65,59 @@ def test_group_relations():
],
}
assert MusicBrainzAPI._group_relations(raw_release) == {
assert MusicBrainzAPI._normalize_data(raw_release) == {
"id": "r1",
"artist-relations": [{"type": "vocal", "name": "A"}],
"url-relations": [
{"type": "streaming", "url": "http://s"},
{"type": "purchase", "url": "http://p"},
"artist_relations": [
{
"type": "vocal",
"type_id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa",
"name": "A",
}
],
"work-relations": [
"url_relations": [
{
"type": "streaming",
"type_id": "b5f3058a-666c-406f-aafb-f9249fc7b122",
"url": "http://s",
},
{
"type": "purchase for download",
"type_id": "92777657-504c-4acb-bd33-51a201bd57e1",
"url": "http://p",
},
],
"work_relations": [
{
"type": "performance",
"type_id": "a3005666-a872-32c3-ad06-98af558e99b0",
"work": {
"artist-relations": [
{"type": "composer", "artist": {"name": "幾田りら"}},
{"type": "lyricist", "artist": {"name": "幾田りら"}},
"artist_relations": [
{
"type": "composer",
"type_id": "d59d99ea-23d4-4a80-b066-edca32ee158f",
"artist": {
"name": "幾田りら",
},
},
{
"type": "lyricist",
"type_id": "3e48faba-ec01-47fd-8e89-30e81161661c",
"artist": {
"name": "幾田りら",
},
},
],
"url-relations": [
"url_relations": [
{
"type": "lyrics",
"type_id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
"url": {
"resource": "https://utaten.com/lyric/tt24121002/"
},
},
{
"type": "lyrics",
"type_id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
"url": {
"resource": "https://www.uta-net.com/song/366579/"
},

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"aliases": [],
"artist-credit": [
"artist_credit": [
{
"artist": {
"aliases": [
@ -11,9 +11,9 @@
"locale": "en",
"name": "Lilas Ikuta",
"primary": true,
"sort-name": "Ikuta, Lilas",
"sort_name": "Ikuta, Lilas",
"type": "Artist name",
"type-id": "894afba6-2816-3c24-8072-eadb66bd04bc"
"type_id": "894afba6-2816-3c24-8072-eadb66bd04bc"
}
],
"country": "JP",
@ -34,7 +34,7 @@
],
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"sort_name": "Ikuta, Lilas",
"tags": [
{
"count": 1,
@ -46,7 +46,7 @@
}
],
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"joinphrase": "",
"name": "Lilas Ikuta"
@ -54,7 +54,7 @@
],
"asin": null,
"barcode": null,
"cover-art-archive": {
"cover_art_archive": {
"artwork": false,
"back": false,
"count": 0,
@ -64,19 +64,19 @@
"disambiguation": "",
"genres": [],
"id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43",
"label-info": [],
"label_info": [],
"media": [
{
"format": "Digital Media",
"format-id": "907a28d9-b3b2-3ef6-89a8-7b18d91d4794",
"format_id": "907a28d9-b3b2-3ef6-89a8-7b18d91d4794",
"id": "606faab7-60fa-3a8b-a40f-2c66150cce81",
"position": 1,
"title": "",
"track-count": 1,
"track-offset": 0,
"track_count": 1,
"track_offset": 0,
"tracks": [
{
"artist-credit": [
"artist_credit": [
{
"artist": {
"aliases": [
@ -87,18 +87,18 @@
"locale": "en",
"name": "Lilas Ikuta",
"primary": true,
"sort-name": "Ikuta, Lilas",
"sort_name": "Ikuta, Lilas",
"type": "Artist name",
"type-id": "894afba6-2816-3c24-8072-eadb66bd04bc"
"type_id": "894afba6-2816-3c24-8072-eadb66bd04bc"
}
],
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"sort_name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"joinphrase": "",
"name": "Lilas Ikuta"
@ -110,43 +110,43 @@
"position": 1,
"recording": {
"aliases": [],
"artist-credit": [
"artist_credit": [
{
"artist": {
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"sort_name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"joinphrase": "",
"name": "幾田りら"
}
],
"artist-relations": [
"artist_relations": [
{
"artist": {
"country": "JP",
"disambiguation": "Japanese composer/arranger/guitarist, agehasprings",
"id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025",
"name": "KOHD",
"sort-name": "KOHD",
"sort_name": "KOHD",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"source_credit": "",
"target_credit": "",
"type": "arranger",
"type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d"
"type_id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d"
},
{
"artist": {
@ -154,21 +154,21 @@
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"sort_name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": "2025",
"direction": "backward",
"end": "2025",
"ended": true,
"source-credit": "",
"target-credit": "Lilas Ikuta",
"source_credit": "",
"target_credit": "Lilas Ikuta",
"type": "phonographic copyright",
"type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30"
"type_id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30"
},
{
"artist": {
@ -176,21 +176,21 @@
"disambiguation": "",
"id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05",
"name": "山本秀哉",
"sort-name": "Yamamoto, Shuya",
"sort_name": "Yamamoto, Shuya",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"source_credit": "",
"target_credit": "",
"type": "producer",
"type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0"
"type_id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0"
},
{
"artist": {
@ -198,25 +198,25 @@
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"sort_name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"source_credit": "",
"target_credit": "",
"type": "vocal",
"type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa"
"type_id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa"
}
],
"disambiguation": "",
"first-release-date": "2025-01-10",
"first_release_date": "2025-01-10",
"genres": [],
"id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e",
"isrcs": [
@ -225,53 +225,53 @@
"length": 179546,
"tags": [],
"title": "百花繚乱",
"url-relations": [
"url_relations": [
{
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": null,
"direction": "forward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"source_credit": "",
"target_credit": "",
"type": "free streaming",
"type-id": "7e41ef12-a124-4324-afdb-fdbae687a89c",
"type_id": "7e41ef12-a124-4324-afdb-fdbae687a89c",
"url": {
"id": "d076eaf9-5fde-4f6e-a946-cde16b67aa3b",
"resource": "https://open.spotify.com/track/782PTXsbAWB70ySDZ5NHmP"
}
},
{
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": null,
"direction": "forward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"source_credit": "",
"target_credit": "",
"type": "purchase for download",
"type-id": "92777657-504c-4acb-bd33-51a201bd57e1",
"type_id": "92777657-504c-4acb-bd33-51a201bd57e1",
"url": {
"id": "64879627-6eca-4755-98b5-b2234a8dbc61",
"resource": "https://music.apple.com/jp/song/1857886416"
}
},
{
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": null,
"direction": "forward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"source_credit": "",
"target_credit": "",
"type": "streaming",
"type-id": "b5f3058a-666c-406f-aafb-f9249fc7b122",
"type_id": "b5f3058a-666c-406f-aafb-f9249fc7b122",
"url": {
"id": "64879627-6eca-4755-98b5-b2234a8dbc61",
"resource": "https://music.apple.com/jp/song/1857886416"
@ -279,42 +279,42 @@
}
],
"video": false,
"work-relations": [
"work_relations": [
{
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": null,
"direction": "forward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"source_credit": "",
"target_credit": "",
"type": "performance",
"type-id": "a3005666-a872-32c3-ad06-98af558e99b0",
"type_id": "a3005666-a872-32c3-ad06-98af558e99b0",
"work": {
"artist-relations": [
"artist_relations": [
{
"artist": {
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"sort_name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"source_credit": "",
"target_credit": "",
"type": "composer",
"type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f"
"type_id": "d59d99ea-23d4-4a80-b066-edca32ee158f"
},
{
"artist": {
@ -322,21 +322,21 @@
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"sort_name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"source_credit": "",
"target_credit": "",
"type": "lyricist",
"type-id": "3e48faba-ec01-47fd-8e89-30e81161661c"
"type_id": "3e48faba-ec01-47fd-8e89-30e81161661c"
}
],
"attributes": [],
@ -349,37 +349,37 @@
],
"title": "百花繚乱",
"type": "Song",
"type-id": "f061270a-2fd6-32f1-a641-f0f8676d14e6",
"url-relations": [
"type_id": "f061270a-2fd6-32f1-a641-f0f8676d14e6",
"url_relations": [
{
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"source_credit": "",
"target_credit": "",
"type": "lyrics",
"type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
"type_id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
"url": {
"id": "dfac3640-6b23-4991-a59c-7cb80e8eb950",
"resource": "https://utaten.com/lyric/tt24121002/"
}
},
{
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"source_credit": "",
"target_credit": "",
"type": "lyrics",
"type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
"type_id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
"url": {
"id": "b1b5d5df-e79d-4cda-bb2a-8014e5505415",
"resource": "https://www.uta-net.com/song/366579/"
@ -396,11 +396,11 @@
}
],
"packaging": null,
"packaging-id": null,
"packaging_id": null,
"quality": "normal",
"release-group": {
"release_group": {
"aliases": [],
"artist-credit": [
"artist_credit": [
{
"artist": {
"aliases": [
@ -411,54 +411,54 @@
"locale": "en",
"name": "Lilas Ikuta",
"primary": true,
"sort-name": "Ikuta, Lilas",
"sort_name": "Ikuta, Lilas",
"type": "Artist name",
"type-id": "894afba6-2816-3c24-8072-eadb66bd04bc"
"type_id": "894afba6-2816-3c24-8072-eadb66bd04bc"
}
],
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"sort_name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"joinphrase": "",
"name": "幾田りら"
}
],
"disambiguation": "",
"first-release-date": "2025-01-10",
"first_release_date": "2025-01-10",
"genres": [],
"id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1",
"primary-type": "Single",
"primary-type-id": "d6038452-8ee0-3f68-affc-2de9a1ede0b9",
"secondary-type-ids": [],
"secondary-types": [],
"primary_type": "Single",
"primary_type_id": "d6038452-8ee0-3f68-affc-2de9a1ede0b9",
"secondary_type_ids": [],
"secondary_types": [],
"tags": [],
"title": "百花繚乱"
},
"release-relations": [
"release_relations": [
{
"attribute-ids": {},
"attribute-values": {},
"attribute_ids": {},
"attribute_values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"release": {
"artist-credit": [
"artist_credit": [
{
"artist": {
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"sort_name": "Ikuta, Lilas",
"type": null,
"type-id": null
"type_id": null
},
"joinphrase": "",
"name": "幾田りら"
@ -471,43 +471,43 @@
"id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103",
"media": [],
"packaging": null,
"packaging-id": null,
"packaging_id": null,
"quality": "normal",
"release-events": [
"release_events": [
{
"area": {
"disambiguation": "",
"id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
"iso-3166-1-codes": [
"iso_3166_1_codes": [
"XW"
],
"name": "[Worldwide]",
"sort-name": "[Worldwide]",
"sort_name": "[Worldwide]",
"type": null,
"type-id": null
"type_id": null
},
"date": "2025-01-10"
}
],
"release-group": null,
"release_group": null,
"status": null,
"status-id": null,
"text-representation": {
"status_id": null,
"text_representation": {
"language": "jpn",
"script": "Jpan"
},
"title": "百花繚乱"
},
"source-credit": "",
"target-credit": "",
"source_credit": "",
"target_credit": "",
"type": "transl-tracklisting",
"type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644"
"type_id": "fc399d47-23a7-4c28-bfcf-0607a562b644"
}
],
"status": "Pseudo-Release",
"status-id": "41121bb9-3413-3818-8a9a-9742318349aa",
"status_id": "41121bb9-3413-3818-8a9a-9742318349aa",
"tags": [],
"text-representation": {
"text_representation": {
"language": "eng",
"script": "Latn"
},