From e0842c44dbe92c5fcc6221f767d0bff7d8016491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 13 Jan 2026 00:43:23 +0000 Subject: [PATCH 01/29] Normalize dashes to underscores in musicbrainz data fields --- beetsplug/_utils/musicbrainz.py | 49 +- beetsplug/mbpseudo.py | 10 +- beetsplug/musicbrainz.py | 88 +-- test/plugins/test_mbpseudo.py | 6 +- test/plugins/test_musicbrainz.py | 88 +-- test/plugins/utils/test_musicbrainz.py | 77 ++- test/rsrc/mbpseudo/official_release.json | 814 +++++++++++------------ test/rsrc/mbpseudo/pseudo_release.json | 256 +++---- 8 files changed, 723 insertions(+), 665 deletions(-) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 2fc821df9..ee94316e7 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -169,7 +169,7 @@ 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) ) @@ -240,29 +240,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 +283,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..9e4be4bad 100644 --- a/beetsplug/mbpseudo.py +++ b/beetsplug/mbpseudo.py @@ -168,7 +168,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): 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 ] @@ -176,7 +176,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): def _has_desired_script(self, release: JSONDict) -> 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 @@ -216,9 +216,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..1dc2ddfaa 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -160,9 +160,9 @@ def _multi_artist_credit( # 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"]) + 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) @@ -245,10 +245,10 @@ def _preferred_release_event( ].as_str_seq() for country in preferred_countries: - for event in release.get("release-events", {}): + for event in release.get("release_events", {}): try: if area := event.get("area"): - if country in area["iso-3166-1-codes"]: + if country in area["iso_3166_1_codes"]: return country, event["date"] except KeyError: pass @@ -381,28 +381,28 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): data_url=track_url(recording["id"]), ) - if recording.get("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"]) + ) = _flatten_artist_credit(recording["artist_credit"]) ( info.artists, info.artists_sort, info.artists_credit, ) = _multi_artist_credit( - recording["artist-credit"], include_join_phrase=False + recording["artist_credit"], include_join_phrase=False ) - info.artists_ids = _artist_ids(recording["artist-credit"]) + info.artists_ids = _artist_ids(recording["artist_credit"]) info.artist_id = info.artists_ids[0] - if recording.get("artist-relations"): + if recording.get("artist_relations"): info.remixer = _get_related_artist_names( - recording["artist-relations"], relation_type="remixer" + recording["artist_relations"], relation_type="remixer" ) if recording.get("length"): @@ -416,7 +416,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): lyricist = [] composer = [] composer_sort = [] - for work_relation in recording.get("work-relations", ()): + for work_relation in recording.get("work_relations", ()): if work_relation["type"] != "performance": continue info.work = work_relation["work"]["title"] @@ -425,7 +425,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): info.work_disambig = work_relation["work"]["disambiguation"] for artist_relation in work_relation["work"].get( - "artist-relations", () + "artist_relations", () ): if "type" in artist_relation: type = artist_relation["type"] @@ -434,7 +434,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): elif type == "composer": composer.append(artist_relation["artist"]["name"]) composer_sort.append( - artist_relation["artist"]["sort-name"] + artist_relation["artist"]["sort_name"] ) if lyricist: info.lyricist = ", ".join(lyricist) @@ -443,7 +443,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): info.composer_sort = ", ".join(composer_sort) arranger = [] - for artist_relation in recording.get("artist-relations", ()): + for artist_relation in recording.get("artist_relations", ()): if "type" in artist_relation: type = artist_relation["type"] if type == "arranger": @@ -464,7 +464,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): """ # Get artist name using join phrases. artist_name, artist_sort_name, artist_credit_name = ( - _flatten_artist_credit(release["artist-credit"]) + _flatten_artist_credit(release["artist_credit"]) ) ( @@ -472,7 +472,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): artists_sort_names, artists_credit_names, ) = _multi_artist_credit( - release["artist-credit"], include_join_phrase=False + release["artist_credit"], include_join_phrase=False ) ntracks = sum(len(m["tracks"]) for m in release["media"]) @@ -508,10 +508,10 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): all_tracks = medium["tracks"] if ( - "data-tracks" in medium + "data_tracks" in medium and not config["match"]["ignore_data_tracks"] ): - all_tracks += medium["data-tracks"] + all_tracks += medium["data_tracks"] track_count = len(all_tracks) if "pregap" in medium: @@ -548,30 +548,30 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): # Prefer track data, where present, over recording data. if track.get("title"): ti.title = track["title"] - if track.get("artist-credit"): + if track.get("artist_credit"): # Get the artist names. ( ti.artist, ti.artist_sort, ti.artist_credit, - ) = _flatten_artist_credit(track["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 + track["artist_credit"], include_join_phrase=False ) - ti.artists_ids = _artist_ids(track["artist-credit"]) + 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"]) + album_artist_ids = _artist_ids(release["artist_credit"]) info = beets.autotag.hooks.AlbumInfo( album=release["title"], album_id=release["id"], @@ -593,38 +593,38 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): if info.va: info.artist = config["va_name"].as_str() info.asin = release.get("asin") - info.releasegroup_id = release["release-group"]["id"] + 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") + 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( + 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"): + 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 "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"]: + 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") + 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 @@ -634,17 +634,17 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): _set_date_str(info, release_group_date, True) # Label name. - if release.get("label-info"): - label_info = release["label-info"][0] + 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") + info.catalognum = label_info.get("catalog_number") # Text representation data. - if release.get("text-representation"): - rep = release["text-representation"] + if release.get("text_representation"): + rep = release["text_representation"] info.script = rep.get("script") info.language = rep.get("language") @@ -659,7 +659,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): if self.config["genres"]: sources = [ - release["release-group"].get(self.genres_field, []), + release["release_group"].get(self.genres_field, []), release.get(self.genres_field, []), ] genres: Counter[str] = Counter() @@ -676,7 +676,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): wanted_sources = { site for site, wanted in external_ids.items() if wanted } - if wanted_sources and (url_rels := release.get("url-relations")): + if wanted_sources and (url_rels := release.get("url_relations")): urls = {} for source, url in product(wanted_sources, url_rels): @@ -800,7 +800,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/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..49039f2ba 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -51,18 +51,18 @@ class MBAlbumInfoTest(MusicBrainzTestCase): "id": "ALBUM ID", "asin": "ALBUM ASIN", "disambiguation": "R_DISAMBIGUATION", - "release-group": { - "primary-type": "Album", - "first-release-date": date_str, + "release_group": { + "primary_type": "Album", + "first_release_date": date_str, "id": "RELEASE GROUP ID", "disambiguation": "RG_DISAMBIGUATION", }, - "artist-credit": [ + "artist_credit": [ { "artist": { "name": "ARTIST NAME", "id": "ARTIST ID", - "sort-name": "ARTIST SORT NAME", + "sort_name": "ARTIST SORT NAME", }, "name": "ARTIST CREDIT", } @@ -71,30 +71,30 @@ class MBAlbumInfoTest(MusicBrainzTestCase): "media": [], "genres": [{"count": 1, "name": "GENRE"}], "tags": [{"count": 1, "name": "TAG"}], - "label-info": [ + "label_info": [ { - "catalog-number": "CATALOG NUMBER", + "catalog_number": "CATALOG NUMBER", "label": {"name": "LABEL NAME"}, } ], - "text-representation": { + "text_representation": { "script": "SCRIPT", "language": "LANGUAGE", }, "country": "COUNTRY", "status": "STATUS", "barcode": "BARCODE", - "release-events": [{"area": None, "date": "2021-03-26"}], + "release_events": [{"area": None, "date": "2021-03-26"}], } if multi_artist_credit: - release["artist-credit"][0]["joinphrase"] = " & " - release["artist-credit"].append( + release["artist_credit"][0]["joinphrase"] = " & " + release["artist_credit"].append( { "artist": { "name": "ARTIST 2 NAME", "id": "ARTIST 2 ID", - "sort-name": "ARTIST 2 SORT NAME", + "sort_name": "ARTIST 2 SORT NAME", }, "name": "ARTIST MULTI CREDIT", } @@ -117,25 +117,25 @@ class MBAlbumInfoTest(MusicBrainzTestCase): if track_artist: # Similarly, track artists can differ from recording # artists. - track["artist-credit"] = [ + track["artist_credit"] = [ { "artist": { "name": "TRACK ARTIST NAME", "id": "TRACK ARTIST ID", - "sort-name": "TRACK ARTIST SORT NAME", + "sort_name": "TRACK ARTIST SORT NAME", }, "name": "TRACK ARTIST CREDIT", } ] if multi_artist_credit: - track["artist-credit"][0]["joinphrase"] = " & " - track["artist-credit"].append( + 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", + "sort_name": "TRACK ARTIST 2 SORT NAME", }, "name": "TRACK ARTIST 2 CREDIT", } @@ -157,7 +157,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): { "position": "1", "tracks": track_list, - "data-tracks": data_track_list, + "data_tracks": data_track_list, "format": medium_format, "title": "MEDIUM TITLE", } @@ -182,39 +182,39 @@ class MBAlbumInfoTest(MusicBrainzTestCase): if duration is not None: track["length"] = duration if artist: - track["artist-credit"] = [ + track["artist_credit"] = [ { "artist": { "name": "RECORDING ARTIST NAME", "id": "RECORDING ARTIST ID", - "sort-name": "RECORDING ARTIST SORT NAME", + "sort_name": "RECORDING ARTIST SORT NAME", }, "name": "RECORDING ARTIST CREDIT", } ] if multi_artist_credit: - track["artist-credit"][0]["joinphrase"] = " & " - track["artist-credit"].append( + 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", + "sort_name": "RECORDING ARTIST 2 SORT NAME", }, "name": "RECORDING ARTIST 2 CREDIT", } ) if remixer: - track["artist-relations"] = [ + track["artist_relations"] = [ { "type": "remixer", - "type-id": "RELATION TYPE ID", + "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", + "sort_name": "RECORDING REMIXER ARTIST SORT NAME", }, } ] @@ -354,7 +354,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_detect_various_artists(self): release = self._make_release(None) - release["artist-credit"][0]["artist"]["id"] = ( + release["artist_credit"][0]["artist"]["id"] = ( musicbrainz.VARIOUS_ARTISTS_ID ) d = self.mb.album_info(release) @@ -429,7 +429,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_missing_language(self): release = self._make_release(None) - del release["text-representation"]["language"] + del release["text_representation"]["language"] d = self.mb.album_info(release) assert d.language is None @@ -697,7 +697,7 @@ class ArtistFlatteningTest(unittest.TestCase): return { "artist": { "name": f"NAME{suffix}", - "sort-name": f"SORT{suffix}", + "sort_name": f"SORT{suffix}", }, "name": f"CREDIT{suffix}", } @@ -706,7 +706,7 @@ class ArtistFlatteningTest(unittest.TestCase): alias = { "name": f"ALIAS{suffix}", "locale": locale, - "sort-name": f"ALIASSORT{suffix}", + "sort_name": f"ALIASSORT{suffix}", } if primary: alias["primary"] = "primary" @@ -810,7 +810,7 @@ class MBLibraryTest(MusicBrainzTestCase): "position": 5, } ], - "artist-credit": [ + "artist_credit": [ { "artist": { "name": "some-artist", @@ -818,10 +818,10 @@ class MBLibraryTest(MusicBrainzTestCase): }, } ], - "release-group": { + "release_group": { "id": "another-id", }, - "release-relations": [ + "release_relations": [ { "type": "transl-tracklisting", "direction": "backward", @@ -852,7 +852,7 @@ class MBLibraryTest(MusicBrainzTestCase): "position": 5, } ], - "artist-credit": [ + "artist_credit": [ { "artist": { "name": "some-artist", @@ -860,7 +860,7 @@ class MBLibraryTest(MusicBrainzTestCase): }, } ], - "release-group": { + "release_group": { "id": "another-id", }, "country": "COUNTRY", @@ -897,7 +897,7 @@ class MBLibraryTest(MusicBrainzTestCase): "position": 5, } ], - "artist-credit": [ + "artist_credit": [ { "artist": { "name": "some-artist", @@ -905,7 +905,7 @@ class MBLibraryTest(MusicBrainzTestCase): }, } ], - "release-group": { + "release_group": { "id": "another-id", }, } @@ -941,7 +941,7 @@ class MBLibraryTest(MusicBrainzTestCase): "position": 5, } ], - "artist-credit": [ + "artist_credit": [ { "artist": { "name": "some-artist", @@ -949,7 +949,7 @@ class MBLibraryTest(MusicBrainzTestCase): }, } ], - "release-group": { + "release_group": { "id": "another-id", }, } @@ -985,7 +985,7 @@ class MBLibraryTest(MusicBrainzTestCase): "position": 5, } ], - "artist-credit": [ + "artist_credit": [ { "artist": { "name": "some-artist", @@ -993,10 +993,10 @@ class MBLibraryTest(MusicBrainzTestCase): }, } ], - "release-group": { + "release_group": { "id": "another-id", }, - "release-relations": [ + "release_relations": [ { "type": "remaster", "direction": "backward", @@ -1097,10 +1097,10 @@ class TestMusicBrainzPlugin(PluginMixin): "position": 5, } ], - "artist-credit": [ + "artist_credit": [ {"artist": {"name": "some-artist", "id": "some-id"}} ], - "release-group": {"id": "another-id"}, + "release_group": {"id": "another-id"}, }, ) candidates = list(mb.candidates([], "hello", "there", False)) 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" }, From 432102ebb694e79bf9c7b9386864554563206b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 16 Jan 2026 22:29:26 +0000 Subject: [PATCH 02/29] Type musicbrainz fully --- beetsplug/_utils/musicbrainz.py | 414 +++++++++++++++++++++++++++++++- beetsplug/mbpseudo.py | 14 +- beetsplug/musicbrainz.py | 66 +++-- setup.cfg | 9 + 4 files changed, 468 insertions(+), 35 deletions(-) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index ee94316e7..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) @@ -175,10 +575,10 @@ class MusicBrainzAPI(RequestHandler): 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. diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py index 9e4be4bad..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,7 +167,7 @@ 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 [] @@ -173,7 +179,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): 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"): @@ -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 diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 1dc2ddfaa..4dc44a289 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -20,7 +20,7 @@ from collections import Counter 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 from urllib.parse import urljoin from confuse.exceptions import NotFoundError @@ -37,11 +37,18 @@ 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, + Recording, + Release, + ) VARIOUS_ARTISTS_ID = "89ad4ac3-39f7-470e-963a-56509c546377" @@ -97,9 +104,14 @@ BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 +UrlSource = Literal[ + "discogs", "bandcamp", "spotify", "deezer", "tidal", "beatport" +] + + def _preferred_alias( - aliases: list[JSONDict], languages: list[str] | None = None -) -> JSONDict | None: + aliases: list[Alias], languages: list[str] | None = None +) -> Alias | None: """Given a list of alias structures for an artist credit, select and return the user's preferred alias or None if no matching """ @@ -139,7 +151,7 @@ def _preferred_alias( def _multi_artist_credit( - credit: list[JSONDict], include_join_phrase: bool + credit: list[ArtistCredit], 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 @@ -149,7 +161,7 @@ def _multi_artist_credit( artist_sort_parts = [] artist_credit_parts = [] for el in credit: - alias = _preferred_alias(el["artist"].get("aliases", ())) + alias = _preferred_alias(el["artist"].get("aliases", [])) # An artist. if alias: @@ -188,7 +200,7 @@ def track_url(trackid: str) -> str: return urljoin(BASE_URL, f"recording/{trackid}") -def _flatten_artist_credit(credit: list[JSONDict]) -> tuple[str, str, str]: +def _flatten_artist_credit(credit: list[ArtistCredit]) -> 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. @@ -203,7 +215,7 @@ def _flatten_artist_credit(credit: list[JSONDict]) -> tuple[str, str, str]: ) -def _artist_ids(credit: list[JSONDict]) -> list[str]: +def _artist_ids(credit: list[ArtistCredit]) -> list[str]: """ Given a list representing an ``artist-credit``, return a list of artist IDs @@ -216,7 +228,9 @@ def _artist_ids(credit: list[JSONDict]) -> list[str]: return artist_ids -def _get_related_artist_names(relations, relation_type): +def _get_related_artist_names( + relations: list[ArtistRelation], relation_type: ArtistRelationType +) -> str: """Given a list representing the artist relationships extract the names of the remixers and concatenate them. """ @@ -233,9 +247,7 @@ 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]: +def _preferred_release_event(release: Release) -> 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. @@ -260,7 +272,7 @@ def _set_date_str( info: beets.autotag.hooks.AlbumInfo, date_str: str, original: bool = False, -): +) -> None: """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. @@ -322,10 +334,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. """ @@ -357,7 +373,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): def track_info( self, - recording: JSONDict, + recording: Recording, index: int | None = None, medium: int | None = None, medium_index: int | None = None, @@ -458,7 +474,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): return info - def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo: + 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. """ @@ -482,7 +498,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( @@ -673,18 +689,20 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): # We might find links to external sources (Discogs, Bandcamp, ...) external_ids = self.config["external_ids"].get() - wanted_sources = { + wanted_sources: set[UrlSource] = { 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 + for url_source, url_relation in product(wanted_sources, url_rels): + if f"{url_source}.com" in ( + target := url_relation["url"]["resource"] + ): + urls[url_source] = target self._log.debug( "Found link to {} release via MusicBrainz", - source.capitalize(), + url_source.capitalize(), ) for source, url in urls.items(): 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 From 78d7a940411eda9f6becb3fece77542624aedcb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 21 Jan 2026 21:54:03 +0000 Subject: [PATCH 03/29] Simplify parsing recordings This way we only ever handle full Recording objects. --- beetsplug/musicbrainz.py | 5 ++--- test/plugins/test_musicbrainz.py | 8 ++++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 4dc44a289..78baaa7db 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -789,10 +789,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 diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 49039f2ba..663a34176 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -1022,7 +1022,7 @@ class TestMusicBrainzPlugin(PluginMixin): mbid = "d2a6f856-b553-40a0-ac54-a321e8e2da99" RECORDING: ClassVar[dict[str, int | str]] = { "title": "foo", - "id": "bar", + "id": "00000000-0000-0000-0000-000000000000", "length": 42, } @@ -1065,7 +1065,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")) From aa640e57b7e58c7a02e3e6762f59c798ae85e01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 17 Jan 2026 18:17:45 +0000 Subject: [PATCH 04/29] Rename track -> recording in tests for clarity --- test/plugins/test_musicbrainz.py | 223 +++++++++++++++++-------------- 1 file changed, 124 insertions(+), 99 deletions(-) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 663a34176..8b0dd7678 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -39,7 +39,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def _make_release( self, date_str="2009", - tracks=None, + recordings=None, track_length=None, track_artist=False, multi_artist_credit=False, @@ -102,8 +102,8 @@ class MBAlbumInfoTest(MusicBrainzTestCase): i = 0 track_list = [] - if tracks: - for recording in tracks: + if recordings: + for recording in recordings: i += 1 track = { "id": f"RELEASE TRACK ID {i}", @@ -164,7 +164,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ) return release - def _make_track( + def _make_recording( self, title, tr_id, @@ -175,14 +175,14 @@ class MBAlbumInfoTest(MusicBrainzTestCase): remixer=False, multi_artist_credit=False, ): - track = { + recording = { "title": title, "id": tr_id, } if duration is not None: - track["length"] = duration + recording["length"] = duration if artist: - track["artist_credit"] = [ + recording["artist_credit"] = [ { "artist": { "name": "RECORDING ARTIST NAME", @@ -193,8 +193,8 @@ class MBAlbumInfoTest(MusicBrainzTestCase): } ] if multi_artist_credit: - track["artist_credit"][0]["joinphrase"] = " & " - track["artist_credit"].append( + recording["artist_credit"][0]["joinphrase"] = " & " + recording["artist_credit"].append( { "artist": { "name": "RECORDING ARTIST 2 NAME", @@ -205,7 +205,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): } ) if remixer: - track["artist_relations"] = [ + recording["artist_relations"] = [ { "type": "remixer", "type_id": "RELATION TYPE ID", @@ -219,10 +219,10 @@ class MBAlbumInfoTest(MusicBrainzTestCase): } ] if video: - track["video"] = True + recording["video"] = True if disambiguation: - track["disambiguation"] = disambiguation - return track + recording["disambiguation"] = disambiguation + return recording def test_parse_release_with_year(self): release = self._make_release("1984") @@ -248,11 +248,11 @@ class MBAlbumInfoTest(MusicBrainzTestCase): 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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(tracks=tracks) + release = self._make_release(recordings=recordings) d = self.mb.album_info(release) t = d.tracks @@ -265,11 +265,11 @@ class MBAlbumInfoTest(MusicBrainzTestCase): 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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(tracks=tracks) + release = self._make_release(recordings=recordings) d = self.mb.album_info(release) t = d.tracks @@ -279,11 +279,11 @@ 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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(tracks=tracks) + release = self._make_release(recordings=recordings) d = self.mb.album_info(release) assert d.mediums == 1 @@ -292,15 +292,15 @@ 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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(tracks=[tracks[0]]) + release = self._make_release(recordings=[recordings[0]]) second_track_list = [ { "id": "RELEASE TRACK ID 2", - "recording": tracks[1], + "recording": recordings[1], "position": "1", "number": "A1", } @@ -329,14 +329,16 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.original_month == 3 def test_no_durations(self): - tracks = [self._make_track("TITLE", "ID", None)] - release = self._make_release(tracks=tracks) + recordings = [self._make_recording("TITLE", "ID", None)] + release = self._make_release(recordings=recordings) 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) + recordings = [self._make_recording("TITLE", "ID", 1.0 * 1000.0)] + release = self._make_release( + recordings=recordings, track_length=2.0 * 1000.0 + ) d = self.mb.album_info(release) assert d.tracks[0].length == 2.0 @@ -402,11 +404,11 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.barcode == "BARCODE" 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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(None, tracks=tracks) + release = self._make_release(None, recordings=recordings) d = self.mb.album_info(release) assert d.media == "FORMAT" @@ -417,11 +419,11 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.releasegroupdisambig == "RG_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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(None, tracks=tracks) + release = self._make_release(None, recordings=recordings) d = self.mb.album_info(release) t = d.tracks assert t[0].disctitle == "MEDIUM TITLE" @@ -434,8 +436,8 @@ class MBAlbumInfoTest(MusicBrainzTestCase): 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) + recordings = [self._make_recording("a", "b", 1, True)] + release = self._make_release(None, recordings=recordings) track = self.mb.album_info(release).tracks[0] assert track.artist == "RECORDING ARTIST NAME" assert track.artist_id == "RECORDING ARTIST ID" @@ -443,8 +445,10 @@ class MBAlbumInfoTest(MusicBrainzTestCase): 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) + recordings = [ + self._make_recording("a", "b", 1, True, multi_artist_credit=True) + ] + release = self._make_release(None, recordings=recordings) 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" @@ -475,8 +479,10 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ] 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) + recordings = [self._make_recording("a", "b", 1, True)] + release = self._make_release( + None, recordings=recordings, track_artist=True + ) track = self.mb.album_info(release).tracks[0] assert track.artist == "TRACK ARTIST NAME" assert track.artist_id == "TRACK ARTIST ID" @@ -484,9 +490,14 @@ class MBAlbumInfoTest(MusicBrainzTestCase): 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)] + recordings = [ + self._make_recording("a", "b", 1, True, multi_artist_credit=True) + ] release = self._make_release( - None, tracks=tracks, track_artist=True, multi_artist_credit=True + None, + recordings=recordings, + track_artist=True, + multi_artist_credit=True, ) track = self.mb.album_info(release).tracks[0] assert track.artist == "TRACK ARTIST NAME & TRACK ARTIST 2 NAME" @@ -511,8 +522,8 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ] def test_parse_recording_remixer(self): - tracks = [self._make_track("a", "b", 1, remixer=True)] - release = self._make_release(None, tracks=tracks) + recordings = [self._make_recording("a", "b", 1, remixer=True)] + release = self._make_release(None, recordings=recordings) track = self.mb.album_info(release).tracks[0] assert track.remixer == "RECORDING REMIXER ARTIST NAME" @@ -543,47 +554,55 @@ class MBAlbumInfoTest(MusicBrainzTestCase): 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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(tracks=tracks, medium_format="IGNORED1") + release = self._make_release( + recordings=recordings, medium_format="IGNORED1" + ) 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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(tracks=tracks, medium_format="NON-IGNORED") + release = self._make_release( + recordings=recordings, medium_format="NON-IGNORED" + ) 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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording( + "[data track]", "ID DATA TRACK", 100.0 * 1000.0 + ), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(tracks=tracks) + release = self._make_release(recordings=recordings) 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" 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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] data_tracks = [ - self._make_track( + self._make_recording( "TITLE AUDIO DATA", "ID DATA TRACK", 100.0 * 1000.0 ) ] - release = self._make_release(tracks=tracks, data_tracks=data_tracks) + release = self._make_release( + recordings=recordings, data_tracks=data_tracks + ) d = self.mb.album_info(release) assert len(d.tracks) == 2 assert d.tracks[0].title == "TITLE ONE" @@ -591,16 +610,18 @@ class MBAlbumInfoTest(MusicBrainzTestCase): 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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] data_tracks = [ - self._make_track( + self._make_recording( "TITLE AUDIO DATA", "ID DATA TRACK", 100.0 * 1000.0 ) ] - release = self._make_release(tracks=tracks, data_tracks=data_tracks) + release = self._make_release( + recordings=recordings, data_tracks=data_tracks + ) d = self.mb.album_info(release) assert len(d.tracks) == 3 assert d.tracks[0].title == "TITLE ONE" @@ -608,30 +629,32 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.tracks[2].title == "TITLE AUDIO DATA" def test_skip_video_tracks_by_default(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track( + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording( "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True ), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(tracks=tracks) + release = self._make_release(recordings=recordings) 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" 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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] data_tracks = [ - self._make_track( + self._make_recording( "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True ) ] - release = self._make_release(tracks=tracks, data_tracks=data_tracks) + release = self._make_release( + recordings=recordings, data_tracks=data_tracks + ) d = self.mb.album_info(release) assert len(d.tracks) == 2 assert d.tracks[0].title == "TITLE ONE" @@ -640,14 +663,14 @@ class MBAlbumInfoTest(MusicBrainzTestCase): 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( + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording( "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True ), - self._make_track("TITLE TWO", "ID TWO", 200.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(tracks=tracks) + release = self._make_release(recordings=recordings) d = self.mb.album_info(release) assert len(d.tracks) == 3 assert d.tracks[0].title == "TITLE ONE" @@ -657,16 +680,18 @@ class MBAlbumInfoTest(MusicBrainzTestCase): 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), + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] data_tracks = [ - self._make_track( + self._make_recording( "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True ) ] - release = self._make_release(tracks=tracks, data_tracks=data_tracks) + release = self._make_release( + recordings=recordings, data_tracks=data_tracks + ) d = self.mb.album_info(release) assert len(d.tracks) == 3 assert d.tracks[0].title == "TITLE ONE" @@ -674,16 +699,16 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.tracks[2].title == "TITLE VIDEO" def test_track_disambiguation(self): - tracks = [ - self._make_track("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_track( + recordings = [ + self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), + self._make_recording( "TITLE TWO", "ID TWO", 200.0 * 1000.0, disambiguation="SECOND TRACK", ), ] - release = self._make_release(tracks=tracks) + release = self._make_release(recordings=recordings) d = self.mb.album_info(release) t = d.tracks From 86f8082fafeac2dfe6c6f433525ab5eec805c119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 17 Jan 2026 18:52:57 +0000 Subject: [PATCH 05/29] Fix recording parsing --- beetsplug/musicbrainz.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 78baaa7db..902e70016 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -421,10 +421,10 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): recording["artist_relations"], relation_type="remixer" ) - if recording.get("length"): - info.length = int(recording["length"]) / 1000.0 + if length := recording["length"]: + info.length = int(length) / 1000.0 - info.trackdisambig = recording.get("disambiguation") + info.trackdisambig = recording["disambiguation"] or None if recording.get("isrcs"): info.isrc = ";".join(recording["isrcs"]) From b24e4504d907945a6e7c6acb1eed4416c737d315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 21 Jan 2026 23:09:07 +0000 Subject: [PATCH 06/29] Ensure correct data shape in recordings tests --- test/plugins/test_musicbrainz.py | 153 +++++++++++++++++-------------- 1 file changed, 82 insertions(+), 71 deletions(-) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 8b0dd7678..c288526f6 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 @@ -27,6 +29,9 @@ from beets.library import Item from beets.test.helper import BeetsTestCase, PluginMixin from beetsplug import musicbrainz +if TYPE_CHECKING: + from beetsplug._utils import musicbrainz as mb + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): @@ -34,10 +39,8 @@ class MusicBrainzTestCase(BeetsTestCase): self.mb = musicbrainz.MusicBrainzPlugin() self.config["match"]["preferred"]["countries"] = ["US"] - -class MBAlbumInfoTest(MusicBrainzTestCase): + @staticmethod def _make_release( - self, date_str="2009", recordings=None, track_length=None, @@ -164,66 +167,86 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ) return release + @staticmethod def _make_recording( - self, title, tr_id, duration, - artist=False, video=False, - disambiguation=None, + disambiguation="", remixer=False, multi_artist_credit=False, - ): - recording = { + ) -> mb.Recording: + recording: mb.Recording = { "title": title, "id": tr_id, - } - if duration is not None: - recording["length"] = duration - if artist: - recording["artist_credit"] = [ + "length": duration, + "video": video, + "disambiguation": disambiguation, + "isrcs": [], + "aliases": [], + "artist_credit": [ { "artist": { "name": "RECORDING ARTIST NAME", "id": "RECORDING ARTIST ID", "sort_name": "RECORDING ARTIST SORT NAME", + "country": None, + "disambiguation": "", + "type": "Person", + "type_id": "b6e035f4-3ce9-331c-97df-83397230b0df", }, "name": "RECORDING ARTIST CREDIT", + "joinphrase": "", } - ] - if multi_artist_credit: - recording["artist_credit"][0]["joinphrase"] = " & " - recording["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 multi_artist_credit: + recording["artist_credit"][0]["joinphrase"] = " & " + recording["artist_credit"].append( + { + "artist": { + "name": "RECORDING ARTIST 2 NAME", + "id": "RECORDING ARTIST 2 ID", + "sort_name": "RECORDING ARTIST 2 SORT NAME", + "country": None, + "disambiguation": "", + "type": "Person", + "type_id": "b6e035f4-3ce9-331c-97df-83397230b0df", + }, + "name": "RECORDING ARTIST 2 CREDIT", + "joinphrase": "", + } + ) if remixer: recording["artist_relations"] = [ { "type": "remixer", "type_id": "RELATION TYPE ID", - "direction": "RECORDING RELATION DIRECTION", + "direction": "backward", "artist": { "id": "RECORDING REMIXER ARTIST ID", - "type": "RECORDING REMIXER ARTIST TYPE", + "type": "Person", "name": "RECORDING REMIXER ARTIST NAME", "sort_name": "RECORDING REMIXER ARTIST SORT NAME", + "country": "GB", + "disambiguation": "", + "type_id": "b6e035f4-3ce9-331c-97df-83397230b0df", }, + "attribute_ids": {}, + "attribute_values": {}, + "attributes": [], + "begin": None, + "end": None, + "ended": False, + "source_credit": "", + "target_credit": "", } ] - if video: - recording["video"] = True - if disambiguation: - recording["disambiguation"] = disambiguation return recording + +class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_release_with_year(self): release = self._make_release("1984") d = self.mb.album_info(release) @@ -436,7 +459,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.language is None def test_parse_recording_artist(self): - recordings = [self._make_recording("a", "b", 1, True)] + recordings = [self._make_recording("a", "b", 1)] release = self._make_release(None, recordings=recordings) track = self.mb.album_info(release).tracks[0] assert track.artist == "RECORDING ARTIST NAME" @@ -446,7 +469,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_recording_artist_multi(self): recordings = [ - self._make_recording("a", "b", 1, True, multi_artist_credit=True) + self._make_recording("a", "b", 1, multi_artist_credit=True) ] release = self._make_release(None, recordings=recordings) track = self.mb.album_info(release).tracks[0] @@ -479,7 +502,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ] def test_track_artist_overrides_recording_artist(self): - recordings = [self._make_recording("a", "b", 1, True)] + recordings = [self._make_recording("a", "b", 1)] release = self._make_release( None, recordings=recordings, track_artist=True ) @@ -491,7 +514,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_track_artist_overrides_recording_artist_multi(self): recordings = [ - self._make_recording("a", "b", 1, True, multi_artist_credit=True) + self._make_recording("a", "b", 1, multi_artist_credit=True) ] release = self._make_release( None, @@ -632,7 +655,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): recordings = [ self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_recording( - "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True + "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, video=True ), self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] @@ -649,7 +672,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ] data_tracks = [ self._make_recording( - "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True + "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, True ) ] release = self._make_release( @@ -666,7 +689,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): recordings = [ self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_recording( - "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True + "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, True ), self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] @@ -686,7 +709,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ] data_tracks = [ self._make_recording( - "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, False, True + "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, True ) ] release = self._make_release( @@ -823,11 +846,9 @@ class MBLibraryTest(MusicBrainzTestCase): "tracks": [ { "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, + "recording": self._make_recording( + "translated title", "bar", 42 + ), "position": 9, "number": "A1", } @@ -865,11 +886,9 @@ class MBLibraryTest(MusicBrainzTestCase): "tracks": [ { "id": "baz", - "recording": { - "title": "original title", - "id": "bar", - "length": 42, - }, + "recording": self._make_recording( + "original title", "bar", 42 + ), "position": 9, "number": "A1", } @@ -910,11 +929,9 @@ class MBLibraryTest(MusicBrainzTestCase): "tracks": [ { "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, + "recording": self._make_recording( + "translated title", "bar", 42 + ), "position": 9, "number": "A1", } @@ -954,11 +971,9 @@ class MBLibraryTest(MusicBrainzTestCase): "tracks": [ { "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, + "recording": self._make_recording( + "translated title", "bar", 42 + ), "position": 9, "number": "A1", } @@ -998,11 +1013,9 @@ class MBLibraryTest(MusicBrainzTestCase): "tracks": [ { "id": "baz", - "recording": { - "title": "translated title", - "id": "bar", - "length": 42, - }, + "recording": self._make_recording( + "translated title", "bar", 42 + ), "position": 9, "number": "A1", } @@ -1045,11 +1058,9 @@ class TestMusicBrainzPlugin(PluginMixin): plugin = "musicbrainz" mbid = "d2a6f856-b553-40a0-ac54-a321e8e2da99" - RECORDING: ClassVar[dict[str, int | str]] = { - "title": "foo", - "id": "00000000-0000-0000-0000-000000000000", - "length": 42, - } + RECORDING: ClassVar[mb.Recording] = MusicBrainzTestCase._make_recording( + "foo", "00000000-0000-0000-0000-000000000000", 42 + ) @pytest.fixture def plugin_config(self): From cae421ba5c20771b64bbeb0a64129a26e5f00655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 21 Jan 2026 23:27:28 +0000 Subject: [PATCH 07/29] Refactor track_info taking into account Recording data shape --- beetsplug/musicbrainz.py | 131 +++++++++++++++++---------------------- 1 file changed, 58 insertions(+), 73 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 902e70016..b8059aeb7 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -54,8 +54,6 @@ 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", @@ -379,12 +377,14 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): 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. + """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"], @@ -395,78 +395,70 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): medium_total=medium_total, data_source=self.data_source, data_url=track_url(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 + ), ) - if recording.get("artist_credit"): - # Get the artist names. - ( - info.artist, - info.artist_sort, - info.artist_credit, - ) = _flatten_artist_credit(recording["artist_credit"]) + # Get the artist names. + ( + info.artist, + info.artist_sort, + info.artist_credit, + ) = _flatten_artist_credit(recording["artist_credit"]) - ( - info.artists, - info.artists_sort, - info.artists_credit, - ) = _multi_artist_credit( - recording["artist_credit"], include_join_phrase=False - ) + ( + 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] + 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 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 - if length := recording["length"]: - info.length = int(length) / 1000.0 - - info.trackdisambig = recording["disambiguation"] or None - - if recording.get("isrcs"): - info.isrc = ";".join(recording["isrcs"]) - - lyricist = [] - composer = [] - composer_sort = [] + 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: @@ -534,15 +526,8 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): 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"] + if track["recording"]["title"] == "[data track]" or ( + track["recording"]["video"] and config["match"]["ignore_video_tracks"] ): continue From 2754462c5ad2540aa4ab2c7291ffd1b494734653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 22 Jan 2026 01:06:01 +0000 Subject: [PATCH 08/29] Simplify aliases parsing --- beetsplug/musicbrainz.py | 43 ++++++--------- poetry.lock | 77 ++++++++++++++++++++++++++- pyproject.toml | 1 + test/plugins/factories/musicbrainz.py | 16 ++++++ test/plugins/test_musicbrainz.py | 65 +++++++++++----------- 5 files changed, 140 insertions(+), 62 deletions(-) create mode 100644 test/plugins/factories/musicbrainz.py diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index b8059aeb7..c6e51fc98 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -110,42 +110,31 @@ UrlSource = Literal[ def _preferred_alias( aliases: list[Alias], languages: list[str] | None = None ) -> Alias | None: - """Given a list of alias structures for an artist credit, select - and return the user's preferred alias or None if no matching - """ + """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( 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/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py new file mode 100644 index 000000000..f03cf9296 --- /dev/null +++ b/test/plugins/factories/musicbrainz.py @@ -0,0 +1,16 @@ +import factory + + +class AliasFactory(factory.DictFactory): + class Params: + suffix = "" + + begin: str | None = None + end: str | None = None + ended = factory.LazyAttribute(lambda obj: obj.end is not None) + locale: str | None = None + name = factory.LazyAttribute(lambda o: f"Alias {o.suffix}") + primary = False + sort_name = factory.LazyAttribute(lambda o: f"{o.name}, The") + type = "Artist name" + type_id = "894afba6-2816-3c24-8072-eadb66bd04bc" diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index c288526f6..9f52ffe0a 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -29,10 +29,16 @@ from beets.library import Item from beets.test.helper import BeetsTestCase, PluginMixin from beetsplug import musicbrainz +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) + + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() @@ -740,7 +746,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].trackdisambig == "SECOND TRACK" -class ArtistFlatteningTest(unittest.TestCase): +class ArtistTest(unittest.TestCase): def _credit_dict(self, suffix=""): return { "artist": { @@ -750,18 +756,6 @@ class ArtistFlatteningTest(unittest.TestCase): "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) - def test_single_artist(self): credit = [self._credit_dict()] a, s, c = musicbrainz._flatten_artist_credit(credit) @@ -793,45 +787,48 @@ class ArtistFlatteningTest(unittest.TestCase): assert s == ["SORTa", "SORTb"] assert c == ["CREDITa", "CREDITb"] - 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): From c39c5021b3d51f6e246d9030d2e1fcc1592c53dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 22 Jan 2026 01:24:39 +0000 Subject: [PATCH 09/29] Simplify multi artist credit parsing --- beetsplug/musicbrainz.py | 170 +++++++++++-------------------- test/plugins/test_musicbrainz.py | 47 +++++---- 2 files changed, 86 insertions(+), 131 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index c6e51fc98..c3c7388bd 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -20,7 +20,7 @@ from collections import Counter from contextlib import suppress from functools import cached_property from itertools import product -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, TypedDict from urllib.parse import urljoin from confuse.exceptions import NotFoundError @@ -107,6 +107,15 @@ UrlSource = Literal[ ] +class ArtistInfo(TypedDict): + artist: str + artist_sort: str + artist_credit: str + artists: list[str] + artists_sort: list[str] + artists_credit: list[str] + + def _preferred_alias( aliases: list[Alias], languages: list[str] | None = None ) -> Alias | None: @@ -137,71 +146,10 @@ def _preferred_alias( return next(matches, None) -def _multi_artist_credit( - credit: list[ArtistCredit], 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 track_url(trackid: str) -> str: return urljoin(BASE_URL, f"recording/{trackid}") -def _flatten_artist_credit(credit: list[ArtistCredit]) -> 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. - """ - 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[ArtistCredit]) -> list[str]: """ Given a list representing an ``artist-credit``, @@ -358,6 +306,53 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): "'musicbrainz.search_limit'", ) + @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] = [] + + for el in artist_credits: + 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_sort": "".join(artist_sort_parts), + "artist_credit": "".join(artist_credit_parts), + "artists": artists, + "artists_sort": artists_sort, + "artists_credit": artists_credit, + } + def track_info( self, recording: Recording, @@ -393,21 +388,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): isrc=( ";".join(isrcs) if (isrcs := recording.get("isrcs")) else None ), - ) - - # Get the artist names. - ( - info.artist, - info.artist_sort, - info.artist_credit, - ) = _flatten_artist_credit(recording["artist_credit"]) - - ( - info.artists, - info.artists_sort, - info.artists_credit, - ) = _multi_artist_credit( - recording["artist_credit"], include_join_phrase=False + **self._parse_artist_credits(recording["artist_credit"]), ) info.artists_ids = _artist_ids(recording["artist_credit"]) @@ -459,19 +440,6 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): """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' @@ -539,19 +507,8 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): 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.update( + **self._parse_artist_credits(track["artist_credit"]) ) ti.artists_ids = _artist_ids(track["artist_credit"]) @@ -563,18 +520,13 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): 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, 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"]), barcode=release.get("barcode"), diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 9f52ffe0a..d1eeb5ef2 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -28,6 +28,7 @@ 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 @@ -747,45 +748,47 @@ class MBAlbumInfoTest(MusicBrainzTestCase): class ArtistTest(unittest.TestCase): - def _credit_dict(self, suffix=""): + def _credit_dict(self, suffix="", joinphrase="") -> mb.ArtistCredit: return { "artist": { "name": f"NAME{suffix}", + "id": f"ID{suffix}", "sort_name": f"SORT{suffix}", + "country": None, + "disambiguation": "", + "type": "Person", + "type_id": "b6e035f4-3ce9-331c-97df-83397230b0df", }, "name": f"CREDIT{suffix}", + "joinphrase": joinphrase, } 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" - 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": "NAME", + "artist_sort": "SORT", + "artist_credit": "CREDIT", + "artists": ["NAME"], + "artists_sort": ["SORT"], + "artists_credit": ["CREDIT"], + } def test_two_artists(self): credit = [ - {**self._credit_dict("a"), "joinphrase": " AND "}, + self._credit_dict("a", " AND "), self._credit_dict("b"), ] - 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": "NAMEa AND NAMEb", + "artist_sort": "SORTa AND SORTb", + "artist_credit": "CREDITa AND CREDITb", + "artists": ["NAMEa", "NAMEb"], + "artists_sort": ["SORTa", "SORTb"], + "artists_credit": ["CREDITa", "CREDITb"], + } def test_preferred_alias(self): aliases = [ From 70df5de2a71fd81f97163326e8d5df6b988fca85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 22 Jan 2026 14:01:08 +0000 Subject: [PATCH 10/29] Use ArtistCreditFactory for test artist_credit construction --- test/plugins/factories/musicbrainz.py | 29 ++- test/plugins/test_musicbrainz.py | 243 ++++++++------------------ 2 files changed, 102 insertions(+), 170 deletions(-) diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py index f03cf9296..cae21bb98 100644 --- a/test/plugins/factories/musicbrainz.py +++ b/test/plugins/factories/musicbrainz.py @@ -1,7 +1,12 @@ import factory -class AliasFactory(factory.DictFactory): +class _SortNameFactory(factory.DictFactory): + name: str + sort_name = factory.LazyAttribute(lambda o: f"{o.name}, The") + + +class AliasFactory(_SortNameFactory): class Params: suffix = "" @@ -11,6 +16,26 @@ class AliasFactory(factory.DictFactory): locale: str | None = None name = factory.LazyAttribute(lambda o: f"Alias {o.suffix}") primary = False - sort_name = factory.LazyAttribute(lambda o: f"{o.name}, The") type = "Artist name" type_id = "894afba6-2816-3c24-8072-eadb66bd04bc" + + +class ArtistFactory(_SortNameFactory): + class Params: + id_base = 0 + index = 1 + + country: str | None = None + disambiguation = "" + id = factory.LazyAttribute( + lambda o: f"00000000-0000-0000-0000-{o.id_base + o.index:012d}" + ) + 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") diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index d1eeb5ef2..0cd63849f 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -40,6 +40,10 @@ def alias_factory(**kwargs) -> mb.Alias: return factories.AliasFactory.build(**kwargs) +def artist_credit_factory(**kwargs) -> mb.ArtistCredit: + return factories.ArtistCreditFactory.build(**kwargs) + + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() @@ -67,16 +71,7 @@ class MusicBrainzTestCase(BeetsTestCase): "id": "RELEASE GROUP ID", "disambiguation": "RG_DISAMBIGUATION", }, - "artist_credit": [ - { - "artist": { - "name": "ARTIST NAME", - "id": "ARTIST ID", - "sort_name": "ARTIST SORT NAME", - }, - "name": "ARTIST CREDIT", - } - ], + "artist_credit": [artist_credit_factory(artist__id_base=10)], "date": "3001", "media": [], "genres": [{"count": 1, "name": "GENRE"}], @@ -100,14 +95,7 @@ class MusicBrainzTestCase(BeetsTestCase): 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", - } + artist_credit_factory(artist__name="Other Artist") ) i = 0 @@ -128,27 +116,16 @@ class MusicBrainzTestCase(BeetsTestCase): # 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", - } + artist_credit_factory(artist__name="Track Artist") ] 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", - } + artist_credit_factory( + artist__name="Other Track Artist", + artist__index=2, + ) ) track_list.append(track) @@ -193,37 +170,16 @@ class MusicBrainzTestCase(BeetsTestCase): "isrcs": [], "aliases": [], "artist_credit": [ - { - "artist": { - "name": "RECORDING ARTIST NAME", - "id": "RECORDING ARTIST ID", - "sort_name": "RECORDING ARTIST SORT NAME", - "country": None, - "disambiguation": "", - "type": "Person", - "type_id": "b6e035f4-3ce9-331c-97df-83397230b0df", - }, - "name": "RECORDING ARTIST CREDIT", - "joinphrase": "", - } + artist_credit_factory(artist__name="Recording Artist") ], } if multi_artist_credit: recording["artist_credit"][0]["joinphrase"] = " & " recording["artist_credit"].append( - { - "artist": { - "name": "RECORDING ARTIST 2 NAME", - "id": "RECORDING ARTIST 2 ID", - "sort_name": "RECORDING ARTIST 2 SORT NAME", - "country": None, - "disambiguation": "", - "type": "Person", - "type_id": "b6e035f4-3ce9-331c-97df-83397230b0df", - }, - "name": "RECORDING ARTIST 2 CREDIT", - "joinphrase": "", - } + artist_credit_factory( + artist__name="Other Recording Artist", + artist__index=2, + ) ) if remixer: recording["artist_relations"] = [ @@ -259,11 +215,11 @@ class MBAlbumInfoTest(MusicBrainzTestCase): 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.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.artist_credit == "Artist Credit" def test_parse_release_type(self): release = self._make_release("1984") @@ -395,7 +351,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_artist_sort_name(self): release = self._make_release(None) 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) @@ -469,10 +425,10 @@ class MBAlbumInfoTest(MusicBrainzTestCase): recordings = [self._make_recording("a", "b", 1)] release = self._make_release(None, recordings=recordings) 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): recordings = [ @@ -480,32 +436,32 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ] release = self._make_release(None, recordings=recordings) 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): @@ -514,10 +470,10 @@ class MBAlbumInfoTest(MusicBrainzTestCase): None, recordings=recordings, track_artist=True ) 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): recordings = [ @@ -530,25 +486,28 @@ class MBAlbumInfoTest(MusicBrainzTestCase): multi_artist_credit=True, ) 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): @@ -748,46 +707,31 @@ class MBAlbumInfoTest(MusicBrainzTestCase): class ArtistTest(unittest.TestCase): - def _credit_dict(self, suffix="", joinphrase="") -> mb.ArtistCredit: - return { - "artist": { - "name": f"NAME{suffix}", - "id": f"ID{suffix}", - "sort_name": f"SORT{suffix}", - "country": None, - "disambiguation": "", - "type": "Person", - "type_id": "b6e035f4-3ce9-331c-97df-83397230b0df", - }, - "name": f"CREDIT{suffix}", - "joinphrase": joinphrase, - } - def test_single_artist(self): - credit = [self._credit_dict()] + credit = [artist_credit_factory(artist__name="Artist")] assert MusicBrainzPlugin._parse_artist_credits(credit) == { - "artist": "NAME", - "artist_sort": "SORT", - "artist_credit": "CREDIT", - "artists": ["NAME"], - "artists_sort": ["SORT"], - "artists_credit": ["CREDIT"], + "artist": "Artist", + "artist_sort": "Artist, The", + "artist_credit": "Artist Credit", + "artists": ["Artist"], + "artists_sort": ["Artist, The"], + "artists_credit": ["Artist Credit"], } def test_two_artists(self): credit = [ - self._credit_dict("a", " AND "), - self._credit_dict("b"), + artist_credit_factory(artist__name="Artist", joinphrase=" AND "), + artist_credit_factory(artist__name="Other Artist"), ] assert MusicBrainzPlugin._parse_artist_credits(credit) == { - "artist": "NAMEa AND NAMEb", - "artist_sort": "SORTa AND SORTb", - "artist_credit": "CREDITa AND CREDITb", - "artists": ["NAMEa", "NAMEb"], - "artists_sort": ["SORTa", "SORTb"], - "artists_credit": ["CREDITa", "CREDITb"], + "artist": "Artist AND Other Artist", + "artist_sort": "Artist, The AND Other Artist, The", + "artist_credit": "Artist Credit AND Other Artist Credit", + "artists": ["Artist", "Other Artist"], + "artists_sort": ["Artist, The", "Other Artist, The"], + "artists_credit": ["Artist Credit", "Other Artist Credit"], } def test_preferred_alias(self): @@ -856,14 +800,7 @@ class MBLibraryTest(MusicBrainzTestCase): "position": 5, } ], - "artist_credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], + "artist_credit": [artist_credit_factory()], "release_group": { "id": "another-id", }, @@ -896,14 +833,7 @@ class MBLibraryTest(MusicBrainzTestCase): "position": 5, } ], - "artist_credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], + "artist_credit": [artist_credit_factory()], "release_group": { "id": "another-id", }, @@ -939,14 +869,7 @@ class MBLibraryTest(MusicBrainzTestCase): "position": 5, } ], - "artist_credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], + "artist_credit": [artist_credit_factory()], "release_group": { "id": "another-id", }, @@ -981,14 +904,7 @@ class MBLibraryTest(MusicBrainzTestCase): "position": 5, } ], - "artist_credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], + "artist_credit": [artist_credit_factory()], "release_group": { "id": "another-id", }, @@ -1023,14 +939,7 @@ class MBLibraryTest(MusicBrainzTestCase): "position": 5, } ], - "artist_credit": [ - { - "artist": { - "name": "some-artist", - "id": "some-id", - }, - } - ], + "artist_credit": [artist_credit_factory()], "release_group": { "id": "another-id", }, @@ -1137,9 +1046,7 @@ class TestMusicBrainzPlugin(PluginMixin): "position": 5, } ], - "artist_credit": [ - {"artist": {"name": "some-artist", "id": "some-id"}} - ], + "artist_credit": [artist_credit_factory()], "release_group": {"id": "another-id"}, }, ) From 0dcb216d4f4c254bfbbf44cb357daab3299708ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 22 Jan 2026 01:37:03 +0000 Subject: [PATCH 11/29] Remove _artist_ids --- beetsplug/musicbrainz.py | 28 ++++++---------------------- test/plugins/test_musicbrainz.py | 11 ++++++++++- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index c3c7388bd..428490f46 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -109,9 +109,11 @@ UrlSource = Literal[ 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] @@ -150,19 +152,6 @@ def track_url(trackid: str) -> str: return urljoin(BASE_URL, f"recording/{trackid}") -def _artist_ids(credit: list[ArtistCredit]) -> 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: list[ArtistRelation], relation_type: ArtistRelationType ) -> str: @@ -330,8 +319,10 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): 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"] @@ -346,9 +337,11 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): 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, } @@ -391,9 +384,6 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): **self._parse_artist_credits(recording["artist_credit"]), ) - info.artists_ids = _artist_ids(recording["artist_credit"]) - info.artist_id = info.artists_ids[0] - if artist_relations := recording.get("artist_relations"): if remixer := _get_related_artist_names( artist_relations, "remixer" @@ -510,21 +500,15 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): ti.update( **self._parse_artist_credits(track["artist_credit"]) ) - - 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_id=album_artist_ids[0], - artists_ids=album_artist_ids, tracks=track_infos, mediums=len(release["media"]), data_source=self.data_source, diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 0cd63849f..e72c08b2c 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -712,9 +712,11 @@ class ArtistTest(unittest.TestCase): 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"], } @@ -722,14 +724,21 @@ class ArtistTest(unittest.TestCase): def test_two_artists(self): credit = [ artist_credit_factory(artist__name="Artist", joinphrase=" AND "), - artist_credit_factory(artist__name="Other Artist"), + artist_credit_factory( + artist__name="Other Artist", artist__id_suffix="1" + ), ] 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"], } From 1ab72230e020f00406600fb0459898359542fc15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 22 Jan 2026 13:11:06 +0000 Subject: [PATCH 12/29] Use ArtistFactory --- test/plugins/test_musicbrainz.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index e72c08b2c..64d436820 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -40,6 +40,10 @@ def alias_factory(**kwargs) -> mb.Alias: return factories.AliasFactory.build(**kwargs) +def artist_factory(**kwargs) -> mb.Artist: + return factories.ArtistFactory.build(**kwargs) + + def artist_credit_factory(**kwargs) -> mb.ArtistCredit: return factories.ArtistCreditFactory.build(**kwargs) @@ -187,15 +191,7 @@ class MusicBrainzTestCase(BeetsTestCase): "type": "remixer", "type_id": "RELATION TYPE ID", "direction": "backward", - "artist": { - "id": "RECORDING REMIXER ARTIST ID", - "type": "Person", - "name": "RECORDING REMIXER ARTIST NAME", - "sort_name": "RECORDING REMIXER ARTIST SORT NAME", - "country": "GB", - "disambiguation": "", - "type_id": "b6e035f4-3ce9-331c-97df-83397230b0df", - }, + "artist": artist_factory(name="Recording Remixer"), "attribute_ids": {}, "attribute_values": {}, "attributes": [], @@ -514,7 +510,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): recordings = [self._make_recording("a", "b", 1, remixer=True)] release = self._make_release(None, recordings=recordings) 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() From 2b9bad71c5c68dc5b37ee1125160ab26464a32d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 22 Jan 2026 13:55:32 +0000 Subject: [PATCH 13/29] Refactor _get_related_artist_names --- beetsplug/musicbrainz.py | 14 ++++-------- test/plugins/factories/musicbrainz.py | 31 +++++++++++++++++++++++---- test/plugins/test_musicbrainz.py | 21 ++++++------------ 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 428490f46..5bbcea2fa 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -155,16 +155,10 @@ def track_url(trackid: str) -> str: def _get_related_artist_names( relations: list[ArtistRelation], relation_type: ArtistRelationType ) -> str: - """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) + """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 album_url(albumid: str) -> str: diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py index cae21bb98..8ef6f390d 100644 --- a/test/plugins/factories/musicbrainz.py +++ b/test/plugins/factories/musicbrainz.py @@ -1,4 +1,7 @@ import factory +from factory.fuzzy import FuzzyChoice + +from beetsplug._utils.musicbrainz import ArtistRelationType class _SortNameFactory(factory.DictFactory): @@ -6,13 +9,16 @@ class _SortNameFactory(factory.DictFactory): sort_name = factory.LazyAttribute(lambda o: f"{o.name}, The") -class AliasFactory(_SortNameFactory): - class Params: - suffix = "" - +class _PeriodFactory(factory.DictFactory): begin: str | None = None end: str | None = None ended = factory.LazyAttribute(lambda obj: obj.end is not None) + + +class AliasFactory(_SortNameFactory, _PeriodFactory): + class Params: + suffix = "" + locale: str | None = None name = factory.LazyAttribute(lambda o: f"Alias {o.suffix}") primary = False @@ -39,3 +45,20 @@ 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") diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 64d436820..04c926fdf 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -48,6 +48,10 @@ def artist_credit_factory(**kwargs) -> mb.ArtistCredit: return factories.ArtistCreditFactory.build(**kwargs) +def artist_relation_factory(**kwargs) -> mb.ArtistRelation: + return factories.ArtistRelationFactory.build(**kwargs) + + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() @@ -187,20 +191,9 @@ class MusicBrainzTestCase(BeetsTestCase): ) if remixer: recording["artist_relations"] = [ - { - "type": "remixer", - "type_id": "RELATION TYPE ID", - "direction": "backward", - "artist": artist_factory(name="Recording Remixer"), - "attribute_ids": {}, - "attribute_values": {}, - "attributes": [], - "begin": None, - "end": None, - "ended": False, - "source_credit": "", - "target_credit": "", - } + artist_relation_factory( + type="remixer", artist__name="Recording Remixer" + ) ] return recording From 2d44c3133bebace85c5dbe1dca178e85ac78019a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 22 Jan 2026 13:57:05 +0000 Subject: [PATCH 14/29] Remove album_url, track_url --- beetsplug/musicbrainz.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 5bbcea2fa..ee02b0728 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -148,10 +148,6 @@ def _preferred_alias( return next(matches, None) -def track_url(trackid: str) -> str: - return urljoin(BASE_URL, f"recording/{trackid}") - - def _get_related_artist_names( relations: list[ArtistRelation], relation_type: ArtistRelationType ) -> str: @@ -161,10 +157,6 @@ def _get_related_artist_names( ) -def album_url(albumid: str) -> str: - return urljoin(BASE_URL, f"release/{albumid}") - - def _preferred_release_event(release: Release) -> 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 @@ -365,7 +357,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): 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"]) @@ -506,7 +498,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): tracks=track_infos, mediums=len(release["media"]), data_source=self.data_source, - data_url=album_url(release["id"]), + data_url=urljoin(BASE_URL, f"release/{release['id']}"), barcode=release.get("barcode"), ) info.va = info.artist_id == VARIOUS_ARTISTS_ID From f0d712e8c5c1cb581ae252575b8f4e174f6567e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 23 Jan 2026 02:15:51 +0000 Subject: [PATCH 15/29] Refactor _preferred_release_event --- beetsplug/musicbrainz.py | 20 +++++++------------- test/plugins/factories/musicbrainz.py | 16 ++++++++++++++++ test/plugins/test_musicbrainz.py | 15 ++++++++++++--- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index ee02b0728..d39c74834 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -158,22 +158,16 @@ def _get_related_artist_names( def _preferred_release_event(release: Release) -> 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. + """Select the most relevant release country and date for matching. + + Fall back to the default release event if a preferred event is not found. """ - preferred_countries: 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") diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py index 8ef6f390d..79ee26f78 100644 --- a/test/plugins/factories/musicbrainz.py +++ b/test/plugins/factories/musicbrainz.py @@ -62,3 +62,19 @@ class ArtistRelationFactory(_PeriodFactory): 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") diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 04c926fdf..fbd3fbe09 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -52,6 +52,10 @@ def artist_relation_factory(**kwargs) -> mb.ArtistRelation: return factories.ArtistRelationFactory.build(**kwargs) +def release_event_factory(**kwargs) -> mb.ReleaseEvent: + return factories.ReleaseEventFactory.build(**kwargs) + + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() @@ -97,7 +101,12 @@ class MusicBrainzTestCase(BeetsTestCase): "country": "COUNTRY", "status": "STATUS", "barcode": "BARCODE", - "release_events": [{"area": None, "date": "2021-03-26"}], + "release_events": [ + release_event_factory(area=None, date="2021-03-26"), + release_event_factory( + area__iso_3166_1_codes=["US"], date="2020-01-01" + ), + ], } if multi_artist_credit: @@ -207,7 +216,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.artist == "Artist" assert d.artist_id == "00000000-0000-0000-0000-000000000011" assert d.original_year == 1984 - assert d.year == 3001 + assert d.year == 2020 assert d.artist_credit == "Artist Credit" def test_parse_release_type(self): @@ -366,7 +375,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_country(self): release = self._make_release(None) d = self.mb.album_info(release) - assert d.country == "COUNTRY" + assert d.country == "US" def test_parse_status(self): release = self._make_release(None) From 3318b539a8a64ced6e4991dc4257089bd402bf64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 23 Jan 2026 09:41:52 +0000 Subject: [PATCH 16/29] Refactor date parsing --- beetsplug/musicbrainz.py | 55 +++++++++++++++----------------- test/plugins/test_musicbrainz.py | 53 +++++++++++++++--------------- 2 files changed, 51 insertions(+), 57 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index d39c74834..b733ef8f4 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -172,28 +172,21 @@ def _preferred_release_event(release: Release) -> tuple[str | None, str | None]: return release.get("country"), release.get("date") -def _set_date_str( - info: beets.autotag.hooks.AlbumInfo, - date_str: str, - original: bool = False, -) -> None: - """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( @@ -528,16 +521,20 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): albumtypes.append(sec_type.lower()) info.albumtypes = albumtypes + info.original_year, info.original_month, info.original_day = _get_date( + release["release_group"]["first_release_date"] + ) # 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) + info.year, info.month, info.day = ( + _get_date(release_date) + if release_date + else ( + info.original_year, + info.original_month, + info.original_day, + ) + ) # Label name. if release.get("label_info"): diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index fbd3fbe09..e002f141a 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -64,7 +64,7 @@ class MusicBrainzTestCase(BeetsTestCase): @staticmethod def _make_release( - date_str="2009", + date="2009", recordings=None, track_length=None, track_artist=False, @@ -79,7 +79,7 @@ class MusicBrainzTestCase(BeetsTestCase): "disambiguation": "R_DISAMBIGUATION", "release_group": { "primary_type": "Album", - "first_release_date": date_str, + "first_release_date": date, "id": "RELEASE GROUP ID", "disambiguation": "RG_DISAMBIGUATION", }, @@ -209,7 +209,7 @@ class MusicBrainzTestCase(BeetsTestCase): class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_release_with_year(self): - release = self._make_release("1984") + release = self._make_release(date="1984") d = self.mb.album_info(release) assert d.album == "ALBUM TITLE" assert d.album_id == "ALBUM ID" @@ -220,12 +220,12 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.artist_credit == "Artist Credit" def test_parse_release_type(self): - release = self._make_release("1984") + release = self._make_release(date="1984") 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 = self._make_release(date="1987-03-31") d = self.mb.album_info(release) assert d.original_year == 1987 assert d.original_month == 3 @@ -307,7 +307,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].index == 2 def test_parse_release_year_month_only(self): - release = self._make_release("1987-03") + release = self._make_release(date="1987-03") d = self.mb.album_info(release) assert d.original_year == 1987 assert d.original_month == 3 @@ -327,19 +327,19 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.tracks[0].length == 2.0 def test_no_release_date(self): - release = self._make_release(None) + release = self._make_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 = self._make_release() d = self.mb.album_info(release) assert not d.va def test_detect_various_artists(self): - release = self._make_release(None) + release = self._make_release() release["artist_credit"][0]["artist"]["id"] = ( musicbrainz.VARIOUS_ARTISTS_ID ) @@ -347,43 +347,43 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.va def test_parse_artist_sort_name(self): - release = self._make_release(None) + release = self._make_release() d = self.mb.album_info(release) assert d.artist_sort == "Artist, The" def test_parse_releasegroupid(self): - release = self._make_release(None) + release = self._make_release() d = self.mb.album_info(release) assert d.releasegroup_id == "RELEASE GROUP ID" def test_parse_asin(self): - release = self._make_release(None) + release = self._make_release() d = self.mb.album_info(release) assert d.asin == "ALBUM ASIN" def test_parse_catalognum(self): - release = self._make_release(None) + release = self._make_release() d = self.mb.album_info(release) assert d.catalognum == "CATALOG NUMBER" def test_parse_textrepr(self): - release = self._make_release(None) + release = self._make_release() d = self.mb.album_info(release) assert d.script == "SCRIPT" assert d.language == "LANGUAGE" def test_parse_country(self): - release = self._make_release(None) + release = self._make_release() d = self.mb.album_info(release) assert d.country == "US" def test_parse_status(self): - release = self._make_release(None) + release = self._make_release() d = self.mb.album_info(release) assert d.albumstatus == "STATUS" def test_parse_barcode(self): - release = self._make_release(None) + release = self._make_release() d = self.mb.album_info(release) assert d.barcode == "BARCODE" @@ -392,12 +392,12 @@ class MBAlbumInfoTest(MusicBrainzTestCase): self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(None, recordings=recordings) + release = self._make_release(recordings=recordings) d = self.mb.album_info(release) assert d.media == "FORMAT" def test_parse_disambig(self): - release = self._make_release(None) + release = self._make_release() d = self.mb.album_info(release) assert d.albumdisambig == "R_DISAMBIGUATION" assert d.releasegroupdisambig == "RG_DISAMBIGUATION" @@ -407,21 +407,21 @@ class MBAlbumInfoTest(MusicBrainzTestCase): self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), ] - release = self._make_release(None, recordings=recordings) + release = self._make_release(recordings=recordings) d = self.mb.album_info(release) t = d.tracks assert t[0].disctitle == "MEDIUM TITLE" assert t[1].disctitle == "MEDIUM TITLE" def test_missing_language(self): - release = self._make_release(None) + release = self._make_release() del release["text_representation"]["language"] d = self.mb.album_info(release) assert d.language is None def test_parse_recording_artist(self): recordings = [self._make_recording("a", "b", 1)] - release = self._make_release(None, recordings=recordings) + release = self._make_release(recordings=recordings) track = self.mb.album_info(release).tracks[0] assert track.artist == "Recording Artist" assert track.artist_id == "00000000-0000-0000-0000-000000000001" @@ -432,7 +432,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): recordings = [ self._make_recording("a", "b", 1, multi_artist_credit=True) ] - release = self._make_release(None, recordings=recordings) + release = self._make_release(recordings=recordings) track = self.mb.album_info(release).tracks[0] assert track.artist == "Recording Artist & Other Recording Artist" assert track.artist_id == "00000000-0000-0000-0000-000000000001" @@ -464,9 +464,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_track_artist_overrides_recording_artist(self): recordings = [self._make_recording("a", "b", 1)] - release = self._make_release( - None, recordings=recordings, track_artist=True - ) + release = self._make_release(recordings=recordings, track_artist=True) track = self.mb.album_info(release).tracks[0] assert track.artist == "Track Artist" assert track.artist_id == "00000000-0000-0000-0000-000000000001" @@ -478,7 +476,6 @@ class MBAlbumInfoTest(MusicBrainzTestCase): self._make_recording("a", "b", 1, multi_artist_credit=True) ] release = self._make_release( - None, recordings=recordings, track_artist=True, multi_artist_credit=True, @@ -510,7 +507,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_recording_remixer(self): recordings = [self._make_recording("a", "b", 1, remixer=True)] - release = self._make_release(None, recordings=recordings) + release = self._make_release(recordings=recordings) track = self.mb.album_info(release).tracks[0] assert track.remixer == "Recording Remixer" From 4096ca608492e1b71a061f6e3f4429dcd86fbd34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 27 Jan 2026 01:54:13 +0000 Subject: [PATCH 17/29] Use ReleaseGroupFactory --- test/plugins/factories/musicbrainz.py | 49 +++++++++++++++++++++++---- test/plugins/test_musicbrainz.py | 37 +++++++------------- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py index 79ee26f78..36549d953 100644 --- a/test/plugins/factories/musicbrainz.py +++ b/test/plugins/factories/musicbrainz.py @@ -15,6 +15,16 @@ class _PeriodFactory(factory.DictFactory): 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 = "" @@ -23,19 +33,25 @@ class AliasFactory(_SortNameFactory, _PeriodFactory): name = factory.LazyAttribute(lambda o: f"Alias {o.suffix}") primary = False type = "Artist name" - type_id = "894afba6-2816-3c24-8072-eadb66bd04bc" + 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): +class ArtistFactory(_SortNameFactory, _IdFactory): class Params: id_base = 0 - index = 1 country: str | None = None disambiguation = "" - id = factory.LazyAttribute( - lambda o: f"00000000-0000-0000-0000-{o.id_base + o.index:012d}" - ) name = "Artist" type = "Person" type_id = "b6e035f4-3ce9-331c-97df-83397230b0df" @@ -78,3 +94,24 @@ class AreaFactory(factory.DictFactory): 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" diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index e002f141a..529f2d4b3 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -56,6 +56,10 @@ def release_event_factory(**kwargs) -> mb.ReleaseEvent: return factories.ReleaseEventFactory.build(**kwargs) +def release_group_factory(**kwargs) -> mb.ReleaseGroup: + return factories.ReleaseGroupFactory.build(**kwargs) + + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() @@ -77,12 +81,7 @@ class MusicBrainzTestCase(BeetsTestCase): "id": "ALBUM ID", "asin": "ALBUM ASIN", "disambiguation": "R_DISAMBIGUATION", - "release_group": { - "primary_type": "Album", - "first_release_date": date, - "id": "RELEASE GROUP ID", - "disambiguation": "RG_DISAMBIGUATION", - }, + "release_group": release_group_factory(first_release_date=date), "artist_credit": [artist_credit_factory(artist__id_base=10)], "date": "3001", "media": [], @@ -354,7 +353,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_releasegroupid(self): release = self._make_release() 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() @@ -400,7 +399,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): release = self._make_release() d = self.mb.album_info(release) assert d.albumdisambig == "R_DISAMBIGUATION" - assert d.releasegroupdisambig == "RG_DISAMBIGUATION" + assert d.releasegroupdisambig == "Release Group Disambiguation" def test_parse_disctitle(self): recordings = [ @@ -805,9 +804,7 @@ class MBLibraryTest(MusicBrainzTestCase): } ], "artist_credit": [artist_credit_factory()], - "release_group": { - "id": "another-id", - }, + "release_group": release_group_factory(), "release_relations": [ { "type": "transl-tracklisting", @@ -838,9 +835,7 @@ class MBLibraryTest(MusicBrainzTestCase): } ], "artist_credit": [artist_credit_factory()], - "release_group": { - "id": "another-id", - }, + "release_group": release_group_factory(), "country": "COUNTRY", }, ] @@ -874,9 +869,7 @@ class MBLibraryTest(MusicBrainzTestCase): } ], "artist_credit": [artist_credit_factory()], - "release_group": { - "id": "another-id", - }, + "release_group": release_group_factory(), } ] @@ -909,9 +902,7 @@ class MBLibraryTest(MusicBrainzTestCase): } ], "artist_credit": [artist_credit_factory()], - "release_group": { - "id": "another-id", - }, + "release_group": release_group_factory(), } ] @@ -944,9 +935,7 @@ class MBLibraryTest(MusicBrainzTestCase): } ], "artist_credit": [artist_credit_factory()], - "release_group": { - "id": "another-id", - }, + "release_group": release_group_factory(), "release_relations": [ { "type": "remaster", @@ -1051,7 +1040,7 @@ class TestMusicBrainzPlugin(PluginMixin): } ], "artist_credit": [artist_credit_factory()], - "release_group": {"id": "another-id"}, + "release_group": release_group_factory(), }, ) candidates = list(mb.candidates([], "hello", "there", False)) From 99e972e2a771090f993e3cb94310836dbd52fbc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 23 Jan 2026 10:14:41 +0000 Subject: [PATCH 18/29] Refactor release group parsing --- beetsplug/musicbrainz.py | 65 +++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index b733ef8f4..b62537524 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -48,6 +48,7 @@ if TYPE_CHECKING: ArtistRelationType, Recording, Release, + ReleaseGroup, ) VARIOUS_ARTISTS_ID = "89ad4ac3-39f7-470e-963a-56509c546377" @@ -118,6 +119,17 @@ class ArtistInfo(TypedDict): 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 + + def _preferred_alias( aliases: list[Alias], languages: list[str] | None = None ) -> Alias | None: @@ -399,6 +411,29 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): return info + @staticmethod + def _parse_release_group(release_group: ReleaseGroup) -> ReleaseGroupInfo: + albumtype = None + albumtypes = [] + if reltype := release_group["primary_type"]: + albumtype = reltype.lower() + albumtypes.append(albumtype) + + year, month, day = _get_date(release_group["first_release_date"]) + return ReleaseGroupInfo( + albumtype=albumtype, + albumtypes=[ + *albumtypes, + *(st.lower() for st in release_group["secondary_types"]), + ], + releasegroup_id=release_group["id"], + release_group_title=release_group["title"], + releasegroupdisambig=release_group["disambiguation"] or None, + original_year=year, + original_month=month, + original_day=day, + ) + 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. @@ -487,43 +522,17 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): data_source=self.data_source, data_url=urljoin(BASE_URL, f"release/{release['id']}"), barcode=release.get("barcode"), + **self._parse_release_group(release["release_group"]), ) 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 - - info.original_year, info.original_month, info.original_day = _get_date( - release["release_group"]["first_release_date"] - ) # Release events. info.country, release_date = _preferred_release_event(release) info.year, info.month, info.day = ( @@ -562,7 +571,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): if self.config["genres"]: sources = [ - release["release_group"].get(self.genres_field, []), + release["release_group"][self.genres_field], release.get(self.genres_field, []), ] genres: Counter[str] = Counter() From 9be006a79dd15ca43814924182d4c0ffdfb483e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 24 Jan 2026 22:36:56 +0000 Subject: [PATCH 19/29] Refactor parsing genre --- beetsplug/musicbrainz.py | 34 +++++++++++++++------------ test/plugins/factories/musicbrainz.py | 11 +++++++++ test/plugins/test_musicbrainz.py | 16 +++++++++---- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index b62537524..ba534e1a8 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -16,7 +16,7 @@ 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 @@ -434,6 +434,23 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): original_day=day, ) + 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 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. @@ -522,6 +539,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): data_source=self.data_source, data_url=urljoin(BASE_URL, f"release/{release['id']}"), barcode=release.get("barcode"), + genre=genre if (genre := self._parse_genre(release)) else None, **self._parse_release_group(release["release_group"]), ) info.va = info.artist_id == VARIOUS_ARTISTS_ID @@ -569,20 +587,6 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): else: info.media = "Media" - if self.config["genres"]: - sources = [ - release["release_group"][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]) - ) - # We might find links to external sources (Discogs, Bandcamp, ...) external_ids = self.config["external_ids"].get() wanted_sources: set[UrlSource] = { diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py index 36549d953..1a7cfa72b 100644 --- a/test/plugins/factories/musicbrainz.py +++ b/test/plugins/factories/musicbrainz.py @@ -115,3 +115,14 @@ class ReleaseGroupFactory(_IdFactory): 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" diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 529f2d4b3..dd1594756 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -60,6 +60,14 @@ def release_group_factory(**kwargs) -> mb.ReleaseGroup: return factories.ReleaseGroupFactory.build(**kwargs) +def genre_factory(**kwargs) -> mb.Genre: + return factories.GenreFactory.build(**kwargs) + + +def tag_factory(**kwargs) -> mb.Tag: + return factories.TagFactory.build(**kwargs) + + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() @@ -85,8 +93,8 @@ class MusicBrainzTestCase(BeetsTestCase): "artist_credit": [artist_credit_factory(artist__id_base=10)], "date": "3001", "media": [], - "genres": [{"count": 1, "name": "GENRE"}], - "tags": [{"count": 1, "name": "TAG"}], + "genres": [genre_factory()], + "tags": [tag_factory()], "label_info": [ { "catalog_number": "CATALOG NUMBER", @@ -520,14 +528,14 @@ class MBAlbumInfoTest(MusicBrainzTestCase): config["musicbrainz"]["genres_tag"] = "genre" release = self._make_release() 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() d = self.mb.album_info(release) - assert d.genre == "TAG" + assert d.genre == "Tag" def test_no_genres(self): config["musicbrainz"]["genres"] = False From 751c2c61c79a65819158c4791914991e27a81b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 25 Jan 2026 00:53:05 +0000 Subject: [PATCH 20/29] Refactor parsing label info --- beetsplug/musicbrainz.py | 27 ++++++++++++++++++--------- test/plugins/factories/musicbrainz.py | 18 ++++++++++++++++++ test/plugins/test_musicbrainz.py | 19 ++++++++++++------- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index ba534e1a8..44ec273a9 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -46,6 +46,7 @@ if TYPE_CHECKING: ArtistCredit, ArtistRelation, ArtistRelationType, + LabelInfo, Recording, Release, ReleaseGroup, @@ -130,6 +131,11 @@ class ReleaseGroupInfo(TypedDict): original_day: int | None +class LabelInfoInfo(TypedDict): + label: str | None + catalognum: str | None + + def _preferred_alias( aliases: list[Alias], languages: list[str] | None = None ) -> Alias | None: @@ -434,6 +440,17 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): 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 = [ @@ -541,6 +558,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): barcode=release.get("barcode"), genre=genre if (genre := self._parse_genre(release)) else None, **self._parse_release_group(release["release_group"]), + **self._parse_label_infos(release["label_info"]), ) info.va = info.artist_id == VARIOUS_ARTISTS_ID if info.va: @@ -563,15 +581,6 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): ) ) - # 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"] diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py index 1a7cfa72b..730f89a35 100644 --- a/test/plugins/factories/musicbrainz.py +++ b/test/plugins/factories/musicbrainz.py @@ -126,3 +126,21 @@ class GenreFactory(factory.DictFactory): 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) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index dd1594756..ce9329ded 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -68,6 +68,10 @@ def tag_factory(**kwargs) -> mb.Tag: return factories.TagFactory.build(**kwargs) +def label_info_factory(**kwargs) -> mb.LabelInfo: + return factories.LabelInfoFactory.build(**kwargs) + + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() @@ -95,12 +99,7 @@ class MusicBrainzTestCase(BeetsTestCase): "media": [], "genres": [genre_factory()], "tags": [tag_factory()], - "label_info": [ - { - "catalog_number": "CATALOG NUMBER", - "label": {"name": "LABEL NAME"}, - } - ], + "label_info": [label_info_factory()], "text_representation": { "script": "SCRIPT", "language": "LANGUAGE", @@ -371,7 +370,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_catalognum(self): release = self._make_release() d = self.mb.album_info(release) - assert d.catalognum == "CATALOG NUMBER" + assert d.catalognum == "LAB123" def test_parse_textrepr(self): release = self._make_release() @@ -813,6 +812,7 @@ class MBLibraryTest(MusicBrainzTestCase): ], "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), + "label_info": [label_info_factory()], "release_relations": [ { "type": "transl-tracklisting", @@ -845,6 +845,7 @@ class MBLibraryTest(MusicBrainzTestCase): "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), "country": "COUNTRY", + "label_info": [label_info_factory()], }, ] @@ -878,6 +879,7 @@ class MBLibraryTest(MusicBrainzTestCase): ], "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), + "label_info": [label_info_factory()], } ] @@ -911,6 +913,7 @@ class MBLibraryTest(MusicBrainzTestCase): ], "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), + "label_info": [label_info_factory()], } ] @@ -944,6 +947,7 @@ class MBLibraryTest(MusicBrainzTestCase): ], "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), + "label_info": [label_info_factory()], "release_relations": [ { "type": "remaster", @@ -1049,6 +1053,7 @@ class TestMusicBrainzPlugin(PluginMixin): ], "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), + "label_info": [label_info_factory()], }, ) candidates = list(mb.candidates([], "hello", "there", False)) From 99afdd3c040957b399c7bea2a00bfa23409875d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 25 Jan 2026 01:19:20 +0000 Subject: [PATCH 21/29] Refactor parsing external ids --- beetsplug/musicbrainz.py | 63 +++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 44ec273a9..0017d1931 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -24,6 +24,7 @@ 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 @@ -50,6 +51,7 @@ if TYPE_CHECKING: Recording, Release, ReleaseGroup, + UrlRelation, ) VARIOUS_ARTISTS_ID = "89ad4ac3-39f7-470e-963a-56509c546377" @@ -136,6 +138,15 @@ class LabelInfoInfo(TypedDict): 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[Alias], languages: list[str] | None = None ) -> Alias | None: @@ -468,6 +479,34 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): return None + def _parse_external_ids( + self, url_relations: list[UrlRelation] + ) -> ExternalIdsInfo: + """Extract configured external release ids from MusicBrainz URLs. + + MusicBrainz releases can include `url_relations` pointing to third-party + sites (for example Bandcamp or Discogs). This helper filters those URL + relations to only the sources enabled in configuration, then derives a + stable external identifier from each matching URL. + """ + external_ids = self.config["external_ids"].get() + wanted_sources: set[UrlSource] = { + site for site, wanted in external_ids.items() if wanted + } + url_by_source: dict[UrlSource, str] = {} + for source, url_relation in product(wanted_sources, url_relations): + if f"{source}.com" in (target := url_relation["url"]["resource"]): + url_by_source[source] = target + self._log.debug( + "Found link to {} release via MusicBrainz", + source.capitalize(), + ) + + return { + f"{source}_album_id": extract_release_id(source, url) + for source, url in url_by_source.items() + } # type: ignore[return-value] + def 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. @@ -559,6 +598,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): genre=genre if (genre := self._parse_genre(release)) else 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: @@ -596,29 +636,6 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): else: info.media = "Media" - # We might find links to external sources (Discogs, Bandcamp, ...) - external_ids = self.config["external_ids"].get() - wanted_sources: set[UrlSource] = { - site for site, wanted in external_ids.items() if wanted - } - if wanted_sources and (url_rels := release.get("url_relations")): - urls = {} - - for url_source, url_relation in product(wanted_sources, url_rels): - if f"{url_source}.com" in ( - target := url_relation["url"]["resource"] - ): - urls[url_source] = target - self._log.debug( - "Found link to {} release via MusicBrainz", - url_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: info.update(extra_albumdata) From 7d2dddcca51932330f2b4def528df319c4e2b14c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 25 Jan 2026 01:31:13 +0000 Subject: [PATCH 22/29] Refactor parsing language and script --- beetsplug/musicbrainz.py | 8 ++------ test/plugins/factories/musicbrainz.py | 5 +++++ test/plugins/test_musicbrainz.py | 21 ++++++++++++++------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 0017d1931..3f3ce4bea 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -596,6 +596,8 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): 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"], **self._parse_release_group(release["release_group"]), **self._parse_label_infos(release["label_info"]), **self._parse_external_ids(release.get("url_relations", [])), @@ -621,12 +623,6 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): ) ) - # 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 diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py index 730f89a35..cdda3e52f 100644 --- a/test/plugins/factories/musicbrainz.py +++ b/test/plugins/factories/musicbrainz.py @@ -144,3 +144,8 @@ class LabelInfoFactory(factory.DictFactory): lambda o: f"{o.label['name'][:3].upper()}123" ) label = factory.SubFactory(LabelFactory) + + +class TextRepresentationFactory(factory.DictFactory): + language = "eng" + script = "Latn" diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index ce9329ded..7507a60c6 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -72,6 +72,10 @@ def label_info_factory(**kwargs) -> mb.LabelInfo: return factories.LabelInfoFactory.build(**kwargs) +def text_representation_factory(**kwargs) -> mb.TextRepresentation: + return factories.TextRepresentationFactory.build(**kwargs) + + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() @@ -100,10 +104,7 @@ class MusicBrainzTestCase(BeetsTestCase): "genres": [genre_factory()], "tags": [tag_factory()], "label_info": [label_info_factory()], - "text_representation": { - "script": "SCRIPT", - "language": "LANGUAGE", - }, + "text_representation": text_representation_factory(), "country": "COUNTRY", "status": "STATUS", "barcode": "BARCODE", @@ -375,8 +376,8 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_textrepr(self): release = self._make_release() 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() @@ -421,7 +422,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_missing_language(self): release = self._make_release() - del release["text_representation"]["language"] + release["text_representation"]["language"] = None d = self.mb.album_info(release) assert d.language is None @@ -813,6 +814,7 @@ class MBLibraryTest(MusicBrainzTestCase): "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), "label_info": [label_info_factory()], + "text_representation": text_representation_factory(), "release_relations": [ { "type": "transl-tracklisting", @@ -846,6 +848,7 @@ class MBLibraryTest(MusicBrainzTestCase): "release_group": release_group_factory(), "country": "COUNTRY", "label_info": [label_info_factory()], + "text_representation": text_representation_factory(), }, ] @@ -880,6 +883,7 @@ class MBLibraryTest(MusicBrainzTestCase): "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), "label_info": [label_info_factory()], + "text_representation": text_representation_factory(), } ] @@ -914,6 +918,7 @@ class MBLibraryTest(MusicBrainzTestCase): "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), "label_info": [label_info_factory()], + "text_representation": text_representation_factory(), } ] @@ -948,6 +953,7 @@ class MBLibraryTest(MusicBrainzTestCase): "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), "label_info": [label_info_factory()], + "text_representation": text_representation_factory(), "release_relations": [ { "type": "remaster", @@ -1054,6 +1060,7 @@ class TestMusicBrainzPlugin(PluginMixin): "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), "label_info": [label_info_factory()], + "text_representation": text_representation_factory(), }, ) candidates = list(mb.candidates([], "hello", "there", False)) From 4a0544410209ec65b7ab4e731147aaafdeb419a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 25 Jan 2026 01:37:55 +0000 Subject: [PATCH 23/29] Enforce asin, disambiguation, fix status --- beetsplug/musicbrainz.py | 8 +++----- test/plugins/test_musicbrainz.py | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 3f3ce4bea..906798341 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -598,6 +598,9 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): 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", [])), @@ -605,11 +608,6 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): info.va = info.artist_id == VARIOUS_ARTISTS_ID if info.va: info.artist = config["va_name"].as_str() - info.asin = release.get("asin") - info.albumstatus = release.get("status") - - if release.get("disambiguation"): - info.albumdisambig = release.get("disambiguation") # Release events. info.country, release_date = _preferred_release_event(release) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 7507a60c6..6817d6292 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -106,7 +106,7 @@ class MusicBrainzTestCase(BeetsTestCase): "label_info": [label_info_factory()], "text_representation": text_representation_factory(), "country": "COUNTRY", - "status": "STATUS", + "status": "Official", "barcode": "BARCODE", "release_events": [ release_event_factory(area=None, date="2021-03-26"), @@ -387,7 +387,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_status(self): release = self._make_release() d = self.mb.album_info(release) - assert d.albumstatus == "STATUS" + assert d.albumstatus == "Official" def test_parse_barcode(self): release = self._make_release() @@ -796,6 +796,8 @@ class MBLibraryTest(MusicBrainzTestCase): "title": "pseudo", "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", "status": "Pseudo-Release", + "asin": None, + "disambiguation": "", "media": [ { "tracks": [ @@ -829,6 +831,8 @@ class MBLibraryTest(MusicBrainzTestCase): "title": "actual", "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01", "status": "Official", + "asin": None, + "disambiguation": "", "media": [ { "tracks": [ @@ -865,6 +869,8 @@ class MBLibraryTest(MusicBrainzTestCase): "title": "pseudo", "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", "status": "Pseudo-Release", + "asin": None, + "disambiguation": "", "media": [ { "tracks": [ @@ -900,6 +906,8 @@ class MBLibraryTest(MusicBrainzTestCase): "title": "pseudo", "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", "status": "Pseudo-Release", + "asin": None, + "disambiguation": "", "media": [ { "tracks": [ @@ -935,6 +943,8 @@ class MBLibraryTest(MusicBrainzTestCase): "title": "pseudo", "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", "status": "Pseudo-Release", + "asin": None, + "disambiguation": "", "media": [ { "tracks": [ @@ -1043,7 +1053,9 @@ class TestMusicBrainzPlugin(PluginMixin): lambda *_, **__: { "title": "hi", "id": self.mbid, - "status": "status", + "status": "Official", + "asin": None, + "disambiguation": "", "media": [ { "tracks": [ From 64d1ebe0e835174aa3ae8c8e8c574f452b8fbcba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 25 Jan 2026 23:12:55 +0000 Subject: [PATCH 24/29] Use RecordingFactory in tests --- test/plugins/factories/musicbrainz.py | 21 +++ test/plugins/test_musicbrainz.py | 250 +++++++++----------------- 2 files changed, 108 insertions(+), 163 deletions(-) diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py index cdda3e52f..4a3d11066 100644 --- a/test/plugins/factories/musicbrainz.py +++ b/test/plugins/factories/musicbrainz.py @@ -149,3 +149,24 @@ class LabelInfoFactory(factory.DictFactory): 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([]) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 6817d6292..cca552325 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -76,6 +76,10 @@ def text_representation_factory(**kwargs) -> mb.TextRepresentation: return factories.TextRepresentationFactory.build(**kwargs) +def recording_factory(**kwargs) -> mb.Recording: + return factories.RecordingFactory.build(**kwargs) + + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() @@ -175,44 +179,6 @@ class MusicBrainzTestCase(BeetsTestCase): ) return release - @staticmethod - def _make_recording( - title, - tr_id, - duration, - video=False, - disambiguation="", - remixer=False, - multi_artist_credit=False, - ) -> mb.Recording: - recording: mb.Recording = { - "title": title, - "id": tr_id, - "length": duration, - "video": video, - "disambiguation": disambiguation, - "isrcs": [], - "aliases": [], - "artist_credit": [ - artist_credit_factory(artist__name="Recording Artist") - ], - } - if multi_artist_credit: - recording["artist_credit"][0]["joinphrase"] = " & " - recording["artist_credit"].append( - artist_credit_factory( - artist__name="Other Recording Artist", - artist__index=2, - ) - ) - if remixer: - recording["artist_relations"] = [ - artist_relation_factory( - type="remixer", artist__name="Recording Remixer" - ) - ] - return recording - class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_release_with_year(self): @@ -240,26 +206,23 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_tracks(self): recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), + recording_factory(length=100000), + recording_factory(index=2, length=200000, title="Other Recording"), ] release = self._make_release(recordings=recordings) 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): - recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] + recordings = [recording_factory(), recording_factory()] release = self._make_release(recordings=recordings) d = self.mb.album_info(release) @@ -270,10 +233,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].index == 2 def test_parse_medium_numbers_single_medium(self): - recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] + recordings = [recording_factory(), recording_factory()] release = self._make_release(recordings=recordings) d = self.mb.album_info(release) @@ -283,10 +243,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].medium == 1 def test_parse_medium_numbers_two_mediums(self): - recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] + recordings = [recording_factory(), recording_factory()] release = self._make_release(recordings=[recordings[0]]) second_track_list = [ { @@ -320,13 +277,13 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.original_month == 3 def test_no_durations(self): - recordings = [self._make_recording("TITLE", "ID", None)] + recordings = [recording_factory(length=None)] release = self._make_release(recordings=recordings) d = self.mb.album_info(release) assert d.tracks[0].length is None def test_track_length_overrides_recording_length(self): - recordings = [self._make_recording("TITLE", "ID", 1.0 * 1000.0)] + recordings = [recording_factory()] release = self._make_release( recordings=recordings, track_length=2.0 * 1000.0 ) @@ -395,10 +352,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.barcode == "BARCODE" def test_parse_media(self): - recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] + recordings = [recording_factory(), recording_factory()] release = self._make_release(recordings=recordings) d = self.mb.album_info(release) assert d.media == "FORMAT" @@ -410,10 +364,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.releasegroupdisambig == "Release Group Disambiguation" def test_parse_disctitle(self): - recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] + recordings = [recording_factory(), recording_factory()] release = self._make_release(recordings=recordings) d = self.mb.album_info(release) t = d.tracks @@ -427,7 +378,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.language is None def test_parse_recording_artist(self): - recordings = [self._make_recording("a", "b", 1)] + recordings = [recording_factory()] release = self._make_release(recordings=recordings) track = self.mb.album_info(release).tracks[0] assert track.artist == "Recording Artist" @@ -437,7 +388,12 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_recording_artist_multi(self): recordings = [ - self._make_recording("a", "b", 1, multi_artist_credit=True) + recording_factory( + artist_credit__0__joinphrase=" & ", + artist_credit__1=artist_credit_factory( + artist__name="Other Recording Artist", artist__index=2 + ), + ) ] release = self._make_release(recordings=recordings) track = self.mb.album_info(release).tracks[0] @@ -470,7 +426,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ] def test_track_artist_overrides_recording_artist(self): - recordings = [self._make_recording("a", "b", 1)] + recordings = [recording_factory()] release = self._make_release(recordings=recordings, track_artist=True) track = self.mb.album_info(release).tracks[0] assert track.artist == "Track Artist" @@ -480,7 +436,12 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_track_artist_overrides_recording_artist_multi(self): recordings = [ - self._make_recording("a", "b", 1, multi_artist_credit=True) + recording_factory( + artist_credit__0__joinphrase=" & ", + artist_credit__1=artist_credit_factory( + artist__name="Other Recording Artist", artist__index=2 + ), + ) ] release = self._make_release( recordings=recordings, @@ -513,7 +474,15 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ] def test_parse_recording_remixer(self): - recordings = [self._make_recording("a", "b", 1, remixer=True)] + recordings = [ + recording_factory( + artist_relations=[ + artist_relation_factory( + type="remixer", artist__name="Recording Remixer" + ) + ] + ) + ] release = self._make_release(recordings=recordings) track = self.mb.album_info(release).tracks[0] assert track.remixer == "Recording Remixer" @@ -545,10 +514,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_ignored_media(self): config["match"]["ignored_media"] = ["IGNORED1", "IGNORED2"] - recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] + recordings = [recording_factory(), recording_factory()] release = self._make_release( recordings=recordings, medium_format="IGNORED1" ) @@ -557,10 +523,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_no_ignored_media(self): config["match"]["ignored_media"] = ["IGNORED1", "IGNORED2"] - recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] + recordings = [recording_factory(), recording_factory()] release = self._make_release( recordings=recordings, medium_format="NON-IGNORED" ) @@ -569,134 +532,109 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_skip_data_track(self): recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording( - "[data track]", "ID DATA TRACK", 100.0 * 1000.0 - ), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), + recording_factory(), + recording_factory(title="[data track]"), + recording_factory(title="Other Recording"), ] release = self._make_release(recordings=recordings) 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): recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - data_tracks = [ - self._make_recording( - "TITLE AUDIO DATA", "ID DATA TRACK", 100.0 * 1000.0 - ) + recording_factory(), + recording_factory(title="Other Recording"), ] + data_tracks = [recording_factory(title="TITLE AUDIO DATA")] release = self._make_release( recordings=recordings, data_tracks=data_tracks ) 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 recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - data_tracks = [ - self._make_recording( - "TITLE AUDIO DATA", "ID DATA TRACK", 100.0 * 1000.0 - ) + recording_factory(), + recording_factory(title="Other Recording"), ] + data_tracks = [recording_factory(title="TITLE AUDIO DATA")] release = self._make_release( recordings=recordings, data_tracks=data_tracks ) 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[0].title == "Recording" + assert d.tracks[1].title == "Other Recording" assert d.tracks[2].title == "TITLE AUDIO DATA" def test_skip_video_tracks_by_default(self): recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording( - "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, video=True - ), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), + recording_factory(), + recording_factory(title="TITLE VIDEO", video=True), + recording_factory(title="Other Recording"), ] release = self._make_release(recordings=recordings) 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): recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - data_tracks = [ - self._make_recording( - "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, True - ) + recording_factory(), + recording_factory(title="Other Recording"), ] + data_tracks = [recording_factory(title="TITLE VIDEO")] release = self._make_release( recordings=recordings, data_tracks=data_tracks ) 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 recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording( - "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, True - ), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), + recording_factory(), + recording_factory(title="TITLE VIDEO"), + recording_factory(title="Other Recording"), ] release = self._make_release(recordings=recordings) d = self.mb.album_info(release) assert len(d.tracks) == 3 - assert d.tracks[0].title == "TITLE ONE" + assert d.tracks[0].title == "Recording" assert d.tracks[1].title == "TITLE VIDEO" - assert d.tracks[2].title == "TITLE TWO" + 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 recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording("TITLE TWO", "ID TWO", 200.0 * 1000.0), - ] - data_tracks = [ - self._make_recording( - "TITLE VIDEO", "ID VIDEO", 100.0 * 1000.0, True - ) + recording_factory(), + recording_factory(title="Other Recording"), ] + data_tracks = [recording_factory(title="TITLE VIDEO")] release = self._make_release( recordings=recordings, data_tracks=data_tracks ) 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[0].title == "Recording" + assert d.tracks[1].title == "Other Recording" assert d.tracks[2].title == "TITLE VIDEO" def test_track_disambiguation(self): recordings = [ - self._make_recording("TITLE ONE", "ID ONE", 100.0 * 1000.0), - self._make_recording( - "TITLE TWO", - "ID TWO", - 200.0 * 1000.0, - disambiguation="SECOND TRACK", + recording_factory(), + recording_factory( + title="Other Recording", disambiguation="SECOND TRACK" ), ] release = self._make_release(recordings=recordings) @@ -726,9 +664,7 @@ class ArtistTest(unittest.TestCase): def test_two_artists(self): credit = [ artist_credit_factory(artist__name="Artist", joinphrase=" AND "), - artist_credit_factory( - artist__name="Other Artist", artist__id_suffix="1" - ), + artist_credit_factory(artist__name="Other Artist", artist__index=2), ] assert MusicBrainzPlugin._parse_artist_credits(credit) == { @@ -803,9 +739,7 @@ class MBLibraryTest(MusicBrainzTestCase): "tracks": [ { "id": "baz", - "recording": self._make_recording( - "translated title", "bar", 42 - ), + "recording": recording_factory(), "position": 9, "number": "A1", } @@ -838,9 +772,7 @@ class MBLibraryTest(MusicBrainzTestCase): "tracks": [ { "id": "baz", - "recording": self._make_recording( - "original title", "bar", 42 - ), + "recording": recording_factory(), "position": 9, "number": "A1", } @@ -876,9 +808,7 @@ class MBLibraryTest(MusicBrainzTestCase): "tracks": [ { "id": "baz", - "recording": self._make_recording( - "translated title", "bar", 42 - ), + "recording": recording_factory(), "position": 9, "number": "A1", } @@ -913,9 +843,7 @@ class MBLibraryTest(MusicBrainzTestCase): "tracks": [ { "id": "baz", - "recording": self._make_recording( - "translated title", "bar", 42 - ), + "recording": recording_factory(), "position": 9, "number": "A1", } @@ -950,9 +878,7 @@ class MBLibraryTest(MusicBrainzTestCase): "tracks": [ { "id": "baz", - "recording": self._make_recording( - "translated title", "bar", 42 - ), + "recording": recording_factory(), "position": 9, "number": "A1", } @@ -988,9 +914,7 @@ class TestMusicBrainzPlugin(PluginMixin): plugin = "musicbrainz" mbid = "d2a6f856-b553-40a0-ac54-a321e8e2da99" - RECORDING: ClassVar[mb.Recording] = MusicBrainzTestCase._make_recording( - "foo", "00000000-0000-0000-0000-000000000000", 42 - ) + RECORDING: ClassVar[mb.Recording] = recording_factory() @pytest.fixture def plugin_config(self): From 9a8d30bc513e3980b04085112f1a07a25e7b6a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 27 Jan 2026 06:16:50 +0000 Subject: [PATCH 25/29] Refactor parsing medium track --- beetsplug/musicbrainz.py | 82 ++++++++--------- test/plugins/factories/musicbrainz.py | 14 +++ test/plugins/test_musicbrainz.py | 124 +++++++------------------- 3 files changed, 83 insertions(+), 137 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 906798341..9e10b779f 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -348,14 +348,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): "artists_credit": artists_credit, } - def track_info( - self, - recording: Recording, - index: int | None = None, - medium: int | None = None, - medium_index: int | None = None, - medium_total: int | None = None, - ) -> beets.autotag.hooks.TrackInfo: + 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 @@ -368,10 +361,6 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): 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=urljoin(BASE_URL, f"recording/{recording['id']}"), length=( @@ -507,6 +496,10 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): for source, url in url_by_source.items() } # type: ignore[return-value] + @cached_property + def ignore_video_tracks(self) -> bool: + return config["match"]["ignore_video_tracks"].get(bool) + 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. @@ -526,17 +519,17 @@ 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") if format in config["match"]["ignored_media"].as_str_seq(): @@ -548,41 +541,38 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): and not config["match"]["ignore_data_tracks"] ): all_tracks += medium["data_tracks"] - track_count = len(all_tracks) + + medium_data = { + "medium": int(medium["position"]), + "medium_total": len(all_tracks), + "disctitle": medium.get("title"), + "media": format, + } if "pregap" in medium: all_tracks.insert(0, medium["pregap"]) - for track in all_tracks: - if track["recording"]["title"] == "[data track]" or ( - track["recording"]["video"] - and config["match"]["ignore_video_tracks"] - ): - continue - - # Basic information from the recording. + 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: 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"] - + recording = track["recording"] # Prefer track data, where present, over recording data. - if track.get("title"): - ti.title = track["title"] - if track.get("artist_credit"): - ti.update( - **self._parse_artist_credits(track["artist_credit"]) - ) - if track.get("length"): - ti.length = int(track["length"]) / (1000.0) + for key in ("title", "artist_credit", "length"): + recording[key] = track[key] or recording[key] + + ti = self.track_info(recording) + ti.update( + index=index, + medium_index=track["position"], + release_track_id=track["id"], + track_alt=track["number"], + **medium_data, + ) track_infos.append(ti) diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py index 4a3d11066..e8a9c0387 100644 --- a/test/plugins/factories/musicbrainz.py +++ b/test/plugins/factories/musicbrainz.py @@ -170,3 +170,17 @@ class RecordingFactory(_IdFactory): 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 + ) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index cca552325..b383a0c3d 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -80,6 +80,10 @@ def recording_factory(**kwargs) -> mb.Recording: return factories.RecordingFactory.build(**kwargs) +def track_factory(**kwargs) -> mb.Track: + return factories.TrackFactory.build(**kwargs) + + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() @@ -129,17 +133,10 @@ class MusicBrainzTestCase(BeetsTestCase): i = 0 track_list = [] if recordings: - for recording in recordings: - 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 + for i, recording in enumerate(recordings, 1): + track = track_factory( + recording=recording, position=i, length=track_length + ) if track_artist: # Similarly, track artists can differ from recording # artists. @@ -159,15 +156,11 @@ class MusicBrainzTestCase(BeetsTestCase): 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) + for i, recording in enumerate(data_tracks, 1): + data_track_list.append( + track_factory(recording=recording, position=i) + ) + release["media"].append( { "position": "1", @@ -243,20 +236,13 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].medium == 1 def test_parse_medium_numbers_two_mediums(self): - recordings = [recording_factory(), recording_factory()] - release = self._make_release(recordings=[recordings[0]]) - second_track_list = [ - { - "id": "RELEASE TRACK ID 2", - "recording": recordings[1], - "position": "1", - "number": "A1", - } - ] + release = self._make_release(recordings=[recording_factory()]) release["media"].append( { "position": "2", - "tracks": second_track_list, + "tracks": [ + track_factory(recording__index=2, title="Other Recording") + ], } ) @@ -284,9 +270,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_track_length_overrides_recording_length(self): recordings = [recording_factory()] - release = self._make_release( - recordings=recordings, track_length=2.0 * 1000.0 - ) + release = self._make_release(recordings=recordings, track_length=2000.0) d = self.mb.album_info(release) assert d.tracks[0].length == 2.0 @@ -547,7 +531,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): recording_factory(), recording_factory(title="Other Recording"), ] - data_tracks = [recording_factory(title="TITLE AUDIO DATA")] + data_tracks = [recording_factory(title="Audio Data Recording")] release = self._make_release( recordings=recordings, data_tracks=data_tracks ) @@ -562,7 +546,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): recording_factory(), recording_factory(title="Other Recording"), ] - data_tracks = [recording_factory(title="TITLE AUDIO DATA")] + data_tracks = [recording_factory(title="Audio Data Recording")] release = self._make_release( recordings=recordings, data_tracks=data_tracks ) @@ -570,12 +554,12 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert len(d.tracks) == 3 assert d.tracks[0].title == "Recording" assert d.tracks[1].title == "Other Recording" - assert d.tracks[2].title == "TITLE AUDIO DATA" + assert d.tracks[2].title == "Audio Data Recording" def test_skip_video_tracks_by_default(self): recordings = [ recording_factory(), - recording_factory(title="TITLE VIDEO", video=True), + recording_factory(video=True), recording_factory(title="Other Recording"), ] release = self._make_release(recordings=recordings) @@ -589,7 +573,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): recording_factory(), recording_factory(title="Other Recording"), ] - data_tracks = [recording_factory(title="TITLE VIDEO")] + data_tracks = [recording_factory(video=True)] release = self._make_release( recordings=recordings, data_tracks=data_tracks ) @@ -603,14 +587,14 @@ class MBAlbumInfoTest(MusicBrainzTestCase): config["match"]["ignore_video_tracks"] = False recordings = [ recording_factory(), - recording_factory(title="TITLE VIDEO"), + recording_factory(video=True), recording_factory(title="Other Recording"), ] release = self._make_release(recordings=recordings) d = self.mb.album_info(release) assert len(d.tracks) == 3 assert d.tracks[0].title == "Recording" - assert d.tracks[1].title == "TITLE VIDEO" + assert d.tracks[1].title == "Video: Recording" assert d.tracks[2].title == "Other Recording" def test_no_skip_video_data_tracks_if_configured(self): @@ -620,7 +604,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): recording_factory(), recording_factory(title="Other Recording"), ] - data_tracks = [recording_factory(title="TITLE VIDEO")] + data_tracks = [recording_factory(video=True)] release = self._make_release( recordings=recordings, data_tracks=data_tracks ) @@ -628,7 +612,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert len(d.tracks) == 3 assert d.tracks[0].title == "Recording" assert d.tracks[1].title == "Other Recording" - assert d.tracks[2].title == "TITLE VIDEO" + assert d.tracks[2].title == "Video: Recording" def test_track_disambiguation(self): recordings = [ @@ -736,14 +720,7 @@ class MBLibraryTest(MusicBrainzTestCase): "disambiguation": "", "media": [ { - "tracks": [ - { - "id": "baz", - "recording": recording_factory(), - "position": 9, - "number": "A1", - } - ], + "tracks": [track_factory()], "position": 5, } ], @@ -769,14 +746,7 @@ class MBLibraryTest(MusicBrainzTestCase): "disambiguation": "", "media": [ { - "tracks": [ - { - "id": "baz", - "recording": recording_factory(), - "position": 9, - "number": "A1", - } - ], + "tracks": [track_factory()], "position": 5, } ], @@ -805,14 +775,7 @@ class MBLibraryTest(MusicBrainzTestCase): "disambiguation": "", "media": [ { - "tracks": [ - { - "id": "baz", - "recording": recording_factory(), - "position": 9, - "number": "A1", - } - ], + "tracks": [track_factory()], "position": 5, } ], @@ -840,14 +803,7 @@ class MBLibraryTest(MusicBrainzTestCase): "disambiguation": "", "media": [ { - "tracks": [ - { - "id": "baz", - "recording": recording_factory(), - "position": 9, - "number": "A1", - } - ], + "tracks": [track_factory()], "position": 5, } ], @@ -875,14 +831,7 @@ class MBLibraryTest(MusicBrainzTestCase): "disambiguation": "", "media": [ { - "tracks": [ - { - "id": "baz", - "recording": recording_factory(), - "position": 9, - "number": "A1", - } - ], + "tracks": [track_factory()], "position": 5, } ], @@ -982,14 +931,7 @@ class TestMusicBrainzPlugin(PluginMixin): "disambiguation": "", "media": [ { - "tracks": [ - { - "id": "baz", - "recording": self.RECORDING, - "position": 9, - "number": "A1", - } - ], + "tracks": [track_factory()], "position": 5, } ], From dfceedc372a49676663275a62b8a31293122caab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 27 Jan 2026 08:00:35 +0000 Subject: [PATCH 26/29] Refactor medium parsing --- beetsplug/musicbrainz.py | 118 ++++++++++++++------------ test/plugins/factories/musicbrainz.py | 14 +++ test/plugins/test_musicbrainz.py | 70 +++++---------- 3 files changed, 97 insertions(+), 105 deletions(-) diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 9e10b779f..adfe34c45 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -48,6 +48,7 @@ if TYPE_CHECKING: ArtistRelation, ArtistRelationType, LabelInfo, + Medium, Recording, Release, ReleaseGroup, @@ -496,10 +497,58 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): 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. @@ -527,60 +576,26 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): ] # Basic info. - track_infos = [] - index = 0 - for medium in release["media"]: - 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 - - all_tracks = medium["tracks"] - if ( - "data_tracks" in medium - and not config["match"]["ignore_data_tracks"] - ): - all_tracks += medium["data_tracks"] - - medium_data = { - "medium": int(medium["position"]), - "medium_total": len(all_tracks), - "disctitle": medium.get("title"), - "media": format, - } - - if "pregap" in medium: - all_tracks.insert(0, medium["pregap"]) - - 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: - index += 1 - 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( - index=index, - medium_index=track["position"], - release_track_id=track["id"], - track_alt=track["number"], - **medium_data, - ) - - track_infos.append(ti) + for index, track_info in enumerate(track_infos, 1): + track_info.index = index info = beets.autotag.hooks.AlbumInfo( **self._parse_artist_credits(release["artist_credit"]), album=release["title"], album_id=release["id"], tracks=track_infos, + media=( + medias.pop() + if len(medias := {t.media for t in track_infos}) == 1 + else "Media" + ), mediums=len(release["media"]), data_source=self.data_source, data_url=urljoin(BASE_URL, f"release/{release['id']}"), @@ -611,15 +626,6 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin): ) ) - # 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" - extra_albumdatas = plugins.send("mb_album_extract", data=release) for extra_albumdata in extra_albumdatas: info.update(extra_albumdata) diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py index e8a9c0387..974f12daa 100644 --- a/test/plugins/factories/musicbrainz.py +++ b/test/plugins/factories/musicbrainz.py @@ -184,3 +184,17 @@ class TrackFactory(_IdFactory): 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 + tracks = factory.List([factory.SubFactory(TrackFactory)]) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index b383a0c3d..a754f25b5 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -84,6 +84,10 @@ def track_factory(**kwargs) -> mb.Track: return factories.TrackFactory.build(**kwargs) +def medium_factory(**kwargs) -> mb.Medium: + return factories.MediumFactory.build(**kwargs) + + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() @@ -162,13 +166,11 @@ class MusicBrainzTestCase(BeetsTestCase): ) release["media"].append( - { - "position": "1", - "tracks": track_list, - "data_tracks": data_track_list, - "format": medium_format, - "title": "MEDIUM TITLE", - } + medium_factory( + format=medium_format, + tracks=track_list, + data_tracks=data_track_list, + ) ) return release @@ -238,12 +240,12 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_medium_numbers_two_mediums(self): release = self._make_release(recordings=[recording_factory()]) release["media"].append( - { - "position": "2", - "tracks": [ + medium_factory( + position=2, + tracks=[ track_factory(recording__index=2, title="Other Recording") ], - } + ) ) d = self.mb.album_info(release) @@ -352,8 +354,8 @@ class MBAlbumInfoTest(MusicBrainzTestCase): release = self._make_release(recordings=recordings) 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() @@ -718,12 +720,7 @@ class MBLibraryTest(MusicBrainzTestCase): "status": "Pseudo-Release", "asin": None, "disambiguation": "", - "media": [ - { - "tracks": [track_factory()], - "position": 5, - } - ], + "media": [medium_factory()], "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), "label_info": [label_info_factory()], @@ -744,12 +741,7 @@ class MBLibraryTest(MusicBrainzTestCase): "status": "Official", "asin": None, "disambiguation": "", - "media": [ - { - "tracks": [track_factory()], - "position": 5, - } - ], + "media": [medium_factory()], "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), "country": "COUNTRY", @@ -773,12 +765,7 @@ class MBLibraryTest(MusicBrainzTestCase): "status": "Pseudo-Release", "asin": None, "disambiguation": "", - "media": [ - { - "tracks": [track_factory()], - "position": 5, - } - ], + "media": [medium_factory()], "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), "label_info": [label_info_factory()], @@ -801,12 +788,7 @@ class MBLibraryTest(MusicBrainzTestCase): "status": "Pseudo-Release", "asin": None, "disambiguation": "", - "media": [ - { - "tracks": [track_factory()], - "position": 5, - } - ], + "media": [medium_factory()], "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), "label_info": [label_info_factory()], @@ -829,12 +811,7 @@ class MBLibraryTest(MusicBrainzTestCase): "status": "Pseudo-Release", "asin": None, "disambiguation": "", - "media": [ - { - "tracks": [track_factory()], - "position": 5, - } - ], + "media": [medium_factory()], "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), "label_info": [label_info_factory()], @@ -929,12 +906,7 @@ class TestMusicBrainzPlugin(PluginMixin): "status": "Official", "asin": None, "disambiguation": "", - "media": [ - { - "tracks": [track_factory()], - "position": 5, - } - ], + "media": [medium_factory()], "artist_credit": [artist_credit_factory()], "release_group": release_group_factory(), "label_info": [label_info_factory()], From 00a9e4a19d085f2f53327307447b6370333e3f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 27 Jan 2026 08:34:23 +0000 Subject: [PATCH 27/29] Introduce ReleaseFactory --- test/plugins/factories/musicbrainz.py | 49 ++++++++ test/plugins/test_musicbrainz.py | 169 +++++++++----------------- 2 files changed, 105 insertions(+), 113 deletions(-) diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py index 974f12daa..71e921f6b 100644 --- a/test/plugins/factories/musicbrainz.py +++ b/test/plugins/factories/musicbrainz.py @@ -198,3 +198,52 @@ class MediumFactory(_IdFactory): data_tracks = factory.List([]) track_offset: int | None = None tracks = factory.List([factory.SubFactory(TrackFactory)]) + + +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" + country = "XW" + 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([]) + 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_musicbrainz.py b/test/plugins/test_musicbrainz.py index a754f25b5..e370a1a40 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -88,6 +88,10 @@ def medium_factory(**kwargs) -> mb.Medium: return factories.MediumFactory.build(**kwargs) +def release_factory(**kwargs) -> mb.Release: + return factories.ReleaseFactory.build(**kwargs) + + class MusicBrainzTestCase(BeetsTestCase): def setUp(self): super().setUp() @@ -102,31 +106,11 @@ class MusicBrainzTestCase(BeetsTestCase): 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": release_group_factory(first_release_date=date), - "artist_credit": [artist_credit_factory(artist__id_base=10)], - "date": "3001", - "media": [], - "genres": [genre_factory()], - "tags": [tag_factory()], - "label_info": [label_info_factory()], - "text_representation": text_representation_factory(), - "country": "COUNTRY", - "status": "Official", - "barcode": "BARCODE", - "release_events": [ - release_event_factory(area=None, date="2021-03-26"), - release_event_factory( - area__iso_3166_1_codes=["US"], date="2020-01-01" - ), - ], - } + medium_format="Digital Media", + ) -> mb.Release: + release: mb.Release = release_factory( + release_group__first_release_date=date + ) if multi_artist_credit: release["artist_credit"][0]["joinphrase"] = " & " @@ -179,8 +163,8 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_release_with_year(self): release = self._make_release(date="1984") d = self.mb.album_info(release) - assert d.album == "ALBUM TITLE" - assert d.album_id == "ALBUM 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 @@ -188,7 +172,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.artist_credit == "Artist Credit" def test_parse_release_type(self): - release = self._make_release(date="1984") + release = self._make_release() d = self.mb.album_info(release) assert d.albumtype == "album" @@ -309,7 +293,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_asin(self): release = self._make_release() 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() @@ -335,18 +319,18 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_barcode(self): release = self._make_release() d = self.mb.album_info(release) - assert d.barcode == "BARCODE" + assert d.barcode == "0000000000000" def test_parse_media(self): recordings = [recording_factory(), recording_factory()] release = self._make_release(recordings=recordings) d = self.mb.album_info(release) - assert d.media == "FORMAT" + assert d.media == "Digital Media" def test_parse_disambig(self): release = self._make_release() d = self.mb.album_info(release) - assert d.albumdisambig == "R_DISAMBIGUATION" + assert d.albumdisambig == "Album Disambiguation" assert d.releasegroupdisambig == "Release Group Disambiguation" def test_parse_disctitle(self): @@ -713,19 +697,14 @@ class ArtistTest(unittest.TestCase): class MBLibraryTest(MusicBrainzTestCase): def test_follow_pseudo_releases(self): - side_effect = [ - { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "asin": None, - "disambiguation": "", - "media": [medium_factory()], - "artist_credit": [artist_credit_factory()], - "release_group": release_group_factory(), - "label_info": [label_info_factory()], - "text_representation": text_representation_factory(), - "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", @@ -734,20 +713,11 @@ class MBLibraryTest(MusicBrainzTestCase): }, } ], - }, - { - "title": "actual", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da01", - "status": "Official", - "asin": None, - "disambiguation": "", - "media": [medium_factory()], - "artist_credit": [artist_credit_factory()], - "release_group": release_group_factory(), - "country": "COUNTRY", - "label_info": [label_info_factory()], - "text_representation": text_representation_factory(), - }, + ), + release_factory( + title="actual", + id="d2a6f856-b553-40a0-ac54-a321e8e2da01", + ), ] with mock.patch( @@ -755,22 +725,16 @@ 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.country == "US" def test_pseudo_releases_with_empty_links(self): - side_effect = [ - { - "title": "pseudo", - "id": "d2a6f856-b553-40a0-ac54-a321e8e2da02", - "status": "Pseudo-Release", - "asin": None, - "disambiguation": "", - "media": [medium_factory()], - "artist_credit": [artist_credit_factory()], - "release_group": release_group_factory(), - "label_info": [label_info_factory()], - "text_representation": text_representation_factory(), - } + side_effect: list[mb.Release] = [ + release_factory( + id="d2a6f856-b553-40a0-ac54-a321e8e2da02", + title="pseudo", + status="Pseudo-Release", + release_events=[], + ) ] with mock.patch( @@ -781,19 +745,13 @@ class MBLibraryTest(MusicBrainzTestCase): 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", - "asin": None, - "disambiguation": "", - "media": [medium_factory()], - "artist_credit": [artist_credit_factory()], - "release_group": release_group_factory(), - "label_info": [label_info_factory()], - "text_representation": text_representation_factory(), - } + side_effect: list[mb.Release] = [ + release_factory( + id="d2a6f856-b553-40a0-ac54-a321e8e2da02", + title="pseudo", + status="Pseudo-Release", + release_events=[], + ) ] with mock.patch( @@ -804,19 +762,13 @@ class MBLibraryTest(MusicBrainzTestCase): 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", - "asin": None, - "disambiguation": "", - "media": [medium_factory()], - "artist_credit": [artist_credit_factory()], - "release_group": release_group_factory(), - "label_info": [label_info_factory()], - "text_representation": text_representation_factory(), - "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", @@ -825,7 +777,7 @@ class MBLibraryTest(MusicBrainzTestCase): }, } ], - } + ) ] with mock.patch( @@ -900,24 +852,15 @@ class TestMusicBrainzPlugin(PluginMixin): ) monkeypatch.setattr( "beetsplug._utils.musicbrainz.MusicBrainzAPI.get_release", - lambda *_, **__: { - "title": "hi", - "id": self.mbid, - "status": "Official", - "asin": None, - "disambiguation": "", - "media": [medium_factory()], - "artist_credit": [artist_credit_factory()], - "release_group": release_group_factory(), - "label_info": [label_info_factory()], - "text_representation": text_representation_factory(), - }, + 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() From 8f53a2e86a831a9e8223e0475ca4fecc3f545a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 31 Jan 2026 18:07:03 +0000 Subject: [PATCH 28/29] Make use of ReleaseFactory --- test/plugins/factories/musicbrainz.py | 17 +- test/plugins/test_musicbrainz.py | 399 +++++++++++--------------- 2 files changed, 188 insertions(+), 228 deletions(-) diff --git a/test/plugins/factories/musicbrainz.py b/test/plugins/factories/musicbrainz.py index 71e921f6b..19368586e 100644 --- a/test/plugins/factories/musicbrainz.py +++ b/test/plugins/factories/musicbrainz.py @@ -197,7 +197,19 @@ class MediumFactory(_IdFactory): track_count = 1 data_tracks = factory.List([]) track_offset: int | None = None - tracks = factory.List([factory.SubFactory(TrackFactory)]) + + @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): @@ -210,7 +222,6 @@ class ReleaseFactory(_IdFactory): ) asin = factory.LazyAttribute(lambda o: f"{o.title} Asin") barcode = "0000000000000" - country = "XW" cover_art_archive = factory.Dict( { "artwork": False, @@ -225,7 +236,7 @@ class ReleaseFactory(_IdFactory): ) genres = factory.List([factory.SubFactory(GenreFactory)]) label_info = factory.List([factory.SubFactory(LabelInfoFactory)]) - media = factory.List([]) + media = factory.List([factory.SubFactory(MediumFactory)]) packaging: str | None = None packaging_id: str | None = None quality = "normal" diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index e370a1a40..57036cf5c 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -40,10 +40,6 @@ def alias_factory(**kwargs) -> mb.Alias: return factories.AliasFactory.build(**kwargs) -def artist_factory(**kwargs) -> mb.Artist: - return factories.ArtistFactory.build(**kwargs) - - def artist_credit_factory(**kwargs) -> mb.ArtistCredit: return factories.ArtistCreditFactory.build(**kwargs) @@ -52,30 +48,10 @@ def artist_relation_factory(**kwargs) -> mb.ArtistRelation: return factories.ArtistRelationFactory.build(**kwargs) -def release_event_factory(**kwargs) -> mb.ReleaseEvent: - return factories.ReleaseEventFactory.build(**kwargs) - - def release_group_factory(**kwargs) -> mb.ReleaseGroup: return factories.ReleaseGroupFactory.build(**kwargs) -def genre_factory(**kwargs) -> mb.Genre: - return factories.GenreFactory.build(**kwargs) - - -def tag_factory(**kwargs) -> mb.Tag: - return factories.TagFactory.build(**kwargs) - - -def label_info_factory(**kwargs) -> mb.LabelInfo: - return factories.LabelInfoFactory.build(**kwargs) - - -def text_representation_factory(**kwargs) -> mb.TextRepresentation: - return factories.TextRepresentationFactory.build(**kwargs) - - def recording_factory(**kwargs) -> mb.Recording: return factories.RecordingFactory.build(**kwargs) @@ -85,11 +61,11 @@ def track_factory(**kwargs) -> mb.Track: def medium_factory(**kwargs) -> mb.Medium: - return factories.MediumFactory.build(**kwargs) + return factories.MediumFactory(**kwargs) # type: ignore[return-value] def release_factory(**kwargs) -> mb.Release: - return factories.ReleaseFactory.build(**kwargs) + return factories.ReleaseFactory(**kwargs) # type: ignore[return-value] class MusicBrainzTestCase(BeetsTestCase): @@ -98,70 +74,10 @@ class MusicBrainzTestCase(BeetsTestCase): self.mb = musicbrainz.MusicBrainzPlugin() self.config["match"]["preferred"]["countries"] = ["US"] - @staticmethod - def _make_release( - date="2009", - recordings=None, - track_length=None, - track_artist=False, - multi_artist_credit=False, - data_tracks=None, - medium_format="Digital Media", - ) -> mb.Release: - release: mb.Release = release_factory( - release_group__first_release_date=date - ) - - if multi_artist_credit: - release["artist_credit"][0]["joinphrase"] = " & " - release["artist_credit"].append( - artist_credit_factory(artist__name="Other Artist") - ) - - i = 0 - track_list = [] - if recordings: - for i, recording in enumerate(recordings, 1): - track = track_factory( - recording=recording, position=i, length=track_length - ) - if track_artist: - # Similarly, track artists can differ from recording - # artists. - track["artist_credit"] = [ - artist_credit_factory(artist__name="Track Artist") - ] - - if multi_artist_credit: - track["artist_credit"][0]["joinphrase"] = " & " - track["artist_credit"].append( - artist_credit_factory( - artist__name="Other Track Artist", - artist__index=2, - ) - ) - - track_list.append(track) - data_track_list = [] - if data_tracks: - for i, recording in enumerate(data_tracks, 1): - data_track_list.append( - track_factory(recording=recording, position=i) - ) - - release["media"].append( - medium_factory( - format=medium_format, - tracks=track_list, - data_tracks=data_track_list, - ) - ) - return release - class MBAlbumInfoTest(MusicBrainzTestCase): def test_parse_release_with_year(self): - release = self._make_release(date="1984") + release = release_factory(release_group__first_release_date="1984") d = self.mb.album_info(release) assert d.album == "Album" assert d.album_id == "00000000-0000-0000-0000-000001000001" @@ -172,23 +88,30 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.artist_credit == "Artist Credit" def test_parse_release_type(self): - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) assert d.albumtype == "album" def test_parse_release_full_date(self): - release = self._make_release(date="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): - recordings = [ - recording_factory(length=100000), - recording_factory(index=2, length=200000, title="Other Recording"), - ] - release = self._make_release(recordings=recordings) + 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 @@ -201,8 +124,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].length == 200.0 def test_parse_track_indices(self): - recordings = [recording_factory(), recording_factory()] - release = self._make_release(recordings=recordings) + release = release_factory(media__0__tracks__count=2) d = self.mb.album_info(release) t = d.tracks @@ -212,8 +134,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].index == 2 def test_parse_medium_numbers_single_medium(self): - recordings = [recording_factory(), recording_factory()] - release = self._make_release(recordings=recordings) + release = release_factory(media__0__tracks__count=2) d = self.mb.album_info(release) assert d.mediums == 1 @@ -222,14 +143,8 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].medium == 1 def test_parse_medium_numbers_two_mediums(self): - release = self._make_release(recordings=[recording_factory()]) - release["media"].append( - medium_factory( - position=2, - tracks=[ - track_factory(recording__index=2, title="Other Recording") - ], - ) + release = release_factory( + media=[medium_factory(), medium_factory(position=2)] ) d = self.mb.album_info(release) @@ -243,37 +158,39 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert t[1].index == 2 def test_parse_release_year_month_only(self): - release = self._make_release(date="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): - recordings = [recording_factory(length=None)] - release = self._make_release(recordings=recordings) + 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): - recordings = [recording_factory()] - release = self._make_release(recordings=recordings, track_length=2000.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(date="") + 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() + release = release_factory() d = self.mb.album_info(release) assert not d.va def test_detect_various_artists(self): - release = self._make_release() + release = release_factory() release["artist_credit"][0]["artist"]["id"] = ( musicbrainz.VARIOUS_ARTISTS_ID ) @@ -281,75 +198,72 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.va def test_parse_artist_sort_name(self): - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) assert d.artist_sort == "Artist, The" def test_parse_releasegroupid(self): - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) assert d.releasegroup_id == "00000000-0000-0000-0000-000000000101" def test_parse_asin(self): - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) assert d.asin == "Album Asin" def test_parse_catalognum(self): - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) assert d.catalognum == "LAB123" def test_parse_textrepr(self): - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) assert d.script == "Latn" assert d.language == "eng" def test_parse_country(self): - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) assert d.country == "US" def test_parse_status(self): - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) assert d.albumstatus == "Official" def test_parse_barcode(self): - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) assert d.barcode == "0000000000000" def test_parse_media(self): - recordings = [recording_factory(), recording_factory()] - release = self._make_release(recordings=recordings) + release = release_factory() d = self.mb.album_info(release) assert d.media == "Digital Media" def test_parse_disambig(self): - release = self._make_release() + release = release_factory() d = self.mb.album_info(release) assert d.albumdisambig == "Album Disambiguation" assert d.releasegroupdisambig == "Release Group Disambiguation" def test_parse_disctitle(self): - recordings = [recording_factory(), recording_factory()] - release = self._make_release(recordings=recordings) + release = release_factory(media__0__tracks__count=2) d = self.mb.album_info(release) t = d.tracks assert t[0].disctitle == "Medium" assert t[1].disctitle == "Medium" def test_missing_language(self): - release = self._make_release() + 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): - recordings = [recording_factory()] - release = self._make_release(recordings=recordings) + release = release_factory() track = self.mb.album_info(release).tracks[0] assert track.artist == "Recording Artist" assert track.artist_id == "00000000-0000-0000-0000-000000000001" @@ -357,15 +271,22 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert track.artist_credit == "Recording Artist Credit" def test_parse_recording_artist_multi(self): - recordings = [ - recording_factory( - artist_credit__0__joinphrase=" & ", - artist_credit__1=artist_credit_factory( - artist__name="Other Recording Artist", artist__index=2 - ), - ) - ] - release = self._make_release(recordings=recordings) + 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 & Other Recording Artist" assert track.artist_id == "00000000-0000-0000-0000-000000000001" @@ -396,8 +317,15 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ] def test_track_artist_overrides_recording_artist(self): - recordings = [recording_factory()] - release = self._make_release(recordings=recordings, 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" assert track.artist_id == "00000000-0000-0000-0000-000000000001" @@ -405,18 +333,31 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert track.artist_credit == "Track Artist Credit" def test_track_artist_overrides_recording_artist_multi(self): - recordings = [ - recording_factory( - artist_credit__0__joinphrase=" & ", - artist_credit__1=artist_credit_factory( - artist__name="Other Recording Artist", artist__index=2 + 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, + ), + ], ), - ) - ] - release = self._make_release( - recordings=recordings, - track_artist=True, - multi_artist_credit=True, + ] ) track = self.mb.album_info(release).tracks[0] assert track.artist == "Track Artist & Other Track Artist" @@ -444,82 +385,83 @@ class MBAlbumInfoTest(MusicBrainzTestCase): ] def test_parse_recording_remixer(self): - recordings = [ - recording_factory( - artist_relations=[ - artist_relation_factory( - type="remixer", artist__name="Recording Remixer" - ) - ] - ) - ] - release = self._make_release(recordings=recordings) + 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" 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" 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" 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"] - recordings = [recording_factory(), recording_factory()] - release = self._make_release( - recordings=recordings, 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"] - recordings = [recording_factory(), recording_factory()] - release = self._make_release( - recordings=recordings, 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): - recordings = [ - recording_factory(), - recording_factory(title="[data track]"), - recording_factory(title="Other Recording"), - ] - release = self._make_release(recordings=recordings) + 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 == "Recording" assert d.tracks[1].title == "Other Recording" def test_skip_audio_data_tracks_by_default(self): - recordings = [ - recording_factory(), - recording_factory(title="Other Recording"), - ] - data_tracks = [recording_factory(title="Audio Data Recording")] - release = self._make_release( - recordings=recordings, 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 @@ -528,13 +470,14 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_no_skip_audio_data_tracks_if_configured(self): config["match"]["ignore_data_tracks"] = False - recordings = [ - recording_factory(), - recording_factory(title="Other Recording"), - ] - data_tracks = [recording_factory(title="Audio Data Recording")] - release = self._make_release( - recordings=recordings, 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 @@ -543,25 +486,27 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.tracks[2].title == "Audio Data Recording" def test_skip_video_tracks_by_default(self): - recordings = [ - recording_factory(), - recording_factory(video=True), - recording_factory(title="Other Recording"), - ] - release = self._make_release(recordings=recordings) + 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 == "Recording" assert d.tracks[1].title == "Other Recording" def test_skip_video_data_tracks_by_default(self): - recordings = [ - recording_factory(), - recording_factory(title="Other Recording"), - ] - data_tracks = [recording_factory(video=True)] - release = self._make_release( - recordings=recordings, 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 @@ -571,12 +516,13 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_no_skip_video_tracks_if_configured(self): config["match"]["ignore_data_tracks"] = False config["match"]["ignore_video_tracks"] = False - recordings = [ - recording_factory(), - recording_factory(video=True), - recording_factory(title="Other Recording"), - ] - release = self._make_release(recordings=recordings) + 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 == "Recording" @@ -586,13 +532,14 @@ class MBAlbumInfoTest(MusicBrainzTestCase): def test_no_skip_video_data_tracks_if_configured(self): config["match"]["ignore_data_tracks"] = False config["match"]["ignore_video_tracks"] = False - recordings = [ - recording_factory(), - recording_factory(title="Other Recording"), - ] - data_tracks = [recording_factory(video=True)] - release = self._make_release( - recordings=recordings, 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 @@ -601,13 +548,15 @@ class MBAlbumInfoTest(MusicBrainzTestCase): assert d.tracks[2].title == "Video: Recording" def test_track_disambiguation(self): - recordings = [ - recording_factory(), - recording_factory( - title="Other Recording", disambiguation="SECOND TRACK" - ), - ] - release = self._make_release(recordings=recordings) + 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 From db30e6b3b93b3ac49962cf6292dc766334f635e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sat, 31 Jan 2026 18:09:59 +0000 Subject: [PATCH 29/29] Fix typing issues in tests --- test/plugins/test_musicbrainz.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index 57036cf5c..d1f49ef37 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -674,6 +674,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 == "US" def test_pseudo_releases_with_empty_links(self): @@ -691,6 +692,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 def test_pseudo_releases_without_links(self): @@ -708,6 +710,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 def test_pseudo_releases_with_unsupported_links(self): @@ -734,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