musicbrainz: lookup release directly

This commit is contained in:
Šarūnas Nejus 2025-09-28 23:19:34 +01:00
parent 7fdb458524
commit 2a63e13617
No known key found for this signature in database
8 changed files with 2856 additions and 1539 deletions

View file

@ -6,7 +6,7 @@ import requests
from beets import __version__
class NotFoundError(requests.exceptions.HTTPError):
class HTTPNotFoundError(requests.exceptions.HTTPError):
pass
@ -29,7 +29,7 @@ class TimeoutSession(requests.Session):
kwargs.setdefault("timeout", 10)
r = super().request(*args, **kwargs)
if r.status_code == HTTPStatus.NOT_FOUND:
raise NotFoundError("HTTP Error: Not Found", response=r)
raise HTTPNotFoundError("HTTP Error: Not Found", response=r)
if 300 <= r.status_code < 400:
raise CaptchaError("Captcha is required", response=r)

View file

@ -39,7 +39,7 @@ from beets import plugins, ui
from beets.autotag.distance import string_dist
from beets.util.config import sanitize_choices
from ._utils.requests import TimeoutSession
from ._utils.requests import CaptchaError, HTTPNotFoundError, TimeoutSession
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
@ -327,7 +327,7 @@ class LRCLib(Backend):
yield self.fetch_json(self.SEARCH_URL, params=base_params)
with suppress(NotFoundError):
with suppress(HTTPNotFoundError):
yield [self.fetch_json(self.GET_URL, params=get_params)]
@classmethod

View file

