diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 2fc821df9..1acdac648 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -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 '-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 + '_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: diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index d084d1531..e4bb5b1a6 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -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", []) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index aac20e9ac..adfe34c45 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -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 ( diff --git a/poetry.lock b/poetry.lock index 8eb7c74ac..edfa1e18b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index aa3c9d5c7..85e927fe2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,6 +104,7 @@ langdetect = "*" pylast = "*" pytest = "*" pytest-cov = "*" +pytest-factoryboy = ">=2.8.1" pytest-flask = "*" python-mpd2 = "*" python3-discogs-client = ">=2.3.15" diff --git a/setup.cfg b/setup.cfg index 000c4a77e..eb5b26af9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py new file mode 100644 index 000000000..19368586e --- /dev/null +++ b/test/plugins/factories/musicbrainz.py @@ -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" diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py index 2fb6321b3..ff0838aad 100644 --- a/test/plugins/test_mbpseudo.py +++ b/test/plugins/test_mbpseudo.py @@ -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) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 069f1fb99..d1f49ef37 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -14,9 +14,11 @@ """Tests for MusicBrainz API wrapper.""" +from __future__ import annotations + import unittest import uuid -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar from unittest import mock import pytest @@ -26,6 +28,44 @@ from beets import config from beets.library import Item from beets.test.helper import BeetsTestCase, PluginMixin from beetsplug import musicbrainz +from beetsplug.musicbrainz import MusicBrainzPlugin + +from .factories import musicbrainz as factories + +if TYPE_CHECKING: + from beetsplug._utils import musicbrainz as mb + + +def alias_factory(**kwargs) -> mb.Alias: + return factories.AliasFactory.build(**kwargs) + + +def artist_credit_factory(**kwargs) -> mb.ArtistCredit: + return factories.ArtistCreditFactory.build(**kwargs) + + +def artist_relation_factory(**kwargs) -> mb.ArtistRelation: + return factories.ArtistRelationFactory.build(**kwargs) + + +def release_group_factory(**kwargs) -> mb.ReleaseGroup: + return factories.ReleaseGroupFactory.build(**kwargs) + + +def recording_factory(**kwargs) -> mb.Recording: + return factories.RecordingFactory.build(**kwargs) + + +def track_factory(**kwargs) -> mb.Track: + return factories.TrackFactory.build(**kwargs) + + +def medium_factory(**kwargs) -> mb.Medium: + return factories.MediumFactory(**kwargs) # type: ignore[return-value] + + +def release_factory(**kwargs) -> mb.Release: + return factories.ReleaseFactory(**kwargs) # type: ignore[return-value] class MusicBrainzTestCase(BeetsTestCase): @@ -36,240 +76,55 @@ class MusicBrainzTestCase(BeetsTestCase): class MBAlbumInfoTest(MusicBrainzTestCase): - def _make_release( - self, - date_str="2009", - tracks=None, - track_length=None, - track_artist=False, - multi_artist_credit=False, - data_tracks=None, - medium_format="FORMAT", - ): - release = { - "title": "ALBUM TITLE", - "id": "ALBUM ID", - "asin": "ALBUM ASIN", - "disambiguation": "R_DISAMBIGUATION", - "release-group": { - "primary-type": "Album", - "first-release-date": date_str, - "id": "RELEASE GROUP ID", - "disambiguation": "RG_DISAMBIGUATION", - }, - "artist-credit": [ - { - "artist": { - "name": "ARTIST NAME", - "id": "ARTIST ID", - "sort-name": "ARTIST SORT NAME", - }, - "name": "ARTIST CREDIT", - } - ], - "date": "3001", - "media": [], - "genres": [{"count": 1, "name": "GENRE"}], - "tags": [{"count": 1, "name": "TAG"}], - "label-info": [ - { - "catalog-number": "CATALOG NUMBER", - "label": {"name": "LABEL NAME"}, - } - ], - "text-representation": { - "script": "SCRIPT", - "language": "LANGUAGE", - }, - "country": "COUNTRY", - "status": "STATUS", - "barcode": "BARCODE", - "release-events": [{"area": None, "date": "2021-03-26"}], - } - - if multi_artist_credit: - release["artist-credit"][0]["joinphrase"] = " & " - release["artist-credit"].append( - { - "artist": { - "name": "ARTIST 2 NAME", - "id": "ARTIST 2 ID", - "sort-name": "ARTIST 2 SORT NAME", - }, - "name": "ARTIST MULTI CREDIT", - } - ) - - i = 0 - track_list = [] - if tracks: - for recording in tracks: - i += 1 - track = { - "id": f"RELEASE TRACK ID {i}", - "recording": recording, - "position": i, - "number": "A1", - } - if track_length: - # Track lengths are distinct from recording lengths. - track["length"] = track_length - if track_artist: - # Similarly, track artists can differ from recording - # artists. - track["artist-credit"] = [ - { - "artist": { - "name": "TRACK ARTIST NAME", - "id": "TRACK ARTIST ID", - "sort-name": "TRACK ARTIST SORT NAME", - }, - "name": "TRACK ARTIST CREDIT", - } - ] - - if multi_artist_credit: - track["artist-credit"][0]["joinphrase"] = " & " - track["artist-credit"].append( - { - "artist": { - "name": "TRACK ARTIST 2 NAME", - "id": "TRACK ARTIST 2 ID", - "sort-name": "TRACK ARTIST 2 SORT NAME", - }, - "name": "TRACK ARTIST 2 CREDIT", - } - ) - - track_list.append(track) - data_track_list = [] - if data_tracks: - for recording in data_tracks: - i += 1 - data_track = { - "id": f"RELEASE TRACK ID {i}", - "recording": recording, - "position": i, - "number": "A1", - } - data_track_list.append(data_track) - release["media"].append( - { - "position": "1", - "tracks": track_list, - "data-tracks": data_track_list, - "format": medium_format, - "title": "MEDIUM TITLE", - } - ) - return release - - def _make_track( - self, - title, - tr_id, - duration, - artist=False, - video=False, - disambiguation=None, - remixer=False, - multi_artist_credit=False, - ): - track = { - "title": title, - "id": tr_id, - } - if duration is not None: - track["length"] = duration - if artist: - track["artist-credit"] = [ - { - "artist": { - "name": "RECORDING ARTIST NAME", - "id": "RECORDING ARTIST ID", - "sort-name": "RECORDING ARTIST SORT NAME", - }, - "name": "RECORDING ARTIST CREDIT", - } - ] - if multi_artist_credit: - track["artist-credit"][0]["joinphrase"] = " & " - track["artist-credit"].append( - { - "artist": { - "name": "RECORDING ARTIST 2 NAME", - "id": "RECORDING ARTIST 2 ID", - "sort-name": "RECORDING ARTIST 2 SORT NAME", - }, - "name": "RECORDING ARTIST 2 CREDIT", - } - ) - if remixer: - track["artist-relations"] = [ - { - "type": "remixer", - "type-id": "RELATION TYPE ID", - "direction": "RECORDING RELATION DIRECTION", - "artist": { - "id": "RECORDING REMIXER ARTIST ID", - "type": "RECORDING REMIXER ARTIST TYPE", - "name": "RECORDING REMIXER ARTIST NAME", - "sort-name": "RECORDING REMIXER ARTIST SORT NAME", - }, - } - ] - if video: - track["video"] = True - if disambiguation: - track["disambiguation"] = disambiguation - return track - def test_parse_release_with_year(self): - release = self._make_release("1984") + release = release_factory(release_group__first_release_date="1984") d = self.mb.album_info(release) - assert d.album == "ALBUM TITLE" - assert d.album_id == "ALBUM ID" - assert d.artist == "ARTIST NAME" - assert d.artist_id == "ARTIST ID" + assert d.album == "Album" + assert d.album_id == "00000000-0000-0000-0000-000001000001" + assert d.artist == "Artist" + assert d.artist_id == "00000000-0000-0000-0000-000000000011" assert d.original_year == 1984 - assert d.year == 3001 - assert d.artist_credit == "ARTIST CREDIT" + assert d.year == 2020 + assert d.artist_credit == "Artist Credit" def test_parse_release_type(self): - release = self._make_release("1984") + release = release_factory() d = self.mb.album_info(release) assert d.albumtype == "album" def test_parse_release_full_date(self): - release = self._make_release("1987-03-31") + release = release_factory( + release_group__first_release_date="1987-03-31" + ) d = self.mb.album_info(release) assert d.original_year == 1987 assert d.original_month == 3 assert d.original_day == 31 def test_parse_tracks(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - release = self._make_release(tracks=tracks) + release = release_factory( + media__0__tracks=[ + track_factory(recording__length=100000), + track_factory( + recording__index=2, + recording__length=200000, + recording__title="Other Recording", + ), + ] + ) d = self.mb.album_info(release) t = d.tracks assert len(t) == 2 - assert t[0].title == "TITLE ONE" - assert t[0].track_id == "ID ONE" + assert t[0].title == "Recording" + assert t[0].track_id == "00000000-0000-0000-0000-000000001001" assert t[0].length == 100.0 - assert t[1].title == "TITLE TWO" - assert t[1].track_id == "ID TWO" + assert t[1].title == "Other Recording" + assert t[1].track_id == "00000000-0000-0000-0000-000000001002" assert t[1].length == 200.0 def test_parse_track_indices(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - release = self._make_release(tracks=tracks) + release = release_factory(media__0__tracks__count=2) d = self.mb.album_info(release) t = d.tracks @@ -279,11 +134,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].index == 2 def test_parse_medium_numbers_single_medium(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - release = self._make_release(tracks=tracks) + release = release_factory(media__0__tracks__count=2) d = self.mb.album_info(release) assert d.mediums == 1 @@ -292,24 +143,8 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].medium == 1 def test_parse_medium_numbers_two_mediums(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - release = self._make_release(tracks=[tracks[0]]) - second_track_list = [ - { - "id": "RELEASE TRACK ID 2", - "recording": tracks[1], - "position": "1", - "number": "A1", - } - ] - release["media"].append( - { - "position": "2", - "tracks": second_track_list, - } + release = release_factory( + media=[medium_factory(), medium_factory(position=2)] ) d = self.mb.album_info(release) @@ -323,367 +158,405 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].index == 2 def test_parse_release_year_month_only(self): - release = self._make_release("1987-03") + release = release_factory(release_group__first_release_date="1987-03") d = self.mb.album_info(release) assert d.original_year == 1987 assert d.original_month == 3 def test_no_durations(self): - tracks = [self._make_track("TITLE", "ID", None)] - release = self._make_release(tracks=tracks) + release = release_factory( + media__0__tracks=[track_factory(recording__length=None)] + ) d = self.mb.album_info(release) assert d.tracks[0].length is None def test_track_length_overrides_recording_length(self): - tracks = [self._make_track("TITLE", "ID", 1.0 * 1000.0)] - release = self._make_release(tracks=tracks, track_length=2.0 * 1000.0) + release = release_factory( + media__0__tracks=[track_factory(recording__length=2000.0)] + ) d = self.mb.album_info(release) assert d.tracks[0].length == 2.0 def test_no_release_date(self): - release = self._make_release(None) + release = release_factory(release_group__first_release_date="") d = self.mb.album_info(release) assert not d.original_year assert not d.original_month assert not d.original_day def test_various_artists_defaults_false(self): - release = self._make_release(None) + release = release_factory() d = self.mb.album_info(release) assert not d.va def test_detect_various_artists(self): - release = self._make_release(None) - release["artist-credit"][0]["artist"]["id"] = ( + release = release_factory() + release["artist_credit"][0]["artist"]["id"] = ( musicbrainz.VARIOUS_ARTISTS_ID ) d = self.mb.album_info(release) assert d.va def test_parse_artist_sort_name(self): - release = self._make_release(None) + release = release_factory() d = self.mb.album_info(release) - assert d.artist_sort == "ARTIST SORT NAME" + assert d.artist_sort == "Artist, The" def test_parse_releasegroupid(self): - release = self._make_release(None) + release = release_factory() d = self.mb.album_info(release) - assert d.releasegroup_id == "RELEASE GROUP ID" + assert d.releasegroup_id == "00000000-0000-0000-0000-000000000101" def test_parse_asin(self): - release = self._make_release(None) + release = release_factory() d = self.mb.album_info(release) - assert d.asin == "ALBUM ASIN" + assert d.asin == "Album Asin" def test_parse_catalognum(self): - release = self._make_release(None) + release = release_factory() d = self.mb.album_info(release) - assert d.catalognum == "CATALOG NUMBER" + assert d.catalognum == "LAB123" def test_parse_textrepr(self): - release = self._make_release(None) + release = release_factory() d = self.mb.album_info(release) - assert d.script == "SCRIPT" - assert d.language == "LANGUAGE" + assert d.script == "Latn" + assert d.language == "eng" def test_parse_country(self): - release = self._make_release(None) + release = release_factory() d = self.mb.album_info(release) - assert d.country == "COUNTRY" + assert d.country == "US" def test_parse_status(self): - release = self._make_release(None) + release = release_factory() d = self.mb.album_info(release) - assert d.albumstatus == "STATUS" + assert d.albumstatus == "Official" def test_parse_barcode(self): - release = self._make_release(None) + release = release_factory() d = self.mb.album_info(release) - assert d.barcode == "BARCODE" + assert d.barcode == "0000000000000" def test_parse_media(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - release = self._make_release(None, tracks=tracks) + release = release_factory() d = self.mb.album_info(release) - assert d.media == "FORMAT" + assert d.media == "Digital Media" def test_parse_disambig(self): - release = self._make_release(None) + release = release_factory() d = self.mb.album_info(release) - assert d.albumdisambig == "R_DISAMBIGUATION" - assert d.releasegroupdisambig == "RG_DISAMBIGUATION" + assert d.albumdisambig == "Album Disambiguation" + assert d.releasegroupdisambig == "Release Group Disambiguation" def test_parse_disctitle(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - release = self._make_release(None, tracks=tracks) + release = release_factory(media__0__tracks__count=2) d = self.mb.album_info(release) t = d.tracks - assert t[0].disctitle == "MEDIUM TITLE" - assert t[1].disctitle == "MEDIUM TITLE" + assert t[0].disctitle == "Medium" + assert t[1].disctitle == "Medium" def test_missing_language(self): - release = self._make_release(None) - del release["text-representation"]["language"] + release = release_factory() + release["text_representation"]["language"] = None d = self.mb.album_info(release) assert d.language is None def test_parse_recording_artist(self): - tracks = [self._make_track("a", "b", 1, True)] - release = self._make_release(None, tracks=tracks) + release = release_factory() track = self.mb.album_info(release).tracks[0] - assert track.artist == "RECORDING ARTIST NAME" - assert track.artist_id == "RECORDING ARTIST ID" - assert track.artist_sort == "RECORDING ARTIST SORT NAME" - assert track.artist_credit == "RECORDING ARTIST CREDIT" + assert track.artist == "Recording Artist" + assert track.artist_id == "00000000-0000-0000-0000-000000000001" + assert track.artist_sort == "Recording Artist, The" + assert track.artist_credit == "Recording Artist Credit" def test_parse_recording_artist_multi(self): - tracks = [self._make_track("a", "b", 1, True, multi_artist_credit=True)] - release = self._make_release(None, tracks=tracks) + release = release_factory( + media__0__tracks=[ + track_factory( + recording__artist_credit=[ + artist_credit_factory( + artist__name="Recording Artist", + joinphrase=" & ", + ), + artist_credit_factory( + artist__name="Other Recording Artist", + artist__index=2, + ), + ] + ) + ] + ) track = self.mb.album_info(release).tracks[0] - assert track.artist == "RECORDING ARTIST NAME & RECORDING ARTIST 2 NAME" - assert track.artist_id == "RECORDING ARTIST ID" + assert track.artist == "Recording Artist & Other Recording Artist" + assert track.artist_id == "00000000-0000-0000-0000-000000000001" assert ( track.artist_sort - == "RECORDING ARTIST SORT NAME & RECORDING ARTIST 2 SORT NAME" + == "Recording Artist, The & Other Recording Artist, The" ) assert ( track.artist_credit - == "RECORDING ARTIST CREDIT & RECORDING ARTIST 2 CREDIT" + == "Recording Artist Credit & Other Recording Artist Credit" ) assert track.artists == [ - "RECORDING ARTIST NAME", - "RECORDING ARTIST 2 NAME", + "Recording Artist", + "Other Recording Artist", ] assert track.artists_ids == [ - "RECORDING ARTIST ID", - "RECORDING ARTIST 2 ID", + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", ] assert track.artists_sort == [ - "RECORDING ARTIST SORT NAME", - "RECORDING ARTIST 2 SORT NAME", + "Recording Artist, The", + "Other Recording Artist, The", ] assert track.artists_credit == [ - "RECORDING ARTIST CREDIT", - "RECORDING ARTIST 2 CREDIT", + "Recording Artist Credit", + "Other Recording Artist Credit", ] def test_track_artist_overrides_recording_artist(self): - tracks = [self._make_track("a", "b", 1, True)] - release = self._make_release(None, tracks=tracks, track_artist=True) + release = release_factory( + media__0__tracks=[ + track_factory( + artist_credit=[ + artist_credit_factory(artist__name="Track Artist") + ] + ) + ] + ) track = self.mb.album_info(release).tracks[0] - assert track.artist == "TRACK ARTIST NAME" - assert track.artist_id == "TRACK ARTIST ID" - assert track.artist_sort == "TRACK ARTIST SORT NAME" - assert track.artist_credit == "TRACK ARTIST CREDIT" + assert track.artist == "Track Artist" + assert track.artist_id == "00000000-0000-0000-0000-000000000001" + assert track.artist_sort == "Track Artist, The" + assert track.artist_credit == "Track Artist Credit" def test_track_artist_overrides_recording_artist_multi(self): - tracks = [self._make_track("a", "b", 1, True, multi_artist_credit=True)] - release = self._make_release( - None, tracks=tracks, track_artist=True, multi_artist_credit=True + release = release_factory( + media__0__tracks=[ + track_factory( + artist_credit=[ + artist_credit_factory( + artist__name="Track Artist", + joinphrase=" & ", + ), + artist_credit_factory( + artist__name="Other Track Artist", + artist__index=2, + ), + ], + recording__artist_credit=[ + artist_credit_factory( + artist__name="Recording Artist", + joinphrase=" & ", + ), + artist_credit_factory( + artist__name="Other Recording Artist", + artist__index=2, + ), + ], + ), + ] ) track = self.mb.album_info(release).tracks[0] - assert track.artist == "TRACK ARTIST NAME & TRACK ARTIST 2 NAME" - assert track.artist_id == "TRACK ARTIST ID" + assert track.artist == "Track Artist & Other Track Artist" + assert track.artist_id == "00000000-0000-0000-0000-000000000001" assert ( - track.artist_sort - == "TRACK ARTIST SORT NAME & TRACK ARTIST 2 SORT NAME" + track.artist_sort == "Track Artist, The & Other Track Artist, The" ) assert ( - track.artist_credit == "TRACK ARTIST CREDIT & TRACK ARTIST 2 CREDIT" + track.artist_credit + == "Track Artist Credit & Other Track Artist Credit" ) - assert track.artists == ["TRACK ARTIST NAME", "TRACK ARTIST 2 NAME"] - assert track.artists_ids == ["TRACK ARTIST ID", "TRACK ARTIST 2 ID"] + assert track.artists == ["Track Artist", "Other Track Artist"] + assert track.artists_ids == [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + ] assert track.artists_sort == [ - "TRACK ARTIST SORT NAME", - "TRACK ARTIST 2 SORT NAME", + "Track Artist, The", + "Other Track Artist, The", ] assert track.artists_credit == [ - "TRACK ARTIST CREDIT", - "TRACK ARTIST 2 CREDIT", + "Track Artist Credit", + "Other Track Artist Credit", ] def test_parse_recording_remixer(self): - tracks = [self._make_track("a", "b", 1, remixer=True)] - release = self._make_release(None, tracks=tracks) + release = release_factory( + media__0__tracks=[ + track_factory( + recording__artist_relations=[ + artist_relation_factory( + type="remixer", artist__name="Recording Remixer" + ) + ] + ) + ] + ) track = self.mb.album_info(release).tracks[0] - assert track.remixer == "RECORDING REMIXER ARTIST NAME" + assert track.remixer == "Recording Remixer" def test_data_source(self): - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) assert d.data_source == "MusicBrainz" def test_genres(self): config["musicbrainz"]["genres"] = True config["musicbrainz"]["genres_tag"] = "genre" - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) - assert d.genre == "GENRE" + assert d.genre == "Genre" def test_tags(self): config["musicbrainz"]["genres"] = True config["musicbrainz"]["genres_tag"] = "tag" - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) - assert d.genre == "TAG" + assert d.genre == "Tag" def test_no_genres(self): config["musicbrainz"]["genres"] = False - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) assert d.genre is None def test_ignored_media(self): config["match"]["ignored_media"] = ["IGNORED1", "IGNORED2"] - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - release = self._make_release(tracks=tracks, medium_format="IGNORED1") + release = release_factory( + media__0__format="IGNORED1", media__0__tracks__count=2 + ) d = self.mb.album_info(release) assert len(d.tracks) == 0 def test_no_ignored_media(self): config["match"]["ignored_media"] = ["IGNORED1", "IGNORED2"] - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - release = self._make_release(tracks=tracks, medium_format="NON-IGNORED") + release = release_factory( + media__0__format="NON-IGNORED", media__0__tracks__count=2 + ) d = self.mb.album_info(release) assert len(d.tracks) == 2 def test_skip_data_track(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("[data track]", "ID DATA TRACK", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - release = self._make_release(tracks=tracks) + release = release_factory( + media__0__tracks=[ + track_factory(), + track_factory(recording__title="[data track]"), + track_factory(recording__title="Other Recording"), + ] + ) d = self.mb.album_info(release) assert len(d.tracks) == 2 - assert d.tracks[0].title == "TITLE ONE" - assert d.tracks[1].title == "TITLE TWO" + assert d.tracks[0].title == "Recording" + assert d.tracks[1].title == "Other Recording" def test_skip_audio_data_tracks_by_default(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - data_tracks = [ - self._make_track( - "TITLE AUDIO DATA", "ID DATA TRACK", 100.0 * 1000.0 - ) - ] - release = self._make_release(tracks=tracks, data_tracks=data_tracks) + release = release_factory( + media__0__tracks=[ + track_factory(), + track_factory(recording__title="Other Recording"), + ], + media__0__data_tracks=[ + track_factory(recording__title="Audio Data Recording"), + ], + ) d = self.mb.album_info(release) assert len(d.tracks) == 2 - assert d.tracks[0].title == "TITLE ONE" - assert d.tracks[1].title == "TITLE TWO" + assert d.tracks[0].title == "Recording" + assert d.tracks[1].title == "Other Recording" def test_no_skip_audio_data_tracks_if_configured(self): config["match"]["ignore_data_tracks"] = False - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - data_tracks = [ - self._make_track( - "TITLE AUDIO DATA", "ID DATA TRACK", 100.0 * 1000.0 - ) - ] - release = self._make_release(tracks=tracks, data_tracks=data_tracks) + release = release_factory( + media__0__tracks=[ + track_factory(), + track_factory(recording__title="Other Recording"), + ], + media__0__data_tracks=[ + track_factory(recording__title="Audio Data Recording"), + ], + ) d = self.mb.album_info(release) assert len(d.tracks) == 3 - assert d.tracks[0].title == "TITLE ONE" - assert d.tracks[1].title == "TITLE TWO" - assert d.tracks[2].title == "TITLE AUDIO DATA" + assert d.tracks[0].title == "Recording" + assert d.tracks[1].title == "Other Recording" + assert d.tracks[2].title == "Audio Data Recording" def test_skip_video_tracks_by_default(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track( - "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True - ), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - release = self._make_release(tracks=tracks) + release = release_factory( + media__0__tracks=[ + track_factory(), + track_factory(recording__video=True), + track_factory(recording__title="Other Recording"), + ] + ) d = self.mb.album_info(release) assert len(d.tracks) == 2 - assert d.tracks[0].title == "TITLE ONE" - assert d.tracks[1].title == "TITLE TWO" + assert d.tracks[0].title == "Recording" + assert d.tracks[1].title == "Other Recording" def test_skip_video_data_tracks_by_default(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - data_tracks = [ - self._make_track( - "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True - ) - ] - release = self._make_release(tracks=tracks, data_tracks=data_tracks) + release = release_factory( + media__0__tracks=[ + track_factory(), + track_factory(recording__title="Other Recording"), + ], + media__0__data_tracks=[ + track_factory(recording__video=True), + ], + ) d = self.mb.album_info(release) assert len(d.tracks) == 2 - assert d.tracks[0].title == "TITLE ONE" - assert d.tracks[1].title == "TITLE TWO" + assert d.tracks[0].title == "Recording" + assert d.tracks[1].title == "Other Recording" def test_no_skip_video_tracks_if_configured(self): config["match"]["ignore_data_tracks"] = False config["match"]["ignore_video_tracks"] = False - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track( - "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True - ), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - release = self._make_release(tracks=tracks) + release = release_factory( + media__0__tracks=[ + track_factory(), + track_factory(recording__video=True), + track_factory(recording__title="Other Recording"), + ] + ) d = self.mb.album_info(release) assert len(d.tracks) == 3 - assert d.tracks[0].title == "TITLE ONE" - assert d.tracks[1].title == "TITLE VIDEO" - assert d.tracks[2].title == "TITLE TWO" + assert d.tracks[0].title == "Recording" + assert d.tracks[1].title == "Video: Recording" + assert d.tracks[2].title == "Other Recording" def test_no_skip_video_data_tracks_if_configured(self): config["match"]["ignore_data_tracks"] = False config["match"]["ignore_video_tracks"] = False - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - data_tracks = [ - self._make_track( - "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True - ) - ] - release = self._make_release(tracks=tracks, data_tracks=data_tracks) + release = release_factory( + media__0__tracks=[ + track_factory(), + track_factory(recording__title="Other Recording"), + ], + media__0__data_tracks=[ + track_factory(recording__video=True), + ], + ) d = self.mb.album_info(release) assert len(d.tracks) == 3 - assert d.tracks[0].title == "TITLE ONE" - assert d.tracks[1].title == "TITLE TWO" - assert d.tracks[2].title == "TITLE VIDEO" + assert d.tracks[0].title == "Recording" + assert d.tracks[1].title == "Other Recording" + assert d.tracks[2].title == "Video: Recording" def test_track_disambiguation(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track( - "TITLE TWO", - "ID TWO", - 200.0 * 1000.0, - disambiguation="SECOND TRACK", - ), - ] - release = self._make_release(tracks=tracks) + release = release_factory( + media__0__tracks=[ + track_factory(), + track_factory( + recording__title="Other Recording", + recording__disambiguation="SECOND TRACK", + ), + ] + ) d = self.mb.album_info(release) t = d.tracks @@ -692,136 +565,95 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].trackdisambig == "SECOND TRACK" -class ArtistFlatteningTest(unittest.TestCase): - def _credit_dict(self, suffix=""): - return { - "artist": { - "name": f"NAME{suffix}", - "sort-name": f"SORT{suffix}", - }, - "name": f"CREDIT{suffix}", - } - - def _add_alias(self, credit_dict, suffix="", locale="", primary=False): - alias = { - "name": f"ALIAS{suffix}", - "locale": locale, - "sort-name": f"ALIASSORT{suffix}", - } - if primary: - alias["primary"] = "primary" - if "aliases" not in credit_dict["artist"]: - credit_dict["artist"]["aliases"] = [] - credit_dict["artist"]["aliases"].append(alias) - +class ArtistTest(unittest.TestCase): def test_single_artist(self): - credit = [self._credit_dict()] - a, s, c = musicbrainz._flatten_artist_credit(credit) - assert a == "NAME" - assert s == "SORT" - assert c == "CREDIT" + credit = [artist_credit_factory(artist__name="Artist")] - a, s, c = musicbrainz._multi_artist_credit( - credit, include_join_phrase=False - ) - assert a == ["NAME"] - assert s == ["SORT"] - assert c == ["CREDIT"] + assert MusicBrainzPlugin._parse_artist_credits(credit) == { + "artist": "Artist", + "artist_id": "00000000-0000-0000-0000-000000000001", + "artist_sort": "Artist, The", + "artist_credit": "Artist Credit", + "artists": ["Artist"], + "artists_ids": ["00000000-0000-0000-0000-000000000001"], + "artists_sort": ["Artist, The"], + "artists_credit": ["Artist Credit"], + } def test_two_artists(self): credit = [ - {**self._credit_dict("a"), "joinphrase": " AND "}, - self._credit_dict("b"), + artist_credit_factory(artist__name="Artist", joinphrase=" AND "), + artist_credit_factory(artist__name="Other Artist", artist__index=2), ] - a, s, c = musicbrainz._flatten_artist_credit(credit) - assert a == "NAMEa AND NAMEb" - assert s == "SORTa AND SORTb" - assert c == "CREDITa AND CREDITb" - a, s, c = musicbrainz._multi_artist_credit( - credit, include_join_phrase=False - ) - assert a == ["NAMEa", "NAMEb"] - assert s == ["SORTa", "SORTb"] - assert c == ["CREDITa", "CREDITb"] + assert MusicBrainzPlugin._parse_artist_credits(credit) == { + "artist": "Artist AND Other Artist", + "artist_id": "00000000-0000-0000-0000-000000000001", + "artist_sort": "Artist, The AND Other Artist, The", + "artist_credit": "Artist Credit AND Other Artist Credit", + "artists": ["Artist", "Other Artist"], + "artists_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + ], + "artists_sort": ["Artist, The", "Other Artist, The"], + "artists_credit": ["Artist Credit", "Other Artist Credit"], + } - def test_alias(self): - credit_dict = self._credit_dict() - self._add_alias(credit_dict, suffix="en", locale="en", primary=True) - self._add_alias( - credit_dict, suffix="en_GB", locale="en_GB", primary=True - ) - self._add_alias(credit_dict, suffix="fr", locale="fr") - self._add_alias(credit_dict, suffix="fr_P", locale="fr", primary=True) - self._add_alias(credit_dict, suffix="pt_BR", locale="pt_BR") + def test_preferred_alias(self): + aliases = [ + alias_factory(suffix="en", locale="en", primary=True), + alias_factory(suffix="en_GB", locale="en_GB", primary=True), + alias_factory(suffix="fr", locale="fr"), + alias_factory(suffix="fr_P", locale="fr", primary=True), + alias_factory(suffix="pt_BR", locale="pt_BR"), + ] # test no alias config["import"]["languages"] = [""] - flat = musicbrainz._flatten_artist_credit([credit_dict]) - assert flat == ("NAME", "SORT", "CREDIT") + assert not musicbrainz._preferred_alias(aliases) # test en primary config["import"]["languages"] = ["en"] - flat = musicbrainz._flatten_artist_credit([credit_dict]) - assert flat == ("ALIASen", "ALIASSORTen", "CREDIT") + preferred_alias = musicbrainz._preferred_alias(aliases) + assert preferred_alias + assert preferred_alias["name"] == "Alias en" # test en_GB en primary config["import"]["languages"] = ["en_GB", "en"] - flat = musicbrainz._flatten_artist_credit([credit_dict]) - assert flat == ("ALIASen_GB", "ALIASSORTen_GB", "CREDIT") + preferred_alias = musicbrainz._preferred_alias(aliases) + assert preferred_alias + assert preferred_alias["name"] == "Alias en_GB" # test en en_GB primary config["import"]["languages"] = ["en", "en_GB"] - flat = musicbrainz._flatten_artist_credit([credit_dict]) - assert flat == ("ALIASen", "ALIASSORTen", "CREDIT") + preferred_alias = musicbrainz._preferred_alias(aliases) + assert preferred_alias + assert preferred_alias["name"] == "Alias en" # test fr primary config["import"]["languages"] = ["fr"] - flat = musicbrainz._flatten_artist_credit([credit_dict]) - assert flat == ("ALIASfr_P", "ALIASSORTfr_P", "CREDIT") + preferred_alias = musicbrainz._preferred_alias(aliases) + assert preferred_alias + assert preferred_alias["name"] == "Alias fr_P" # test for not matching non-primary config["import"]["languages"] = ["pt_BR", "fr"] - flat = musicbrainz._flatten_artist_credit([credit_dict]) - assert flat == ("ALIASfr_P", "ALIASSORTfr_P", "CREDIT") + preferred_alias = musicbrainz._preferred_alias(aliases) + assert preferred_alias + assert preferred_alias["name"] == "Alias fr_P" class MBLibraryTest(MusicBrainzTestCase): def test_follow_pseudo_releases(self): - side_effect = [ - { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "media": [ - { - "tracks": [ - { - "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - "release-relations": [ + side_effect: list[mb.Release] = [ + release_factory( + id="d2a6f856-b553-40a0-ac54-a321e8e2da02", + title="pseudo", + status="Pseudo-Release", + country=None, + release_events=[], + release_relations=[ { "type": "transl-tracklisting", "direction": "backward", @@ -830,41 +662,11 @@ class MBLibraryTest(MusicBrainzTestCase): }, } ], - }, - { - "title": "actual", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01", - "status": "Official", - "media": [ - { - "tracks": [ - { - "id": "baz", - "recording": { - "title": "original title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - "country": "COUNTRY", - }, + ), + release_factory( + title="actual", + id="d2a6f856-b553-40a0-ac54-a321e8e2da01", + ), ] with mock.patch( @@ -872,43 +674,17 @@ class MBLibraryTest(MusicBrainzTestCase): ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") - assert album.country == "COUNTRY" + assert album + assert album.country == "US" def test_pseudo_releases_with_empty_links(self): - side_effect = [ - { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "media": [ - { - "tracks": [ - { - "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - } + side_effect: list[mb.Release] = [ + release_factory( + id="d2a6f856-b553-40a0-ac54-a321e8e2da02", + title="pseudo", + status="Pseudo-Release", + release_events=[], + ) ] with mock.patch( @@ -916,43 +692,17 @@ class MBLibraryTest(MusicBrainzTestCase): ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") + assert album assert album.country is None def test_pseudo_releases_without_links(self): - side_effect = [ - { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "media": [ - { - "tracks": [ - { - "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - } + side_effect: list[mb.Release] = [ + release_factory( + id="d2a6f856-b553-40a0-ac54-a321e8e2da02", + title="pseudo", + status="Pseudo-Release", + release_events=[], + ) ] with mock.patch( @@ -960,43 +710,17 @@ class MBLibraryTest(MusicBrainzTestCase): ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") + assert album assert album.country is None def test_pseudo_releases_with_unsupported_links(self): - side_effect = [ - { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "media": [ - { - "tracks": [ - { - "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], - "release-group": { - "id": "another-id", - }, - "release-relations": [ + side_effect: list[mb.Release] = [ + release_factory( + id="d2a6f856-b553-40a0-ac54-a321e8e2da02", + title="pseudo", + status="Pseudo-Release", + release_events=[], + release_relations=[ { "type": "remaster", "direction": "backward", @@ -1005,7 +729,7 @@ class MBLibraryTest(MusicBrainzTestCase): }, } ], - } + ) ] with mock.patch( @@ -1013,6 +737,7 @@ class MBLibraryTest(MusicBrainzTestCase): ) as gp: gp.side_effect = side_effect album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02") + assert album assert album.country is None @@ -1020,11 +745,7 @@ class TestMusicBrainzPlugin(PluginMixin): plugin = "musicbrainz" mbid = "d2a6f856-b553-40a0-ac54-a321e8e2da99" - RECORDING: ClassVar[dict[str, int | str]] = { - "title": "foo", - "id": "bar", - "length": 42, - } + RECORDING: ClassVar[mb.Recording] = recording_factory() @pytest.fixture def plugin_config(self): @@ -1065,7 +786,11 @@ class TestMusicBrainzPlugin(PluginMixin): def test_item_candidates(self, monkeypatch, mb): monkeypatch.setattr( "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_json", - lambda *_, **__: {"recordings": [self.RECORDING]}, + lambda *_, **__: {"recordings": [{"id": self.RECORDING["id"]}]}, + ) + monkeypatch.setattr( + "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_recording", + lambda *_, **__: self.RECORDING, ) candidates = list(mb.item_candidates(Item(), "hello", "there")) @@ -1080,34 +805,15 @@ class TestMusicBrainzPlugin(PluginMixin): ) monkeypatch.setattr( "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release", - lambda *_, **__: { - "title": "hi", - "id": self.mbid, - "status": "status", - "media": [ - { - "tracks": [ - { - "id": "baz", - "recording": self.RECORDING, - "position": 9, - "number": "A1", - } - ], - "position": 5, - } - ], - "artist-credit": [ - {"artist": {"name": "some-artist", "id": "some-id"}} - ], - "release-group": {"id": "another-id"}, - }, + lambda *_, **__: release_factory( + id=self.mbid, media=[medium_factory()] + ), ) candidates = list(mb.candidates([], "hello", "there", False)) assert len(candidates) == 1 assert candidates[0].tracks[0].track_id == self.RECORDING["id"] - assert candidates[0].album == "hi" + assert candidates[0].album == "Album" def test_import_handles_404_gracefully(self, mb, requests_mock): id_ = uuid.uuid4() diff --git a/test/plugins/utils/test_musicbrainz.py b/test/plugins/utils/test_musicbrainz.py index 291f50eb5..0a2a40602 100644 --- a/test/plugins/utils/test_musicbrainz.py +++ b/test/plugins/utils/test_musicbrainz.py @@ -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/" }, diff --git a/test/rsrc/mbpseudo/official_release.json b/test/rsrc/mbpseudo/official_release.json index cd6bb3ba9..2778c9fba 100644 --- a/test/rsrc/mbpseudo/official_release.json +++ b/test/rsrc/mbpseudo/official_release.json @@ -7,12 +7,12 @@ "locale": "en", "name": "In Bloom", "primary": true, - "sort-name": "In Bloom", + "sort_name": "In Bloom", "type": "Release name", - "type-id": "df187855-059b-3514-9d5e-d240de0b4228" + "type_id": "df187855-059b-3514-9d5e-d240de0b4228" } ], - "artist-credit": [ + "artist_credit": [ { "artist": { "aliases": [ @@ -23,9 +23,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", @@ -46,7 +46,7 @@ ], "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", "name": "幾田りら", - "sort-name": "Ikuta, Lilas", + "sort_name": "Ikuta, Lilas", "tags": [ { "count": 1, @@ -58,34 +58,34 @@ } ], "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": "", "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": "copyright", - "type-id": "730b5251-7432-4896-8fc6-e1cba943bfe1" + "type_id": "730b5251-7432-4896-8fc6-e1cba943bfe1" }, { "artist": { @@ -93,27 +93,27 @@ "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": "01d3488d-8d2a-4cff-9226-5250404db4dc" + "type_id": "01d3488d-8d2a-4cff-9226-5250404db4dc" } ], "asin": "B0DR8Y2YDC", "barcode": "199066336168", "country": "XW", - "cover-art-archive": { + "cover_art_archive": { "artwork": true, "back": false, "count": 1, @@ -124,9 +124,9 @@ "disambiguation": "", "genres": [], "id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", - "label-info": [ + "label_info": [ { - "catalog-number": "Lilas-020", + "catalog_number": "Lilas-020", "label": { "aliases": [ { @@ -136,9 +136,9 @@ "locale": null, "name": "2636621 Records DK", "primary": null, - "sort-name": "2636621 Records DK", + "sort_name": "2636621 Records DK", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -147,9 +147,9 @@ "locale": null, "name": "Antipole", "primary": null, - "sort-name": "Antipole", + "sort_name": "Antipole", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -158,9 +158,9 @@ "locale": null, "name": "Auto production", "primary": null, - "sort-name": "Auto production", + "sort_name": "Auto production", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -169,9 +169,9 @@ "locale": null, "name": "Auto-Edición", "primary": null, - "sort-name": "Auto-Edición", + "sort_name": "Auto-Edición", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -180,9 +180,9 @@ "locale": null, "name": "Auto-Product", "primary": null, - "sort-name": "Auto-Product", + "sort_name": "Auto-Product", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -191,9 +191,9 @@ "locale": null, "name": "Autoedición", "primary": null, - "sort-name": "Autoedición", + "sort_name": "Autoedición", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -202,9 +202,9 @@ "locale": null, "name": "Autoeditado", "primary": null, - "sort-name": "Autoeditado", + "sort_name": "Autoeditado", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -213,9 +213,9 @@ "locale": null, "name": "Autoproduit", "primary": null, - "sort-name": "Autoproduit", + "sort_name": "Autoproduit", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -224,9 +224,9 @@ "locale": null, "name": "Banana Skin Records", "primary": null, - "sort-name": "Banana Skin Records", + "sort_name": "Banana Skin Records", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -235,9 +235,9 @@ "locale": null, "name": "Cannelle", "primary": null, - "sort-name": "Cannelle", + "sort_name": "Cannelle", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -246,9 +246,9 @@ "locale": null, "name": "Cece Natalie", "primary": null, - "sort-name": "Cece Natalie", + "sort_name": "Cece Natalie", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -257,9 +257,9 @@ "locale": null, "name": "Cherry X", "primary": null, - "sort-name": "Cherry X", + "sort_name": "Cherry X", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -268,9 +268,9 @@ "locale": null, "name": "Chung", "primary": null, - "sort-name": "Chung", + "sort_name": "Chung", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -279,9 +279,9 @@ "locale": null, "name": "Cody Johnson", "primary": null, - "sort-name": "Cody Johnson", + "sort_name": "Cody Johnson", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -290,9 +290,9 @@ "locale": null, "name": "Cowgirl Clue", "primary": null, - "sort-name": "Cowgirl Clue", + "sort_name": "Cowgirl Clue", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -301,9 +301,9 @@ "locale": null, "name": "D.I.Y.", "primary": null, - "sort-name": "D.I.Y.", + "sort_name": "D.I.Y.", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -312,9 +312,9 @@ "locale": null, "name": "Damjan Mravunac Self-released)", "primary": null, - "sort-name": "Damjan Mravunac Self-released)", + "sort_name": "Damjan Mravunac Self-released)", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -323,9 +323,9 @@ "locale": null, "name": "Demo", "primary": null, - "sort-name": "Demo", + "sort_name": "Demo", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -334,9 +334,9 @@ "locale": null, "name": "DistroKid", "primary": null, - "sort-name": "DistroKid", + "sort_name": "DistroKid", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -345,9 +345,9 @@ "locale": null, "name": "Egzod", "primary": null, - "sort-name": "Egzod", + "sort_name": "Egzod", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -356,9 +356,9 @@ "locale": null, "name": "Eigenverlag", "primary": null, - "sort-name": "Eigenverlag", + "sort_name": "Eigenverlag", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -367,9 +367,9 @@ "locale": null, "name": "Eigenvertrieb", "primary": null, - "sort-name": "Eigenvertrieb", + "sort_name": "Eigenvertrieb", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -378,9 +378,9 @@ "locale": null, "name": "GRIND MODE", "primary": null, - "sort-name": "GRIND MODE", + "sort_name": "GRIND MODE", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -389,9 +389,9 @@ "locale": null, "name": "INDIPENDANT", "primary": null, - "sort-name": "INDIPENDANT", + "sort_name": "INDIPENDANT", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -400,9 +400,9 @@ "locale": null, "name": "Indepandant", "primary": null, - "sort-name": "Indepandant", + "sort_name": "Indepandant", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -411,9 +411,9 @@ "locale": null, "name": "Independant release", "primary": null, - "sort-name": "Independant release", + "sort_name": "Independant release", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -422,9 +422,9 @@ "locale": null, "name": "Independent", "primary": null, - "sort-name": "Independent", + "sort_name": "Independent", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -433,9 +433,9 @@ "locale": null, "name": "Independente", "primary": null, - "sort-name": "Independente", + "sort_name": "Independente", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -444,9 +444,9 @@ "locale": null, "name": "Independiente", "primary": null, - "sort-name": "Independiente", + "sort_name": "Independiente", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -455,9 +455,9 @@ "locale": null, "name": "Indie", "primary": null, - "sort-name": "Indie", + "sort_name": "Indie", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -466,9 +466,9 @@ "locale": null, "name": "Joost Klein", "primary": null, - "sort-name": "Joost Klein", + "sort_name": "Joost Klein", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -477,9 +477,9 @@ "locale": null, "name": "Millington Records", "primary": null, - "sort-name": "Millington Records", + "sort_name": "Millington Records", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -488,9 +488,9 @@ "locale": null, "name": "MoroseSound", "primary": null, - "sort-name": "MoroseSound", + "sort_name": "MoroseSound", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -499,9 +499,9 @@ "locale": null, "name": "N/A", "primary": null, - "sort-name": "N/A", + "sort_name": "N/A", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -510,9 +510,9 @@ "locale": null, "name": "No Label", "primary": null, - "sort-name": "No Label", + "sort_name": "No Label", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -521,9 +521,9 @@ "locale": null, "name": "None", "primary": null, - "sort-name": "None", + "sort_name": "None", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -532,9 +532,9 @@ "locale": null, "name": "None Like Joshua", "primary": null, - "sort-name": "None Like Joshua", + "sort_name": "None Like Joshua", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -543,9 +543,9 @@ "locale": null, "name": "Not On A Lebel", "primary": null, - "sort-name": "Not On A Lebel", + "sort_name": "Not On A Lebel", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -554,9 +554,9 @@ "locale": null, "name": "Not On Label", "primary": null, - "sort-name": "Not On Label", + "sort_name": "Not On Label", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -565,9 +565,9 @@ "locale": null, "name": "Offensively Average Productions", "primary": null, - "sort-name": "Offensively Average Productions", + "sort_name": "Offensively Average Productions", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -576,9 +576,9 @@ "locale": null, "name": "Ours", "primary": null, - "sort-name": "Ours", + "sort_name": "Ours", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -587,9 +587,9 @@ "locale": null, "name": "P2019", "primary": null, - "sort-name": "P2019", + "sort_name": "P2019", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -598,9 +598,9 @@ "locale": null, "name": "P2020", "primary": null, - "sort-name": "P2020", + "sort_name": "P2020", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -609,9 +609,9 @@ "locale": null, "name": "P2021", "primary": null, - "sort-name": "P2021", + "sort_name": "P2021", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -620,9 +620,9 @@ "locale": null, "name": "P2022", "primary": null, - "sort-name": "P2022", + "sort_name": "P2022", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -631,9 +631,9 @@ "locale": null, "name": "P2023", "primary": null, - "sort-name": "P2023", + "sort_name": "P2023", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -642,9 +642,9 @@ "locale": null, "name": "P2024", "primary": null, - "sort-name": "P2024", + "sort_name": "P2024", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -653,9 +653,9 @@ "locale": null, "name": "P2025", "primary": null, - "sort-name": "P2025", + "sort_name": "P2025", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -664,9 +664,9 @@ "locale": null, "name": "Patriarchy", "primary": null, - "sort-name": "Patriarchy", + "sort_name": "Patriarchy", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -675,9 +675,9 @@ "locale": null, "name": "Plini", "primary": null, - "sort-name": "Plini", + "sort_name": "Plini", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -686,9 +686,9 @@ "locale": null, "name": "Records DK", "primary": null, - "sort-name": "Records DK", + "sort_name": "Records DK", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -697,9 +697,9 @@ "locale": null, "name": "Self Digital", "primary": null, - "sort-name": "Self Digital", + "sort_name": "Self Digital", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -708,9 +708,9 @@ "locale": null, "name": "Self Release", "primary": null, - "sort-name": "Self Release", + "sort_name": "Self Release", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -719,9 +719,9 @@ "locale": null, "name": "Self Released", "primary": null, - "sort-name": "Self Released", + "sort_name": "Self Released", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -730,9 +730,9 @@ "locale": null, "name": "Self-release", "primary": null, - "sort-name": "Self-release", + "sort_name": "Self-release", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -741,9 +741,9 @@ "locale": null, "name": "Self-released", "primary": null, - "sort-name": "Self-released", + "sort_name": "Self-released", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -752,9 +752,9 @@ "locale": null, "name": "Self-released/independent", "primary": null, - "sort-name": "Self-released/independent", + "sort_name": "Self-released/independent", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -763,9 +763,9 @@ "locale": null, "name": "Sevdaliza", "primary": null, - "sort-name": "Sevdaliza", + "sort_name": "Sevdaliza", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -774,9 +774,9 @@ "locale": null, "name": "TOMMY CASH", "primary": null, - "sort-name": "TOMMY CASH", + "sort_name": "TOMMY CASH", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -785,9 +785,9 @@ "locale": null, "name": "Take Van", "primary": null, - "sort-name": "Take Van", + "sort_name": "Take Van", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -796,9 +796,9 @@ "locale": null, "name": "Talwiinder", "primary": null, - "sort-name": "Talwiinder", + "sort_name": "Talwiinder", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -807,9 +807,9 @@ "locale": null, "name": "Unsigned", "primary": null, - "sort-name": "Unsigned", + "sort_name": "Unsigned", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -818,9 +818,9 @@ "locale": null, "name": "VGR", "primary": null, - "sort-name": "VGR", + "sort_name": "VGR", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -829,9 +829,9 @@ "locale": null, "name": "Woo Da Savage", "primary": null, - "sort-name": "Woo Da Savage", + "sort_name": "Woo Da Savage", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -840,9 +840,9 @@ "locale": null, "name": "YANAA", "primary": null, - "sort-name": "YANAA", + "sort_name": "YANAA", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -851,9 +851,9 @@ "locale": "fi", "name": "[ei levymerkkiä]", "primary": true, - "sort-name": "ei levymerkkiä", + "sort_name": "ei levymerkkiä", "type": "Label name", - "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + "type_id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, @@ -862,9 +862,9 @@ "locale": "nl", "name": "[geen platenmaatschappij]", "primary": true, - "sort-name": "[geen platenmaatschappij]", + "sort_name": "[geen platenmaatschappij]", "type": "Label name", - "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + "type_id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, @@ -873,9 +873,9 @@ "locale": "et", "name": "[ilma plaadifirmata]", "primary": false, - "sort-name": "[ilma plaadifirmata]", + "sort_name": "[ilma plaadifirmata]", "type": "Label name", - "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + "type_id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, @@ -884,9 +884,9 @@ "locale": "es", "name": "[nada]", "primary": true, - "sort-name": "[nada]", + "sort_name": "[nada]", "type": "Label name", - "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + "type_id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, @@ -895,9 +895,9 @@ "locale": "en", "name": "[no label]", "primary": true, - "sort-name": "[no label]", + "sort_name": "[no label]", "type": "Label name", - "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + "type_id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, @@ -906,9 +906,9 @@ "locale": null, "name": "[nolabel]", "primary": null, - "sort-name": "[nolabel]", + "sort_name": "[nolabel]", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -917,9 +917,9 @@ "locale": null, "name": "[none]", "primary": null, - "sort-name": "[none]", + "sort_name": "[none]", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -928,9 +928,9 @@ "locale": "lt", "name": "[nėra leidybinės kompanijos]", "primary": false, - "sort-name": "[nėra leidybinės kompanijos]", + "sort_name": "[nėra leidybinės kompanijos]", "type": "Label name", - "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + "type_id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, @@ -939,9 +939,9 @@ "locale": "lt", "name": "[nėra leidyklos]", "primary": false, - "sort-name": "[nėra leidyklos]", + "sort_name": "[nėra leidyklos]", "type": "Label name", - "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + "type_id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, @@ -950,9 +950,9 @@ "locale": "lt", "name": "[nėra įrašų kompanijos]", "primary": true, - "sort-name": "[nėra įrašų kompanijos]", + "sort_name": "[nėra įrašų kompanijos]", "type": "Label name", - "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + "type_id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, @@ -961,9 +961,9 @@ "locale": "et", "name": "[puudub]", "primary": false, - "sort-name": "[puudub]", + "sort_name": "[puudub]", "type": "Label name", - "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + "type_id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, @@ -972,9 +972,9 @@ "locale": "ru", "name": "[самиздат]", "primary": false, - "sort-name": "samizdat", + "sort_name": "samizdat", "type": "Label name", - "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + "type_id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, @@ -983,9 +983,9 @@ "locale": "ja", "name": "[レーベルなし]", "primary": true, - "sort-name": "[レーベルなし]", + "sort_name": "[レーベルなし]", "type": "Label name", - "type-id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" + "type_id": "3a1a0c48-d885-3b89-87b2-9e8a483c5675" }, { "begin": null, @@ -994,9 +994,9 @@ "locale": null, "name": "annapantsu music", "primary": null, - "sort-name": "annapantsu music", + "sort_name": "annapantsu music", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -1005,9 +1005,9 @@ "locale": null, "name": "auto-release", "primary": null, - "sort-name": "auto-release", + "sort_name": "auto-release", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -1016,9 +1016,9 @@ "locale": null, "name": "autoprod.", "primary": null, - "sort-name": "autoprod.", + "sort_name": "autoprod.", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -1027,9 +1027,9 @@ "locale": null, "name": "ayesha erotica", "primary": null, - "sort-name": "ayesha erotica", + "sort_name": "ayesha erotica", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -1038,9 +1038,9 @@ "locale": null, "name": "blank", "primary": null, - "sort-name": "blank", + "sort_name": "blank", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -1049,9 +1049,9 @@ "locale": null, "name": "cupcakKe", "primary": null, - "sort-name": "cupcakKe", + "sort_name": "cupcakKe", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -1060,9 +1060,9 @@ "locale": null, "name": "d.silvestre", "primary": null, - "sort-name": "d.silvestre", + "sort_name": "d.silvestre", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -1071,9 +1071,9 @@ "locale": null, "name": "dj-Jo", "primary": null, - "sort-name": "dj-Jo", + "sort_name": "dj-Jo", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -1082,9 +1082,9 @@ "locale": null, "name": "independent release", "primary": null, - "sort-name": "independent release", + "sort_name": "independent release", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -1093,9 +1093,9 @@ "locale": null, "name": "lor2mg", "primary": null, - "sort-name": "lor2mg", + "sort_name": "lor2mg", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -1104,9 +1104,9 @@ "locale": null, "name": "nyamura", "primary": null, - "sort-name": "nyamura", + "sort_name": "nyamura", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -1115,9 +1115,9 @@ "locale": null, "name": "pls dnt stp", "primary": null, - "sort-name": "pls dnt stp", + "sort_name": "pls dnt stp", "type": null, - "type-id": null + "type_id": null }, { "begin": null, @@ -1126,9 +1126,9 @@ "locale": null, "name": "self", "primary": null, - "sort-name": "self", + "sort_name": "self", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -1137,9 +1137,9 @@ "locale": null, "name": "self issued", "primary": null, - "sort-name": "self issued", + "sort_name": "self issued", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -1148,9 +1148,9 @@ "locale": null, "name": "self-issued", "primary": null, - "sort-name": "self-issued", + "sort_name": "self-issued", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -1159,9 +1159,9 @@ "locale": null, "name": "white label", "primary": null, - "sort-name": "white label", + "sort_name": "white label", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -1170,9 +1170,9 @@ "locale": null, "name": "но лабел", "primary": null, - "sort-name": "но лабел", + "sort_name": "но лабел", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" }, { "begin": null, @@ -1181,17 +1181,17 @@ "locale": null, "name": "独立发行", "primary": null, - "sort-name": "独立发行", + "sort_name": "独立发行", "type": "Search hint", - "type-id": "829662f2-a781-3ec8-8b46-fbcea6196f81" + "type_id": "829662f2-a781-3ec8-8b46-fbcea6196f81" } ], "disambiguation": "Special purpose label – white labels, self-published releases and other “no label” releases", "genres": [], "id": "157afde4-4bf5-4039-8ad2-5a15acc85176", - "label-code": null, + "label_code": null, "name": "[no label]", - "sort-name": "[no label]", + "sort_name": "[no label]", "tags": [ { "count": 12, @@ -1203,22 +1203,22 @@ } ], "type": "Production", - "type-id": "a2426aab-2dd4-339c-b47d-b4923a241678" + "type_id": "a2426aab-2dd4-339c-b47d-b4923a241678" } } ], "media": [ { "format": "Digital Media", - "format-id": "907a28d9-b3b2-3ef6-89a8-7b18d91d4794", + "format_id": "907a28d9-b3b2-3ef6-89a8-7b18d91d4794", "id": "43f08d54-a896-3561-be75-b881cbc832d5", "position": 1, "title": "", - "track-count": 1, - "track-offset": 0, + "track_count": 1, + "track_offset": 0, "tracks": [ { - "artist-credit": [ + "artist_credit": [ { "artist": { "aliases": [ @@ -1229,18 +1229,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": "幾田りら" @@ -1252,43 +1252,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": { @@ -1296,21 +1296,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": { @@ -1318,21 +1318,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": { @@ -1340,25 +1340,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": [ @@ -1367,53 +1367,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" @@ -1421,42 +1421,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": { @@ -1464,21 +1464,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": [], @@ -1491,37 +1491,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/" @@ -1538,27 +1538,27 @@ } ], "packaging": "None", - "packaging-id": "119eba76-b343-3e02-a292-f0f00644bb9b", + "packaging_id": "119eba76-b343-3e02-a292-f0f00644bb9b", "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": { + "release_group": { "aliases": [], - "artist-credit": [ + "artist_credit": [ { "artist": { "aliases": [ @@ -1569,54 +1569,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": "forward", "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": "Lilas Ikuta" @@ -1627,248 +1627,248 @@ "id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", "media": [], "packaging": null, - "packaging-id": null, + "packaging_id": null, "quality": "normal", - "release-group": null, + "release_group": null, "status": null, - "status-id": null, - "text-representation": { + "status_id": null, + "text_representation": { "language": "eng", "script": "Latn" }, "title": "In Bloom" }, - "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": "Official", - "status-id": "4e304316-386d-3409-af2e-78857eec5cfe", + "status_id": "4e304316-386d-3409-af2e-78857eec5cfe", "tags": [], - "text-representation": { + "text_representation": { "language": "jpn", "script": "Jpan" }, "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": "amazon asin", - "type-id": "4f2e710d-166c-480c-a293-2e2c8d658d87", + "type_id": "4f2e710d-166c-480c-a293-2e2c8d658d87", "url": { "id": "b50c7fb8-2327-4a05-b989-f2211a41afee", "resource": "https://www.amazon.co.jp/gp/product/B0DR8Y2YDC" } }, { - "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": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", + "type_id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", "url": { "id": "5106a7b0-1443-4803-91a2-28cac2cfb5e0", "resource": "https://open.spotify.com/album/3LDV2xGL9HiqCsQujEPQLb" } }, { - "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": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", + "type_id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", "url": { "id": "d481d94b-a7bf-4e82-8da0-1757fedcda62", "resource": "https://www.deezer.com/album/687686261" } }, { - "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": "98e08c20-8402-4163-8970-53504bb6a1e4", + "type_id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "6156d2e4-d107-43f9-8f44-52f04d39c78e", "resource": "https://mora.jp/package/43000011/199066336168/" } }, { - "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": "98e08c20-8402-4163-8970-53504bb6a1e4", + "type_id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "a4eabb88-1746-4aa2-ab09-c28cfbe65efb", "resource": "https://mora.jp/package/43000011/199066336168_HD/" } }, { - "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": "98e08c20-8402-4163-8970-53504bb6a1e4", + "type_id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "ab8440f0-3b13-4436-b3ad-f4695c9d8875", "resource": "https://mora.jp/package/43000011/199066336168_LL/" } }, { - "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": "98e08c20-8402-4163-8970-53504bb6a1e4", + "type_id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "9a8ee8d1-f946-44a1-be16-8f7a77c951e9", "resource": "https://music.apple.com/jp/album/1786972161" } }, { - "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": "98e08c20-8402-4163-8970-53504bb6a1e4", + "type_id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "c6faaa80-38fb-46a4-aa2b-78cddc5cbe70", "resource": "https://ototoy.jp/_/default/p/2501951" } }, { - "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": "98e08c20-8402-4163-8970-53504bb6a1e4", + "type_id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "0e7e8bc5-0779-492d-a9db-9ab58f96d23b", "resource": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/fl9tx2j78reza" } }, { - "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": "98e08c20-8402-4163-8970-53504bb6a1e4", + "type_id": "98e08c20-8402-4163-8970-53504bb6a1e4", "url": { "id": "c0cf8fe0-3413-4544-a026-37d346a59a77", "resource": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/l1dnc4xoi6l7a" } }, { - "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": "320adf26-96fa-4183-9045-1f5f32f833cb", + "type_id": "320adf26-96fa-4183-9045-1f5f32f833cb", "url": { "id": "e4ce55a9-a5e1-4842-b42d-11be6a31fdab", "resource": "https://music.amazon.co.jp/albums/B0DR8Y2YDC" } }, { - "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": "320adf26-96fa-4183-9045-1f5f32f833cb", + "type_id": "320adf26-96fa-4183-9045-1f5f32f833cb", "url": { "id": "9a8ee8d1-f946-44a1-be16-8f7a77c951e9", "resource": "https://music.apple.com/jp/album/1786972161" } }, { - "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": "vgmdb", - "type-id": "6af0134a-df6a-425a-96e2-895f9cd342ba", + "type_id": "6af0134a-df6a-425a-96e2-895f9cd342ba", "url": { "id": "1885772a-4004-4d45-9512-d0c8822506c9", "resource": "https://vgmdb.net/album/145936" diff --git a/test/rsrc/mbpseudo/pseudo_release.json b/test/rsrc/mbpseudo/pseudo_release.json index ae4bf7b6b..1b5d9857c 100644 --- a/test/rsrc/mbpseudo/pseudo_release.json +++ b/test/rsrc/mbpseudo/pseudo_release.json @@ -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" },