@ -22,7 +22,6 @@ from copy import deepcopy
from typing import TYPE_CHECKING, Any
import mediafile
import musicbrainzngs
from typing_extensions import override
from beets import config
@ -32,7 +31,6 @@ from beets.autotag.match import assign_items
from beets.plugins import find_plugins
from beets.util.id_extractors import extract_release_id
from beetsplug.musicbrainz import (
RELEASE_INCLUDES,
MusicBrainzAPIError,
MusicBrainzPlugin,
_merge_pseudo_and_actual_album,
@ -53,8 +51,6 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
def __init__(self) -> None:
super().__init__()
self._release_getter = musicbrainzngs.get_release_by_id
self.config.add(
{
"scripts": [],
@ -143,12 +139,12 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
if release.get("status") == _STATUS_PSEUDO:
return official_release
elif pseudo_release_ids := self._intercept_mb_release(release):
album_id = self._extract_id(pseudo_release_ids[0])
if (ids := self._intercept_mb_release(release)) and (
album_id := self._extract_id(ids[0])
):
try:
raw_pseudo_release = self._release_getter(
album_id, RELEASE_INCLUDES
)["release"]
raw_pseudo_release = self.api.get_release(album_id)
pseudo_release = super().album_info(raw_pseudo_release)
if self.config["custom_tags_only"].get(bool):
@ -181,7 +177,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
return [
pr_id
for rel in data.get("release-relation-list", [])
for rel in data.get("release-relations", [])
if (pr_id := self._wanted_pseudo_release_id(album_id, rel))
is not None
]
@ -234,7 +230,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
"artist-credit", []
)
aliases = [
artist_credit.get("artist", {}).get("alias-list", [])
artist_credit.get("artist", {}).get("aliases", [])
for artist_credit in artist_credits
]
@ -247,7 +243,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
aliases_flattened,
)
if alias_dict := _preferred_alias(aliases_flattened, [locale]):
if alias := alias_dict.get("alias"):
if alias := alias_dict.get("name"):
self._log.debug("Got alias '{0}'", alias)
pseudo_release.artist = alias
for track in pseudo_release.tracks:

View file

@ -16,11 +16,12 @@
from __future__ import annotations
import operator
import traceback
from collections import Counter
from contextlib import suppress
from functools import cached_property
from itertools import product
from functools import cached_property, singledispatchmethod
from itertools import groupby, product
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin
@ -35,7 +36,7 @@ from beets.metadata_plugins import MetadataSourcePlugin
from beets.util.deprecation import deprecate_for_user
from beets.util.id_extractors import extract_release_id
from ._utils.requests import TimeoutSession
from ._utils.requests import HTTPNotFoundError, TimeoutSession
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
@ -83,27 +84,24 @@ class MusicBrainzAPIError(util.HumanReadableError):
return f"{self._reasonstr()} in {self.verb} with query {self.query!r}"
RELEASE_INCLUDES = list(
{
"artists",
"media",
"recordings",
"release-groups",
"labels",
"artist-credits",
"aliases",
"recording-level-rels",
"work-rels",
"work-level-rels",
"artist-rels",
"isrcs",
"url-rels",
"release-rels",
"genres",
"tags",
}
& set(musicbrainzngs.VALID_INCLUDES["release"])
)
RELEASE_INCLUDES = [
"artists",
"media",
"recordings",
"release-groups",
"labels",
"artist-credits",
"aliases",
"recording-level-rels",
"work-rels",
"work-level-rels",
"artist-rels",
"isrcs",
"url-rels",
"release-rels",
"genres",
"tags",
]
TRACK_INCLUDES = list(
{
@ -130,7 +128,7 @@ BROWSE_MAXTRACKS = 500
class MusicBrainzAPI:
api_url = "https://musicbrainz.org/ws/2/"
api_url = "https://musicbrainz.org/ws/2"
@cached_property
def session(self) -> LimiterTimeoutSession:
@ -141,6 +139,54 @@ class MusicBrainzAPI:
f"{self.api_url}/{entity}", params={**kwargs, "fmt": "json"}
).json()
def get_release(self, id_: str) -> JSONDict:
return self._group_relations(
self._get(f"release/{id_}", inc=" ".join(RELEASE_INCLUDES))
)
@singledispatchmethod
@classmethod
def _group_relations(cls, data: Any) -> Any:
"""Normalize MusicBrainz 'relations' into type-keyed fields recursively.
This helper rewrites payloads that use a generic 'relations' list into
a structure that is easier to consume downstream. When a mapping
contains 'relations', those entries are regrouped by their 'target-type'
and stored under keys like '<target-type>-relations'. The original
'relations' key is removed to avoid ambiguous access patterns.
The transformation is applied recursively so that nested objects and
sequences are normalized consistently, while non-container values are
left unchanged.
"""
return data
@_group_relations.register(list)
@classmethod
def _(cls, data: list[Any]) -> list[Any]:
return [cls._group_relations(i) for i in data]
@_group_relations.register(dict)
@classmethod
def _(cls, data: JSONDict) -> JSONDict:
for k, v in list(data.items()):
if k == "relations":
get_target_type = operator.methodcaller("get", "target-type")
for target_type, group in groupby(
sorted(v, key=get_target_type), get_target_type
):
relations = [
{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
)
data.pop("relations")
else:
data[k] = cls._group_relations(v)
return data
def _preferred_alias(
aliases: list[JSONDict], languages: list[str] | None = None
@ -169,7 +215,7 @@ def _preferred_alias(
for alias in valid_aliases:
if (
alias["locale"] == locale
and "primary" in alias
and alias.get("primary")
and alias.get("type", "").lower() not in ignored_alias_types
):
matches.append(alias)
@ -194,36 +240,33 @@ def _multi_artist_credit(
artist_sort_parts = []
artist_credit_parts = []
for el in credit:
if isinstance(el, str):
# Join phrase.
if include_join_phrase:
artist_parts.append(el)
artist_credit_parts.append(el)
artist_sort_parts.append(el)
alias = _preferred_alias(el["artist"].get("aliases", ()))
# An artist.
if alias:
cur_artist_name = alias["name"]
else:
alias = _preferred_alias(el["artist"].get("alias-list", ()))
cur_artist_name = el["artist"]["name"]
artist_parts.append(cur_artist_name)
# An artist.
if alias:
cur_artist_name = alias["alias"]
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 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)
# 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,
@ -293,9 +336,9 @@ def _preferred_release_event(
].as_str_seq()
for country in preferred_countries:
for event in release.get("release-event-list", {}):
for event in release.get("release-events", {}):
try:
if country in event["area"]["iso-3166-1-code-list"]:
if country in event["area"]["iso-3166-1-codes"]:
return country, event["date"]
except KeyError:
pass
@ -370,7 +413,11 @@ def _merge_pseudo_and_actual_album(
class MusicBrainzPlugin(MetadataSourcePlugin):
@cached_property
def genres_field(self) -> str:
return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}-list"
return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}s"
@cached_property
def api(self) -> MusicBrainzAPI:
return MusicBrainzAPI()
def __init__(self):
"""Set up the python-musicbrainz-ngs module according to settings
@ -461,9 +508,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
info.artists_ids = _artist_ids(recording["artist-credit"])
info.artist_id = info.artists_ids[0]
if recording.get("artist-relation-list"):
if recording.get("artist-relations"):
info.remixer = _get_related_artist_names(
recording["artist-relation-list"], relation_type="remixer"
recording["artist-relations"], relation_type="remixer"
)
if recording.get("length"):
@ -477,7 +524,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
lyricist = []
composer = []
composer_sort = []
for work_relation in recording.get("work-relation-list", ()):
for work_relation in recording.get("work-relations", ()):
if work_relation["type"] != "performance":
continue
info.work = work_relation["work"]["title"]
@ -486,7 +533,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
info.work_disambig = work_relation["work"]["disambiguation"]
for artist_relation in work_relation["work"].get(
"artist-relation-list", ()
"artist-relations", ()
):
if "type" in artist_relation:
type = artist_relation["type"]
@ -504,7 +551,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
info.composer_sort = ", ".join(composer_sort)
arranger = []
for artist_relation in recording.get("artist-relation-list", ()):
for artist_relation in recording.get("artist-relations", ()):
if "type" in artist_relation:
type = artist_relation["type"]
if type == "arranger":
@ -536,9 +583,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
release["artist-credit"], include_join_phrase=False
)
ntracks = sum(len(m["track-list"]) for m in release["medium-list"])
ntracks = sum(len(m["tracks"]) for m in release["media"])
# The MusicBrainz API omits 'artist-relation-list' and 'work-relation-list'
# The MusicBrainz API omits 'relations'
# when the release has more than 500 tracks. So we use browse_recordings
# on chunks of tracks to recover the same information in this case.
if ntracks > BROWSE_MAXTRACKS:
@ -555,27 +602,27 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
)["recording-list"]
)
track_map = {r["id"]: r for r in recording_list}
for medium in release["medium-list"]:
for recording in medium["track-list"]:
for medium in release["media"]:
for recording in medium["tracks"]:
recording_info = track_map[recording["recording"]["id"]]
recording["recording"] = recording_info
# Basic info.
track_infos = []
index = 0
for medium in release["medium-list"]:
for medium in release["media"]:
disctitle = medium.get("title")
format = medium.get("format")
if format in config["match"]["ignored_media"].as_str_seq():
continue
all_tracks = medium["track-list"]
all_tracks = medium["tracks"]
if (
"data-track-list" in medium
"data-tracks" in medium
and not config["match"]["ignore_data_tracks"]
):
all_tracks += medium["data-track-list"]
all_tracks += medium["data-tracks"]
track_count = len(all_tracks)
if "pregap" in medium:
@ -590,7 +637,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
if (
"video" in track["recording"]
and track["recording"]["video"] == "true"
and track["recording"]["video"]
and config["match"]["ignore_video_tracks"]
):
continue
@ -644,7 +691,7 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
artists=artists_names,
artists_ids=album_artist_ids,
tracks=track_infos,
mediums=len(release["medium-list"]),
mediums=len(release["media"]),
artist_sort=artist_sort_name,
artists_sort=artists_sort_names,
artist_credit=artist_credit_name,
@ -684,9 +731,9 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
rel_primarytype = release["release-group"]["primary-type"]
if rel_primarytype:
albumtypes.append(rel_primarytype.lower())
if "secondary-type-list" in release["release-group"]:
if release["release-group"]["secondary-type-list"]:
for sec_type in release["release-group"]["secondary-type-list"]:
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
@ -702,8 +749,8 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
_set_date_str(info, release_group_date, True)
# Label name.
if release.get("label-info-list"):
label_info = release["label-info-list"][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]":
@ -717,10 +764,10 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
info.language = rep.get("language")
# Media (format).
if release["medium-list"]:
if release["media"]:
# If all media are the same, use that medium name
if len({m.get("format") for m in release["medium-list"]}) == 1:
info.media = release["medium-list"][0].get("format")
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"
@ -744,11 +791,11 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
wanted_sources = {
site for site, wanted in external_ids.items() if wanted
}
if wanted_sources and (url_rels := release.get("url-relation-list")):
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["target"]):
if f"{source}.com" in (target := url["url"]["resource"]):
urls[source] = target
self._log.debug(
"Found link to {} release via MusicBrainz",
@ -838,7 +885,10 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
criteria = self.get_album_criteria(items, artist, album, va_likely)
release_ids = (r["id"] for r in self._search_api("release", criteria))
yield from filter(None, map(self.album_for_id, release_ids))
for id_ in release_ids:
with suppress(HTTPNotFoundError):
if album_info := self.album_for_id(id_):
yield album_info
def item_candidates(
self, item: Item, artist: str, title: str
@ -862,22 +912,20 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
return None
try:
res = musicbrainzngs.get_release_by_id(albumid, RELEASE_INCLUDES)
res = self.api.get_release(albumid)
# resolve linked release relations
actual_res = None
if res.get("status") == "Pseudo-Release" and (
relations := res["release"].get("release-relation-list")
relations := res.get("release-relations")
):
for rel in relations:
if (
rel["type"] == "transl-tracklisting"
and rel["direction"] == "backward"
):
actual_res = musicbrainzngs.get_release_by_id(
rel["target"], RELEASE_INCLUDES
)
actual_res = self.api.get_release(rel["target"])
except musicbrainzngs.ResponseError:
self._log.debug("Album ID match failed.")
@ -888,11 +936,11 @@ class MusicBrainzPlugin(MetadataSourcePlugin):
)
# release is potentially a pseudo release
release = self.album_info(res["release"])
release = self.album_info(res)
# should be None unless we're dealing with a pseudo release
if actual_res is not None:
actual_release = self.album_info(actual_res["release"])
actual_release = self.album_info(actual_res)
return _merge_pseudo_and_actual_album(release, actual_release)
else:
return release

View file

@ -1,14 +1,14 @@
import json
import pathlib
from copy import deepcopy
import pytest
from beets import config
from beets.autotag import AlbumMatch
from beets.autotag.distance import Distance
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.library import Item
from beets.test.helper import PluginMixin
from beets.test.helper import ConfigMixin, PluginMixin
from beetsplug._typing import JSONDict
from beetsplug.mbpseudo import (
_STATUS_PSEUDO,
@ -18,6 +18,23 @@ from beetsplug.mbpseudo import (
@pytest.fixture(scope="module")
def rsrc_dir(pytestconfig: pytest.Config):
return pytestconfig.rootpath / "test" / "rsrc" / "mbpseudo"
@pytest.fixture
def official_release(rsrc_dir: pathlib.Path) -> JSONDict:
info_json = (rsrc_dir / "official_release.json").read_text(encoding="utf-8")
return json.loads(info_json)
@pytest.fixture
def pseudo_release(rsrc_dir: pathlib.Path) -> JSONDict:
info_json = (rsrc_dir / "pseudo_release.json").read_text(encoding="utf-8")
return json.loads(info_json)
@pytest.fixture
def official_release_info() -> AlbumInfo:
return AlbumInfo(
tracks=[TrackInfo(title="百花繚乱")],
@ -26,7 +43,7 @@ def official_release_info() -> AlbumInfo:
)
@pytest.fixture(scope="module")
@pytest.fixture
def pseudo_release_info() -> AlbumInfo:
return AlbumInfo(
tracks=[TrackInfo(title="In Bloom")],
@ -35,6 +52,14 @@ def pseudo_release_info() -> AlbumInfo:
)
@pytest.fixture(scope="module", autouse=True)
def config():
config = ConfigMixin().config
with pytest.MonkeyPatch.context() as m:
m.setattr("beetsplug.mbpseudo.config", config)
yield config
class TestPseudoAlbumInfo:
def test_album_id_always_from_pseudo(
self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo
@ -62,8 +87,7 @@ class TestPseudoAlbumInfo:
info = PseudoAlbumInfo(
pseudo_release_info, official_release_info, data_source="test"
)
item = Item()
item["title"] = "百花繚乱"
item = Item(title="百花繚乱")
assert info.determine_best_ref([item]) == "official"
@ -71,37 +95,29 @@ class TestPseudoAlbumInfo:
assert info.data_source == "test"
@pytest.fixture(scope="module")
def rsrc_dir(pytestconfig: pytest.Config):
return pytestconfig.rootpath / "test" / "rsrc" / "mbpseudo"
class TestMBPseudoPlugin(PluginMixin):
class TestMBPseudoMixin(PluginMixin):
plugin = "mbpseudo"
@pytest.fixture(autouse=True)
def patch_get_release(self, monkeypatch, pseudo_release: JSONDict):
monkeypatch.setattr(
"beetsplug.musicbrainz.MusicBrainzAPI.get_release",
lambda _, album_id: deepcopy(
{pseudo_release["id"]: pseudo_release}[album_id]
),
)
@pytest.fixture(scope="class")
def plugin_config(self):
return {"scripts": ["Latn", "Dummy"]}
@pytest.fixture(scope="class")
@pytest.fixture
def mbpseudo_plugin(self, plugin_config) -> MusicBrainzPseudoReleasePlugin:
self.config[self.plugin].set(plugin_config)
return MusicBrainzPseudoReleasePlugin()
@pytest.fixture
def official_release(self, rsrc_dir: pathlib.Path) -> JSONDict:
info_json = (rsrc_dir / "official_release.json").read_text(
encoding="utf-8"
)
return json.loads(info_json)
@pytest.fixture
def pseudo_release(self, rsrc_dir: pathlib.Path) -> JSONDict:
info_json = (rsrc_dir / "pseudo_release.json").read_text(
encoding="utf-8"
)
return json.loads(info_json)
class TestMBPseudoPlugin(TestMBPseudoMixin):
def test_scripts_init(
self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin
):
@ -129,7 +145,7 @@ class TestMBPseudoPlugin(PluginMixin):
mbpseudo_plugin: MusicBrainzPseudoReleasePlugin,
pseudo_release: JSONDict,
):
album_info = mbpseudo_plugin.album_info(pseudo_release["release"])
album_info = mbpseudo_plugin.album_info(pseudo_release)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainzPseudoRelease"
assert album_info.albumstatus == _STATUS_PSEUDO
@ -148,9 +164,9 @@ class TestMBPseudoPlugin(PluginMixin):
official_release: JSONDict,
json_key: str,
):
del official_release["release"]["release-relation-list"][0][json_key]
del official_release["release-relations"][0][json_key]
album_info = mbpseudo_plugin.album_info(official_release["release"])
album_info = mbpseudo_plugin.album_info(official_release)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainzPseudoRelease"
@ -159,11 +175,11 @@ class TestMBPseudoPlugin(PluginMixin):
mbpseudo_plugin: MusicBrainzPseudoReleasePlugin,
official_release: JSONDict,
):
official_release["release"]["release-relation-list"][0]["release"][
official_release["release-relations"][0]["release"][
"text-representation"
]["script"] = "Null"
album_info = mbpseudo_plugin.album_info(official_release["release"])
album_info = mbpseudo_plugin.album_info(official_release)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainzPseudoRelease"
@ -171,12 +187,8 @@ class TestMBPseudoPlugin(PluginMixin):
self,
mbpseudo_plugin: MusicBrainzPseudoReleasePlugin,
official_release: JSONDict,
pseudo_release: JSONDict,
):
mbpseudo_plugin._release_getter = (
lambda album_id, includes: pseudo_release
)
album_info = mbpseudo_plugin.album_info(official_release["release"])
album_info = mbpseudo_plugin.album_info(official_release)
assert isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainzPseudoRelease"
@ -226,40 +238,19 @@ class TestMBPseudoPlugin(PluginMixin):
assert match.info.album == "In Bloom"
class TestMBPseudoPluginCustomTagsOnly(PluginMixin):
plugin = "mbpseudo"
class TestMBPseudoPluginCustomTagsOnly(TestMBPseudoMixin):
@pytest.fixture(scope="class")
def mbpseudo_plugin(self) -> MusicBrainzPseudoReleasePlugin:
self.config[self.plugin]["scripts"] = ["Latn"]
self.config[self.plugin]["custom_tags_only"] = True
return MusicBrainzPseudoReleasePlugin()
@pytest.fixture(scope="class")
def official_release(self, rsrc_dir: pathlib.Path) -> JSONDict:
info_json = (rsrc_dir / "official_release.json").read_text(
encoding="utf-8"
)
return json.loads(info_json)
@pytest.fixture(scope="class")
def pseudo_release(self, rsrc_dir: pathlib.Path) -> JSONDict:
info_json = (rsrc_dir / "pseudo_release.json").read_text(
encoding="utf-8"
)
return json.loads(info_json)
def plugin_config(self):
return {"scripts": ["Latn", "Dummy"], "custom_tags_only": True}
def test_custom_tags(
self,
config,
mbpseudo_plugin: MusicBrainzPseudoReleasePlugin,
official_release: JSONDict,
pseudo_release: JSONDict,
):
config["import"]["languages"] = []
mbpseudo_plugin._release_getter = (
lambda album_id, includes: pseudo_release
)
album_info = mbpseudo_plugin.album_info(official_release["release"])
config["import"]["languages"] = ["en", "jp"]
album_info = mbpseudo_plugin.album_info(official_release)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainzPseudoRelease"
assert album_info["album_transl"] == "In Bloom"
@ -269,15 +260,12 @@ class TestMBPseudoPluginCustomTagsOnly(PluginMixin):
def test_custom_tags_with_import_languages(
self,
config,
mbpseudo_plugin: MusicBrainzPseudoReleasePlugin,
official_release: JSONDict,
pseudo_release: JSONDict,
):
config["import"]["languages"] = ["en", "jp"]
mbpseudo_plugin._release_getter = (
lambda album_id, includes: pseudo_release
)
album_info = mbpseudo_plugin.album_info(official_release["release"])
config["import"]["languages"] = []
album_info = mbpseudo_plugin.album_info(official_release)
assert not isinstance(album_info, PseudoAlbumInfo)
assert album_info.data_source == "MusicBrainzPseudoRelease"
assert album_info["album_transl"] == "In Bloom"

View file

@ -64,10 +64,10 @@ class MBAlbumInfoTest(MusicBrainzTestCase):
}
],
"date": "3001",
"medium-list": [],
"genre-list": [{"count": 1, "name": "GENRE"}],
"tag-list": [{"count": 1, "name": "TAG"}],
"label-info-list": [
"media": [],
"genres": [{"count": 1, "name": "GENRE"}],
"tags": [{"count": 1, "name": "TAG"}],
"label-info": [
{
"catalog-number": "CATALOG NUMBER",
"label": {"name": "LABEL NAME"},
@ -83,7 +83,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase):
}
if multi_artist_credit:
release["artist-credit"].append(" & ") # add join phase
release["artist-credit"][0]["joinphrase"] = " & "
release["artist-credit"].append(
{
"artist": {
@ -124,7 +124,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase):
]
if multi_artist_credit:
track["artist-credit"].append(" & ") # add join phase
track["artist-credit"][0]["joinphrase"] = " & "
track["artist-credit"].append(
{
"artist": {
@ -148,11 +148,11 @@ class MBAlbumInfoTest(MusicBrainzTestCase):
"number": "A1",
}
data_track_list.append(data_track)
release["medium-list"].append(
release["media"].append(
{
"position": "1",
"track-list": track_list,
"data-track-list": data_track_list,
"tracks": track_list,
"data-tracks": data_track_list,
"format": medium_format,
"title": "MEDIUM TITLE",
}
@ -188,7 +188,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase):
}
]
if multi_artist_credit:
track["artist-credit"].append(" & ") # add join phase
track["artist-credit"][0]["joinphrase"] = " & "
track["artist-credit"].append(
{
"artist": {
@ -200,7 +200,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase):
}
)
if remixer:
track["artist-relation-list"] = [
track["artist-relations"] = [
{
"type": "remixer",
"type-id": "RELATION TYPE ID",
@ -215,7 +215,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase):
}
]
if video:
track["video"] = "true"
track["video"] = True
if disambiguation:
track["disambiguation"] = disambiguation
return track
@ -301,10 +301,10 @@ class MBAlbumInfoTest(MusicBrainzTestCase):
"number": "A1",
}
]
release["medium-list"].append(
release["media"].append(
{
"position": "2",
"track-list": second_track_list,
"tracks": second_track_list,
}
)
@ -700,15 +700,15 @@ class ArtistFlatteningTest(unittest.TestCase):
def _add_alias(self, credit_dict, suffix="", locale="", primary=False):
alias = {
"alias": f"ALIAS{suffix}",
"name": f"ALIAS{suffix}",
"locale": locale,
"sort-name": f"ALIASSORT{suffix}",
}
if primary:
alias["primary"] = "primary"
if "alias-list" not in credit_dict["artist"]:
credit_dict["artist"]["alias-list"] = []
credit_dict["artist"]["alias-list"].append(alias)
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()]
@ -725,7 +725,10 @@ class ArtistFlatteningTest(unittest.TestCase):
assert c == ["CREDIT"]
def test_two_artists(self):
credit = [self._credit_dict("a"), " AND ", self._credit_dict("b")]
credit = [
{**self._credit_dict("a"), "joinphrase": " AND "},
self._credit_dict("b"),
]
a, s, c = musicbrainz._flatten_artist_credit(credit)
assert a == "NAMEa AND NAMEb"
assert s == "SORTa AND SORTb"
@ -783,86 +786,84 @@ class MBLibraryTest(MusicBrainzTestCase):
def test_follow_pseudo_releases(self):
side_effect = [
{
"release": {
"title": "pseudo",
"id": "d2a6f856-b553-40a0-ac54-a321e8e2da02",
"status": "Pseudo-Release",
"medium-list": [
{
"track-list": [
{
"id": "baz",
"recording": {
"title": "translated title",
"id": "bar",
"length": 42,
},
"position": 9,
"number": "A1",
}
],
"position": 5,
}
],
"artist-credit": [
{
"artist": {
"name": "some-artist",
"id": "some-id",
},
}
],
"release-group": {
"id": "another-id",
},
"release-relation-list": [
{
"type": "transl-tracklisting",
"target": "d2a6f856-b553-40a0-ac54-a321e8e2da01",
"direction": "backward",
}
],
}
"title": "pseudo",
"id": "d2a6f856-b553-40a0-ac54-a321e8e2da02",
"status": "Pseudo-Release",
"media": [
{
"tracks": [
{
"id": "baz",
"recording": {
"title": "translated title",
"id": "bar",
"length": 42,
},
"position": 9,
"number": "A1",
}
],
"position": 5,
}
],
"artist-credit": [
{
"artist": {
"name": "some-artist",
"id": "some-id",
},
}
],
"release-group": {
"id": "another-id",
},
"release-relations": [
{
"type": "transl-tracklisting",
"target": "d2a6f856-b553-40a0-ac54-a321e8e2da01",
"direction": "backward",
}
],
},
{
"release": {
"title": "actual",
"id": "d2a6f856-b553-40a0-ac54-a321e8e2da01",
"status": "Official",
"medium-list": [
{
"track-list": [
{
"id": "baz",
"recording": {
"title": "original title",
"id": "bar",
"length": 42,
},
"position": 9,
"number": "A1",
}
],
"position": 5,
}
],
"artist-credit": [
{
"artist": {
"name": "some-artist",
"id": "some-id",
},
}
],
"release-group": {
"id": "another-id",
},
"country": "COUNTRY",
}
"title": "actual",
"id": "d2a6f856-b553-40a0-ac54-a321e8e2da01",
"status": "Official",
"media": [
{
"tracks": [
{
"id": "baz",
"recording": {
"title": "original title",
"id": "bar",
"length": 42,
},
"position": 9,
"number": "A1",
}
],
"position": 5,
}
],
"artist-credit": [
{
"artist": {
"name": "some-artist",
"id": "some-id",
},
}
],
"release-group": {
"id": "another-id",
},
"country": "COUNTRY",
},
]
with mock.patch("musicbrainzngs.get_release_by_id") as gp:
with mock.patch(
"beetsplug.musicbrainz.MusicBrainzAPI.get_release"
) as gp:
gp.side_effect = side_effect
album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02")
assert album.country == "COUNTRY"
@ -870,44 +871,43 @@ class MBLibraryTest(MusicBrainzTestCase):
def test_pseudo_releases_with_empty_links(self):
side_effect = [
{
"release": {
"title": "pseudo",
"id": "d2a6f856-b553-40a0-ac54-a321e8e2da02",
"status": "Pseudo-Release",
"medium-list": [
{
"track-list": [
{
"id": "baz",
"recording": {
"title": "translated title",
"id": "bar",
"length": 42,
},
"position": 9,
"number": "A1",
}
],
"position": 5,
}
],
"artist-credit": [
{
"artist": {
"name": "some-artist",
"id": "some-id",
},
}
],
"release-group": {
"id": "another-id",
},
"release-relation-list": [],
}
},
"title": "pseudo",
"id": "d2a6f856-b553-40a0-ac54-a321e8e2da02",
"status": "Pseudo-Release",
"media": [
{
"tracks": [
{
"id": "baz",
"recording": {
"title": "translated title",
"id": "bar",
"length": 42,
},
"position": 9,
"number": "A1",
}
],
"position": 5,
}
],
"artist-credit": [
{
"artist": {
"name": "some-artist",
"id": "some-id",
},
}
],
"release-group": {
"id": "another-id",
},
}
]
with mock.patch("musicbrainzngs.get_release_by_id") as gp:
with mock.patch(
"beetsplug.musicbrainz.MusicBrainzAPI.get_release"
) as gp:
gp.side_effect = side_effect
album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02")
assert album.country is None
@ -915,43 +915,43 @@ class MBLibraryTest(MusicBrainzTestCase):
def test_pseudo_releases_without_links(self):
side_effect = [
{
"release": {
"title": "pseudo",
"id": "d2a6f856-b553-40a0-ac54-a321e8e2da02",
"status": "Pseudo-Release",
"medium-list": [
{
"track-list": [
{
"id": "baz",
"recording": {
"title": "translated title",
"id": "bar",
"length": 42,
},
"position": 9,
"number": "A1",
}
],
"position": 5,
}
],
"artist-credit": [
{
"artist": {
"name": "some-artist",
"id": "some-id",
},
}
],
"release-group": {
"id": "another-id",
},
}
},
"title": "pseudo",
"id": "d2a6f856-b553-40a0-ac54-a321e8e2da02",
"status": "Pseudo-Release",
"media": [
{
"tracks": [
{
"id": "baz",
"recording": {
"title": "translated title",
"id": "bar",
"length": 42,
},
"position": 9,
"number": "A1",
}
],
"position": 5,
}
],
"artist-credit": [
{
"artist": {
"name": "some-artist",
"id": "some-id",
},
}
],
"release-group": {
"id": "another-id",
},
}
]
with mock.patch("musicbrainzngs.get_release_by_id") as gp:
with mock.patch(
"beetsplug.musicbrainz.MusicBrainzAPI.get_release"
) as gp:
gp.side_effect = side_effect
album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02")
assert album.country is None
@ -959,50 +959,50 @@ class MBLibraryTest(MusicBrainzTestCase):
def test_pseudo_releases_with_unsupported_links(self):
side_effect = [
{
"release": {
"title": "pseudo",
"id": "d2a6f856-b553-40a0-ac54-a321e8e2da02",
"status": "Pseudo-Release",
"medium-list": [
{
"track-list": [
{
"id": "baz",
"recording": {
"title": "translated title",
"id": "bar",
"length": 42,
},
"position": 9,
"number": "A1",
}
],
"position": 5,
}
],
"artist-credit": [
{
"artist": {
"name": "some-artist",
"id": "some-id",
},
}
],
"release-group": {
"id": "another-id",
},
"release-relation-list": [
{
"type": "remaster",
"target": "d2a6f856-b553-40a0-ac54-a321e8e2da01",
"direction": "backward",
}
],
}
},
"title": "pseudo",
"id": "d2a6f856-b553-40a0-ac54-a321e8e2da02",
"status": "Pseudo-Release",
"media": [
{
"tracks": [
{
"id": "baz",
"recording": {
"title": "translated title",
"id": "bar",
"length": 42,
},
"position": 9,
"number": "A1",
}
],
"position": 5,
}
],
"artist-credit": [
{
"artist": {
"name": "some-artist",
"id": "some-id",
},
}
],
"release-group": {
"id": "another-id",
},
"release-relations": [
{
"type": "remaster",
"target": "d2a6f856-b553-40a0-ac54-a321e8e2da01",
"direction": "backward",
}
],
}
]
with mock.patch("musicbrainzngs.get_release_by_id") as gp:
with mock.patch(
"beetsplug.musicbrainz.MusicBrainzAPI.get_release"
) as gp:
gp.side_effect = side_effect
album = self.mb.album_for_id("d2a6f856-b553-40a0-ac54-a321e8e2da02")
assert album.country is None
@ -1069,30 +1069,28 @@ class TestMusicBrainzPlugin(PluginMixin):
lambda *_, **__: {"release-list": [{"id": self.mbid}]},
)
monkeypatch.setattr(
"musicbrainzngs.get_release_by_id",
"beetsplug.musicbrainz.MusicBrainzAPI.get_release",
lambda *_, **__: {
"release": {
"title": "hi",
"id": self.mbid,
"status": "status",
"medium-list": [
{
"track-list": [
{
"id": "baz",
"recording": self.RECORDING,
"position": 9,
"number": "A1",
}
],
"position": 5,
}
],
"artist-credit": [
{"artist": {"name": "some-artist", "id": "some-id"}}
],
"release-group": {"id": "another-id"},
}
"title": "hi",
"id": self.mbid,
"status": "status",
"media": [
{
"tracks": [
{
"id": "baz",
"recording": self.RECORDING,
"position": 9,
"number": "A1",
}
],
"position": 5,
}
],
"artist-credit": [
{"artist": {"name": "some-artist", "id": "some-id"}}
],
"release-group": {"id": "another-id"},
},
)
candidates = list(mb.candidates([], "hello", "there", False))
@ -1100,3 +1098,84 @@ class TestMusicBrainzPlugin(PluginMixin):
assert len(candidates) == 1
assert candidates[0].tracks[0].track_id == self.RECORDING["id"]
assert candidates[0].album == "hi"
def test_group_relations():
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": "work",
"type": "performance",
"work": {
"relations": [
{
"artist": {"name": "幾田りら"},
"target-type": "artist",
"type": "composer",
},
{
"target-type": "url",
"type": "lyrics",
"url": {
"resource": "https://utaten.com/lyric/tt24121002/"
},
},
{
"artist": {"name": "幾田りら"},
"target-type": "artist",
"type": "lyricist",
},
{
"target-type": "url",
"type": "lyrics",
"url": {
"resource": "https://www.uta-net.com/song/366579/"
},
},
],
"title": "百花繚乱",
"type": "Song",
},
},
],
}
assert musicbrainz.MusicBrainzAPI._group_relations(raw_release) == {
"id": "r1",
"artist-relations": [{"type": "vocal", "name": "A"}],
"url-relations": [
{"type": "streaming", "url": "http://s"},
{"type": "purchase", "url": "http://p"},
],
"work-relations": [
{
"type": "performance",
"work": {
"artist-relations": [
{"type": "composer", "artist": {"name": "幾田りら"}},
{"type": "lyricist", "artist": {"name": "幾田りら"}},
],
"url-relations": [
{
"type": "lyrics",
"url": {
"resource": "https://utaten.com/lyric/tt24121002/"
},
},
{
"type": "lyrics",
"url": {
"resource": "https://www.uta-net.com/song/366579/"
},
},
],
"title": "百花繚乱",
"type": "Song",
},
},
],
}

File diff suppressed because it is too large Load diff

View file

@ -1,346 +1,515 @@
{
"release": {
"id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43",
"title": "In Bloom",
"status": "Pseudo-Release",
"quality": "normal",
"text-representation": {
"language": "eng",
"script": "Latn"
},
"artist-credit": [
{
"name": "Lilas Ikuta",
"artist": {
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"type": "Person",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"country": "JP",
"alias-list": [
"aliases": [],
"artist-credit": [
{
"artist": {
"aliases": [
{
"begin": null,
"end": null,
"ended": false,
"locale": "en",
"name": "Lilas Ikuta",
"primary": true,
"sort-name": "Ikuta, Lilas",
"type": "Artist name",
"type-id": "894afba6-2816-3c24-8072-eadb66bd04bc"
}
],
"country": "JP",
"disambiguation": "",
"genres": [
{
"count": 1,
"disambiguation": "",
"id": "eba7715e-ee26-4989-8d49-9db382955419",
"name": "j-pop"
},
{
"count": 1,
"disambiguation": "",
"id": "455f264b-db00-4716-991d-fbd32dc24523",
"name": "singer-songwriter"
}
],
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"tags": [
{
"count": 1,
"name": "j-pop"
},
{
"count": 1,
"name": "singer-songwriter"
}
],
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"joinphrase": "",
"name": "Lilas Ikuta"
}
],
"asin": null,
"barcode": null,
"cover-art-archive": {
"artwork": false,
"back": false,
"count": 0,
"darkened": false,
"front": false
},
"disambiguation": "",
"genres": [],
"id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43",
"label-info": [],
"media": [
{
"format": "Digital Media",
"format-id": "907a28d9-b3b2-3ef6-89a8-7b18d91d4794",
"id": "606faab7-60fa-3a8b-a40f-2c66150cce81",
"position": 1,
"title": "",
"track-count": 1,
"track-offset": 0,
"tracks": [
{
"artist-credit": [
{
"locale": "en",
"sort-name": "Ikuta, Lilas",
"type": "Artist name",
"primary": "primary",
"alias": "Lilas Ikuta"
"artist": {
"aliases": [
{
"begin": null,
"end": null,
"ended": false,
"locale": "en",
"name": "Lilas Ikuta",
"primary": true,
"sort-name": "Ikuta, Lilas",
"type": "Artist name",
"type-id": "894afba6-2816-3c24-8072-eadb66bd04bc"
}
],
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"joinphrase": "",
"name": "Lilas Ikuta"
}
],
"alias-count": 1,
"tag-list": [
{
"count": "1",
"name": "j-pop"
},
{
"count": "1",
"name": "singer-songwriter"
}
]
}
}
],
"release-group": {
"id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1",
"type": "Single",
"title": "百花繚乱",
"first-release-date": "2025-01-10",
"primary-type": "Single",
"artist-credit": [
{
"artist": {
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"type": "Person",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"country": "JP",
"alias-list": [
{
"locale": "en",
"sort-name": "Ikuta, Lilas",
"type": "Artist name",
"primary": "primary",
"alias": "Lilas Ikuta"
}
],
"alias-count": 1,
"tag-list": [
{
"count": "1",
"name": "j-pop"
},
{
"count": "1",
"name": "singer-songwriter"
}
]
}
}
],
"artist-credit-phrase": "幾田りら"
},
"cover-art-archive": {
"artwork": "false",
"count": "0",
"front": "false",
"back": "false"
},
"label-info-list": [],
"label-info-count": 0,
"medium-list": [
{
"position": "1",
"format": "Digital Media",
"track-list": [
{
"id": "2018b012-a184-49a2-a464-fb4628a89588",
"position": "1",
"number": "1",
"title": "In Bloom",
"length": "179239",
"id": "2018b012-a184-49a2-a464-fb4628a89588",
"length": 179239,
"number": "1",
"position": 1,
"recording": {
"aliases": [],
"artist-credit": [
{
"name": "Lilas Ikuta",
"artist": {
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"type": "Person",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"joinphrase": "",
"name": "幾田りら"
}
],
"artist-relations": [
{
"artist": {
"country": "JP",
"alias-list": [
"disambiguation": "Japanese composer/arranger/guitarist, agehasprings",
"id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025",
"name": "KOHD",
"sort-name": "KOHD",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"attribute-ids": {},
"attribute-values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"type": "arranger",
"type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d"
},
{
"artist": {
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"attribute-ids": {},
"attribute-values": {},
"attributes": [],
"begin": "2025",
"direction": "backward",
"end": "2025",
"ended": true,
"source-credit": "",
"target-credit": "Lilas Ikuta",
"type": "phonographic copyright",
"type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30"
},
{
"artist": {
"country": "JP",
"disambiguation": "",
"id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05",
"name": "山本秀哉",
"sort-name": "Yamamoto, Shuya",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"attribute-ids": {},
"attribute-values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"type": "producer",
"type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0"
},
{
"artist": {
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"attribute-ids": {},
"attribute-values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"type": "vocal",
"type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa"
}
],
"disambiguation": "",
"first-release-date": "2025-01-10",
"genres": [],
"id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e",
"isrcs": [
"JPP302400868"
],
"length": 179546,
"tags": [],
"title": "百花繚乱",
"url-relations": [
{
"attribute-ids": {},
"attribute-values": {},
"attributes": [],
"begin": null,
"direction": "forward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"type": "free streaming",
"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": {},
"attributes": [],
"begin": null,
"direction": "forward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"type": "purchase for download",
"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": {},
"attributes": [],
"begin": null,
"direction": "forward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"type": "streaming",
"type-id": "b5f3058a-666c-406f-aafb-f9249fc7b122",
"url": {
"id": "64879627-6eca-4755-98b5-b2234a8dbc61",
"resource": "https://music.apple.com/jp/song/1857886416"
}
}
],
"video": false,
"work-relations": [
{
"attribute-ids": {},
"attribute-values": {},
"attributes": [],
"begin": null,
"direction": "forward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"type": "performance",
"type-id": "a3005666-a872-32c3-ad06-98af558e99b0",
"work": {
"artist-relations": [
{
"locale": "en",
"sort-name": "Ikuta, Lilas",
"type": "Artist name",
"primary": "primary",
"alias": "Lilas Ikuta"
}
],
"alias-count": 1,
"tag-list": [
{
"count": "1",
"name": "j-pop"
"artist": {
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"attribute-ids": {},
"attribute-values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"type": "composer",
"type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f"
},
{
"count": "1",
"name": "singer-songwriter"
"artist": {
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"attribute-ids": {},
"attribute-values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"type": "lyricist",
"type-id": "3e48faba-ec01-47fd-8e89-30e81161661c"
}
],
"attributes": [],
"disambiguation": "",
"id": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed",
"iswcs": [],
"language": "jpn",
"languages": [
"jpn"
],
"title": "百花繚乱",
"type": "Song",
"type-id": "f061270a-2fd6-32f1-a641-f0f8676d14e6",
"url-relations": [
{
"attribute-ids": {},
"attribute-values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"type": "lyrics",
"type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
"url": {
"id": "dfac3640-6b23-4991-a59c-7cb80e8eb950",
"resource": "https://utaten.com/lyric/tt24121002/"
}
},
{
"attribute-ids": {},
"attribute-values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"source-credit": "",
"target-credit": "",
"type": "lyrics",
"type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
"url": {
"id": "b1b5d5df-e79d-4cda-bb2a-8014e5505415",
"resource": "https://www.uta-net.com/song/366579/"
}
}
]
}
}
],
"recording": {
"id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e",
"title": "百花繚乱",
"length": "179546",
"artist-credit": [
{
"artist": {
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"type": "Person",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"country": "JP",
"alias-list": [
{
"locale": "en",
"sort-name": "Ikuta, Lilas",
"type": "Artist name",
"primary": "primary",
"alias": "Lilas Ikuta"
}
],
"alias-count": 1,
"tag-list": [
{
"count": "1",
"name": "j-pop"
},
{
"count": "1",
"name": "singer-songwriter"
}
]
}
}
],
"isrc-list": [
"JPP302400868"
],
"isrc-count": 1,
"artist-relation-list": [
{
"type": "arranger",
"type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d",
"target": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025",
"direction": "backward",
"artist": {
"id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025",
"type": "Person",
"name": "KOHD",
"sort-name": "KOHD",
"country": "JP",
"disambiguation": "Japanese composer/arranger/guitarist, agehasprings"
}
},
{
"type": "phonographic copyright",
"type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30",
"target": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"direction": "backward",
"begin": "2025",
"end": "2025",
"ended": "true",
"artist": {
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"type": "Person",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"country": "JP"
},
"target-credit": "Lilas Ikuta"
},
{
"type": "producer",
"type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0",
"target": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05",
"direction": "backward",
"artist": {
"id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05",
"type": "Person",
"name": "山本秀哉",
"sort-name": "Yamamoto, Shuya",
"country": "JP"
}
},
{
"type": "vocal",
"type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa",
"target": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"direction": "backward",
"artist": {
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"type": "Person",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"country": "JP"
}
}
],
"work-relation-list": [
{
"type": "performance",
"type-id": "a3005666-a872-32c3-ad06-98af558e99b0",
"target": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed",
"direction": "forward",
"work": {
"id": "9e14d6b2-ac7d-43e9-82a9-561bc76ce2ed",
"type": "Song",
"title": "百花繚乱",
"language": "jpn",
"artist-relation-list": [
{
"type": "composer",
"type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f",
"target": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"direction": "backward",
"artist": {
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"type": "Person",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"country": "JP"
}
},
{
"type": "lyricist",
"type-id": "3e48faba-ec01-47fd-8e89-30e81161661c",
"target": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"direction": "backward",
"artist": {
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"type": "Person",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"country": "JP"
}
}
],
"url-relation-list": [
{
"type": "lyrics",
"type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
"target": "https://utaten.com/lyric/tt24121002/",
"direction": "backward"
},
{
"type": "lyrics",
"type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
"target": "https://www.uta-net.com/song/366579/",
"direction": "backward"
}
]
}
}
],
"artist-credit-phrase": "幾田りら"
]
},
"title": "In Bloom"
}
]
}
],
"packaging": null,
"packaging-id": null,
"quality": "normal",
"release-group": {
"aliases": [],
"artist-credit": [
{
"artist": {
"aliases": [
{
"begin": null,
"end": null,
"ended": false,
"locale": "en",
"name": "Lilas Ikuta",
"primary": true,
"sort-name": "Ikuta, Lilas",
"type": "Artist name",
"type-id": "894afba6-2816-3c24-8072-eadb66bd04bc"
}
],
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"type": "Person",
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
},
"joinphrase": "",
"name": "幾田りら"
}
],
"disambiguation": "",
"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": [],
"tags": [],
"title": "百花繚乱"
},
"release-relations": [
{
"attribute-ids": {},
"attribute-values": {},
"attributes": [],
"begin": null,
"direction": "backward",
"end": null,
"ended": false,
"release": {
"artist-credit": [
{
"artist": {
"country": "JP",
"disambiguation": "",
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"type": null,
"type-id": null
},
"artist-credit-phrase": "Lilas Ikuta",
"track_or_recording_length": "179239"
"joinphrase": "",
"name": "幾田りら"
}
],
"track-count": 1
}
],
"medium-count": 1,
"release-relation-list": [
{
"type": "transl-tracklisting",
"type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644",
"target": "a5ce1d11-2e32-45a4-b37f-c1589d46b103",
"direction": "backward",
"release": {
"id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103",
"title": "百花繚乱",
"quality": "normal",
"text-representation": {
"language": "jpn",
"script": "Jpan"
},
"artist-credit": [
{
"artist": {
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
"name": "幾田りら",
"sort-name": "Ikuta, Lilas",
"country": "JP"
}
}
],
"date": "2025-01-10",
"country": "XW",
"release-event-list": [
{
"date": "2025-01-10",
"area": {
"id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
"name": "[Worldwide]",
"sort-name": "[Worldwide]",
"iso-3166-1-code-list": [
"XW"
]
}
}
],
"release-event-count": 1,
"barcode": "199066336168",
"medium-list": [],
"medium-count": 0,
"artist-credit-phrase": "幾田りら"
}
}
],
"artist-credit-phrase": "Lilas Ikuta"
}
}
"barcode": "199066336168",
"country": "XW",
"date": "2025-01-10",
"disambiguation": "",
"id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103",
"media": [],
"packaging": null,
"packaging-id": null,
"quality": "normal",
"release-events": [
{
"area": {
"disambiguation": "",
"id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
"iso-3166-1-codes": [
"XW"
],
"name": "[Worldwide]",
"sort-name": "[Worldwide]",
"type": null,
"type-id": null
},
"date": "2025-01-10"
}
],
"release-group": null,
"status": null,
"status-id": null,
"text-representation": {
"language": "jpn",
"script": "Jpan"
},
"title": "百花繚乱"
},
"source-credit": "",
"target-credit": "",
"type": "transl-tracklisting",
"type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644"
}
],
"status": "Pseudo-Release",
"status-id": "41121bb9-3413-3818-8a9a-9742318349aa",
"tags": [],
"text-representation": {
"language": "eng",
"script": "Latn"
},
"title": "In Bloom"
}