diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bb888d520..d014b925b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,4 +2,5 @@ * @beetbox/maintainers # Specific ownerships: -/beets/metadata_plugins.py @semohr \ No newline at end of file +/beets/metadata_plugins.py @semohr +/beetsplug/mbpseudo.py @asardaes \ No newline at end of file diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 8fec844a6..d0f3fd134 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar import lap import numpy as np -from beets import config, logging, metadata_plugins +from beets import config, logging, metadata_plugins, plugins from beets.autotag import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch, hooks from beets.util import get_most_common_tags @@ -274,12 +274,17 @@ def tag_album( log.debug("Searching for album ID: {}", search_id) if info := metadata_plugins.album_for_id(search_id): _add_candidate(items, candidates, info) + if opt_candidate := candidates.get(info.album_id): + plugins.send("album_matched", match=opt_candidate) # Use existing metadata or text search. else: # Try search based on current ID. if info := match_by_id(items): _add_candidate(items, candidates, info) + for candidate in candidates.values(): + plugins.send("album_matched", match=candidate) + rec = _recommendation(list(candidates.values())) log.debug("Album ID match recommendation is {}", rec) if candidates and not config["import"]["timid"]: @@ -313,6 +318,8 @@ def tag_album( items, search_artist, search_album, va_likely ): _add_candidate(items, candidates, matched_candidate) + if opt_candidate := candidates.get(matched_candidate.album_id): + plugins.send("album_matched", match=opt_candidate) log.debug("Evaluating {} candidates.", len(candidates)) # Sort and get the recommendation. diff --git a/beets/plugins.py b/beets/plugins.py index e10dcf80c..4fdad9807 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -72,6 +72,7 @@ EventType = Literal[ "album_imported", "album_removed", "albuminfo_received", + "album_matched", "before_choose_candidate", "before_item_moved", "cli_exit", diff --git a/beetsplug/mbpseudo.py b/beetsplug/mbpseudo.py new file mode 100644 index 000000000..448aef365 --- /dev/null +++ b/beetsplug/mbpseudo.py @@ -0,0 +1,364 @@ +# This file is part of beets. +# Copyright 2025, Alexis Sarda-Espinosa. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Adds pseudo-releases from MusicBrainz as candidates during import.""" + +from __future__ import annotations + +import itertools +import traceback +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Iterable, Sequence + +import mediafile +import musicbrainzngs +from typing_extensions import override + +from beets import config +from beets.autotag.distance import Distance, distance +from beets.autotag.hooks import AlbumInfo +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, + _preferred_alias, +) + +if TYPE_CHECKING: + from beets.autotag import AlbumMatch + from beets.library import Item + from beetsplug._typing import JSONDict + +_STATUS_PSEUDO = "Pseudo-Release" + + +class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin): + def __init__(self) -> None: + super().__init__() + + self._release_getter = musicbrainzngs.get_release_by_id + + self.config.add( + { + "scripts": [], + "custom_tags_only": False, + "album_custom_tags": { + "album_transl": "album", + "album_artist_transl": "artist", + }, + "track_custom_tags": { + "title_transl": "title", + "artist_transl": "artist", + }, + } + ) + + self._scripts = self.config["scripts"].as_str_seq() + self._log.debug("Desired scripts: {0}", self._scripts) + + album_custom_tags = self.config["album_custom_tags"].get().keys() + track_custom_tags = self.config["track_custom_tags"].get().keys() + self._log.debug( + "Custom tags for albums and tracks: {0} + {1}", + album_custom_tags, + track_custom_tags, + ) + for custom_tag in album_custom_tags | track_custom_tags: + if not isinstance(custom_tag, str): + continue + + media_field = mediafile.MediaField( + mediafile.MP3DescStorageStyle(custom_tag), + mediafile.MP4StorageStyle( + f"----:com.apple.iTunes:{custom_tag}" + ), + mediafile.StorageStyle(custom_tag), + mediafile.ASFStorageStyle(custom_tag), + ) + try: + self.add_media_field(custom_tag, media_field) + except ValueError: + # ignore errors due to duplicates + pass + + self.register_listener("pluginload", self._on_plugins_loaded) + self.register_listener("album_matched", self._adjust_final_album_match) + + # noinspection PyMethodMayBeStatic + def _on_plugins_loaded(self): + for plugin in find_plugins(): + if isinstance(plugin, MusicBrainzPlugin) and not isinstance( + plugin, MusicBrainzPseudoReleasePlugin + ): + raise RuntimeError( + "The musicbrainz plugin should not be enabled together with" + " the mbpseudo plugin" + ) + + @override + def candidates( + self, + items: Sequence[Item], + artist: str, + album: str, + va_likely: bool, + ) -> Iterable[AlbumInfo]: + if len(self._scripts) == 0: + yield from super().candidates(items, artist, album, va_likely) + else: + for album_info in super().candidates( + items, artist, album, va_likely + ): + if isinstance(album_info, PseudoAlbumInfo): + self._log.debug( + "Using {0} release for distance calculations for album {1}", + album_info.determine_best_ref(items), + album_info.album_id, + ) + yield album_info # first yield pseudo to give it priority + yield album_info.get_official_release() + else: + yield album_info + + @override + def album_info(self, release: JSONDict) -> AlbumInfo: + official_release = super().album_info(release) + + 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]) + try: + raw_pseudo_release = self._release_getter( + album_id, RELEASE_INCLUDES + )["release"] + pseudo_release = super().album_info(raw_pseudo_release) + + if self.config["custom_tags_only"].get(bool): + self._replace_artist_with_alias( + raw_pseudo_release, pseudo_release + ) + self._add_custom_tags(official_release, pseudo_release) + return official_release + else: + return PseudoAlbumInfo( + pseudo_release=_merge_pseudo_and_actual_album( + pseudo_release, official_release + ), + official_release=official_release, + ) + except musicbrainzngs.MusicBrainzError as exc: + raise MusicBrainzAPIError( + exc, + "get pseudo-release by ID", + album_id, + traceback.format_exc(), + ) + else: + return official_release + + def _intercept_mb_release(self, data: JSONDict) -> list[str]: + album_id = data["id"] if "id" in data else None + if self._has_desired_script(data) or not isinstance(album_id, str): + return [] + + return [ + pr_id + for rel in data.get("release-relation-list", []) + if (pr_id := self._wanted_pseudo_release_id(album_id, rel)) + is not None + ] + + def _has_desired_script(self, release: JSONDict) -> bool: + if len(self._scripts) == 0: + return False + elif script := release.get("text-representation", {}).get("script"): + return script in self._scripts + else: + return False + + def _wanted_pseudo_release_id( + self, + album_id: str, + relation: JSONDict, + ) -> str | None: + if ( + len(self._scripts) == 0 + or relation.get("type", "") != "transl-tracklisting" + or relation.get("direction", "") != "forward" + or "release" not in relation + ): + return None + + release = relation["release"] + if "id" in release and self._has_desired_script(release): + self._log.debug( + "Adding pseudo-release {0} for main release {1}", + release["id"], + album_id, + ) + return release["id"] + else: + return None + + def _replace_artist_with_alias( + self, + raw_pseudo_release: JSONDict, + pseudo_release: AlbumInfo, + ): + """Use the pseudo-release's language to search for artist + alias if the user hasn't configured import languages.""" + + 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", [] + ) + aliases = [ + artist_credit.get("artist", {}).get("alias-list", []) + for artist_credit in artist_credits + ] + + if lang and len(lang) >= 2 and len(aliases) > 0: + locale = lang[0:2] + aliases_flattened = list(itertools.chain.from_iterable(aliases)) + self._log.debug( + "Using locale '{0}' to search aliases {1}", + locale, + aliases_flattened, + ) + if alias_dict := _preferred_alias(aliases_flattened, [locale]): + if alias := alias_dict.get("alias"): + self._log.debug("Got alias '{0}'", alias) + pseudo_release.artist = alias + for track in pseudo_release.tracks: + track.artist = alias + + def _add_custom_tags( + self, + official_release: AlbumInfo, + pseudo_release: AlbumInfo, + ): + for tag_key, pseudo_key in ( + self.config["album_custom_tags"].get().items() + ): + official_release[tag_key] = pseudo_release[pseudo_key] + + track_custom_tags = self.config["track_custom_tags"].get().items() + for track, pseudo_track in zip( + official_release.tracks, pseudo_release.tracks + ): + for tag_key, pseudo_key in track_custom_tags: + track[tag_key] = pseudo_track[pseudo_key] + + def _adjust_final_album_match(self, match: AlbumMatch): + album_info = match.info + if isinstance(album_info, PseudoAlbumInfo): + self._log.debug( + "Switching {0} to pseudo-release source for final proposal", + album_info.album_id, + ) + album_info.use_pseudo_as_ref() + mapping = match.mapping + new_mappings, _, _ = assign_items( + list(mapping.keys()), album_info.tracks + ) + mapping.update(new_mappings) + + if album_info.data_source == self.data_source: + album_info.data_source = "MusicBrainz" + + @override + def _extract_id(self, url: str) -> str | None: + return extract_release_id("MusicBrainz", url) + + +class PseudoAlbumInfo(AlbumInfo): + """This is a not-so-ugly hack. + + We want the pseudo-release to result in a distance that is lower or equal to that of + the official release, otherwise it won't qualify as a good candidate. However, if + the input is in a script that's different from the pseudo-release (and we want to + translate/transliterate it in the library), it will receive unwanted penalties. + + This class is essentially a view of the ``AlbumInfo`` of both official and + pseudo-releases, where it's possible to change the details that are exposed to other + parts of the auto-tagger, enabling a "fair" distance calculation based on the + current input's script but still preferring the translation/transliteration in the + final proposal. + """ + + def __init__( + self, + pseudo_release: AlbumInfo, + official_release: AlbumInfo, + **kwargs, + ): + super().__init__(pseudo_release.tracks, **kwargs) + self.__dict__["_pseudo_source"] = True + self.__dict__["_official_release"] = official_release + for k, v in pseudo_release.items(): + if k not in kwargs: + self[k] = v + + def get_official_release(self) -> AlbumInfo: + return self.__dict__["_official_release"] + + def determine_best_ref(self, items: Sequence[Item]) -> str: + self.use_pseudo_as_ref() + pseudo_dist = self._compute_distance(items) + + self.use_official_as_ref() + official_dist = self._compute_distance(items) + + if official_dist < pseudo_dist: + self.use_official_as_ref() + return "official" + else: + self.use_pseudo_as_ref() + return "pseudo" + + def _compute_distance(self, items: Sequence[Item]) -> Distance: + mapping, _, _ = assign_items(items, self.tracks) + return distance(items, self, mapping) + + def use_pseudo_as_ref(self): + self.__dict__["_pseudo_source"] = True + + def use_official_as_ref(self): + self.__dict__["_pseudo_source"] = False + + def __getattr__(self, attr: str) -> Any: + # ensure we don't duplicate an official release's id, always return pseudo's + if self.__dict__["_pseudo_source"] or attr == "album_id": + return super().__getattr__(attr) + else: + return self.__dict__["_official_release"].__getattr__(attr) + + def __deepcopy__(self, memo): + cls = self.__class__ + result = cls.__new__(cls) + + memo[id(self)] = result + result.__dict__.update(self.__dict__) + for k, v in self.items(): + result[k] = deepcopy(v, memo) + + return result diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py index 8e259e94b..29bbc26d0 100644 --- a/beetsplug/musicbrainz.py +++ b/beetsplug/musicbrainz.py @@ -118,13 +118,15 @@ BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 -def _preferred_alias(aliases: list[JSONDict]): - """Given an list of alias structures for an artist credit, select - and return the user's preferred alias alias or None if no matching +def _preferred_alias( + aliases: list[JSONDict], languages: list[str] | None = None +) -> JSONDict | None: + """Given a list of alias structures for an artist credit, select + and return the user's preferred alias or None if no matching alias is found. """ if not aliases: - return + return None # Only consider aliases that have locales set. valid_aliases = [a for a in aliases if "locale" in a] @@ -134,7 +136,10 @@ def _preferred_alias(aliases: list[JSONDict]): ignored_alias_types = [a.lower() for a in ignored_alias_types] # Search configured locales in order. - for locale in config["import"]["languages"].as_str_seq(): + if languages is None: + languages = config["import"]["languages"].as_str_seq() + + for locale in languages: # Find matching primary aliases for this locale that are not # being ignored matches = [] @@ -152,6 +157,8 @@ def _preferred_alias(aliases: list[JSONDict]): return matches[0] + return None + def _multi_artist_credit( credit: list[JSONDict], include_join_phrase: bool @@ -323,7 +330,7 @@ def _find_actual_release_from_pseudo_release( def _merge_pseudo_and_actual_album( pseudo: beets.autotag.hooks.AlbumInfo, actual: beets.autotag.hooks.AlbumInfo -) -> beets.autotag.hooks.AlbumInfo | None: +) -> beets.autotag.hooks.AlbumInfo: """ Merges a pseudo release with its actual release. diff --git a/docs/changelog.rst b/docs/changelog.rst index e192259b1..5ebf3f53e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,8 @@ New features: to receive extra verbose logging around last.fm results and how they are resolved. The ``extended_debug`` config setting and ``--debug`` option have been removed. +- :doc:`plugins/mbpseudo`: Add a new `mbpseudo` plugin to proactively receive + MusicBrainz pseudo-releases as recommendations during import. - Added support for Python 3.13. Bug fixes: @@ -28,6 +30,12 @@ Bug fixes: features for all remaining tracks in the session, avoiding unnecessary API calls and rate limit exhaustion. +For plugin developers: + +- A new plugin event, ``album_matched``, is sent when an album that is being + imported has been matched to its metadata and the corresponding distance has + been calculated. + For packagers: Other changes: diff --git a/docs/dev/plugins/events.rst b/docs/dev/plugins/events.rst index 68773db3b..aaab9ccd7 100644 --- a/docs/dev/plugins/events.rst +++ b/docs/dev/plugins/events.rst @@ -178,6 +178,13 @@ registration process in this case: :Parameters: ``info`` (|AlbumInfo|) :Description: Like ``trackinfo_received`` but for album-level metadata. +``album_matched`` + :Parameters: ``match`` (``AlbumMatch``) + :Description: Called after ``Item`` objects from a folder that's being + imported have been matched to an ``AlbumInfo`` and the corresponding + distance has been calculated. Missing and extra tracks, if any, are + included in the match. + ``before_choose_candidate`` :Parameters: ``task`` (|ImportTask|), ``session`` (|ImportSession|) :Description: Called before prompting the user during interactive import. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index d1590504d..c211616e4 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -102,6 +102,7 @@ databases. They share the following configuration options: loadext lyrics mbcollection + mbpseudo mbsubmit mbsync metasync @@ -153,6 +154,9 @@ Autotagger Extensions :doc:`musicbrainz ` Search for releases in the MusicBrainz_ database. +:doc:`mbpseudo ` + Search for releases and pseudo-releases in the MusicBrainz_ database. + :doc:`spotify ` Search for releases in the Spotify_ database. diff --git a/docs/plugins/mbpseudo.rst b/docs/plugins/mbpseudo.rst new file mode 100644 index 000000000..56658db26 --- /dev/null +++ b/docs/plugins/mbpseudo.rst @@ -0,0 +1,103 @@ +MusicBrainz Pseudo-Release Plugin +================================= + +The `mbpseudo` plugin can be used *instead of* the `musicbrainz` plugin to +search for MusicBrainz pseudo-releases_ during the import process, which are +added to the normal candidates from the MusicBrainz search. + +.. _pseudo-releases: https://musicbrainz.org/doc/Style/Specific_types_of_releases/Pseudo-Releases + +This is useful for releases whose title and track titles are written with a +script_ that can be translated or transliterated into a different one. + +.. _script: https://en.wikipedia.org/wiki/ISO_15924 + +Pseudo-releases will only be included if the initial search in MusicBrainz +returns releases whose script is *not* desired and whose relationships include +pseudo-releases with desired scripts. + +Configuration +------------- + +Since this plugin first searches for official releases from MusicBrainz, all +options from the `musicbrainz` plugin's :ref:`musicbrainz-config` are supported, +but they must be specified under `mbpseudo` in the configuration file. +Additionally, the configuration expects an array of scripts that are desired for +the pseudo-releases. For ``artist`` in particular, keep in mind that even +pseudo-releases might specify it with the original script, so you should also +configure import :ref:`languages` to give artist aliases more priority. +Therefore, the minimum configuration for this plugin looks like this: + +.. code-block:: yaml + + plugins: mbpseudo # remove musicbrainz + + import: + languages: en + + mbpseudo: + scripts: + - Latn + +Note that the `search_limit` configuration applies to the initial search for +official releases, and that the `data_source` in the database will be +"MusicBrainz". Nevertheless, `data_source_mismatch_penalty` must also be +specified under `mbpseudo` if desired (see also +:ref:`metadata-source-plugin-configuration`). An example with multiple data +sources may look like this: + +.. code-block:: yaml + + plugins: mbpseudo deezer + + import: + languages: en + + mbpseudo: + data_source_mismatch_penalty: 0 + scripts: + - Latn + + deezer: + data_source_mismatch_penalty: 0.2 + +By default, the data from the pseudo-release will be used to create a proposal +that is independent from the official release and sets all properties in its +metadata. It's possible to change the configuration so that some information +from the pseudo-release is instead added as custom tags, keeping the metadata +from the official release: + +.. code-block:: yaml + + mbpseudo: + # other config not shown + custom_tags_only: yes + +The default custom tags with this configuration are specified as mappings where +the keys define the tag names and the values define the pseudo-release property +that will be used to set the tag's value: + +.. code-block:: yaml + + mbpseudo: + album_custom_tags: + album_transl: album + album_artist_transl: artist + track_custom_tags: + title_transl: title + artist_transl: artist + +Note that the information for each set of custom tags corresponds to different +metadata levels (album or track level), which is why ``artist`` appears twice +even though it effectively references album artist and track artist +respectively. + +If you want to modify any mapping under ``album_custom_tags`` or +``track_custom_tags``, you must specify *everything* for that set of tags in +your configuration file because any customization replaces the whole dictionary +of mappings for that level. + +.. note:: + + These custom tags are also added to the music files, not only to the + database. diff --git a/test/plugins/test_mbpseudo.py b/test/plugins/test_mbpseudo.py new file mode 100644 index 000000000..621e08950 --- /dev/null +++ b/test/plugins/test_mbpseudo.py @@ -0,0 +1,286 @@ +import json +import pathlib + +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 beetsplug._typing import JSONDict +from beetsplug.mbpseudo import ( + _STATUS_PSEUDO, + MusicBrainzPseudoReleasePlugin, + PseudoAlbumInfo, +) + + +@pytest.fixture(scope="module") +def official_release_info() -> AlbumInfo: + return AlbumInfo( + tracks=[TrackInfo(title="百花繚乱")], + album_id="official", + album="百花繚乱", + ) + + +@pytest.fixture(scope="module") +def pseudo_release_info() -> AlbumInfo: + return AlbumInfo( + tracks=[TrackInfo(title="In Bloom")], + album_id="pseudo", + album="In Bloom", + ) + + +class TestPseudoAlbumInfo: + def test_album_id_always_from_pseudo( + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo + ): + info = PseudoAlbumInfo(pseudo_release_info, official_release_info) + info.use_official_as_ref() + assert info.album_id == "pseudo" + + def test_get_attr_from_pseudo( + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo + ): + info = PseudoAlbumInfo(pseudo_release_info, official_release_info) + assert info.album == "In Bloom" + + def test_get_attr_from_official( + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo + ): + info = PseudoAlbumInfo(pseudo_release_info, official_release_info) + info.use_official_as_ref() + assert info.album == info.get_official_release().album + + def test_determine_best_ref( + self, official_release_info: AlbumInfo, pseudo_release_info: AlbumInfo + ): + info = PseudoAlbumInfo( + pseudo_release_info, official_release_info, data_source="test" + ) + item = Item() + item["title"] = "百花繚乱" + + assert info.determine_best_ref([item]) == "official" + + info.use_pseudo_as_ref() + assert info.data_source == "test" + + +@pytest.fixture(scope="module") +def rsrc_dir(pytestconfig: pytest.Config): + return pytestconfig.rootpath / "test" / "rsrc" / "mbpseudo" + + +class TestMBPseudoPlugin(PluginMixin): + plugin = "mbpseudo" + + @pytest.fixture(scope="class") + def plugin_config(self): + return {"scripts": ["Latn", "Dummy"]} + + @pytest.fixture(scope="class") + 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) + + def test_scripts_init( + self, mbpseudo_plugin: MusicBrainzPseudoReleasePlugin + ): + assert mbpseudo_plugin._scripts == ["Latn", "Dummy"] + + @pytest.mark.parametrize( + "album_id", + [ + "a5ce1d11-2e32-45a4-b37f-c1589d46b103", + "-5ce1d11-2e32-45a4-b37f-c1589d46b103", + ], + ) + def test_extract_id_uses_music_brainz_pattern( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + album_id: str, + ): + if album_id.startswith("-"): + assert mbpseudo_plugin._extract_id(album_id) is None + else: + assert mbpseudo_plugin._extract_id(album_id) == album_id + + def test_album_info_for_pseudo_release( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + pseudo_release: JSONDict, + ): + album_info = mbpseudo_plugin.album_info(pseudo_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + assert album_info.albumstatus == _STATUS_PSEUDO + + @pytest.mark.parametrize( + "json_key", + [ + "type", + "direction", + "release", + ], + ) + def test_interception_skip_when_rel_values_dont_match( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + json_key: str, + ): + del official_release["release"]["release-relation-list"][0][json_key] + + album_info = mbpseudo_plugin.album_info(official_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + + def test_interception_skip_when_script_doesnt_match( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release: JSONDict, + ): + official_release["release"]["release-relation-list"][0]["release"][ + "text-representation" + ]["script"] = "Null" + + album_info = mbpseudo_plugin.album_info(official_release["release"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + + def test_interception( + 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"]) + assert isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + + def test_final_adjustment_skip( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + ): + match = AlbumMatch( + distance=Distance(), + info=AlbumInfo(tracks=[], data_source="mb"), + mapping={}, + extra_items=[], + extra_tracks=[], + ) + + mbpseudo_plugin._adjust_final_album_match(match) + assert match.info.data_source == "mb" + + def test_final_adjustment( + self, + mbpseudo_plugin: MusicBrainzPseudoReleasePlugin, + official_release_info: AlbumInfo, + pseudo_release_info: AlbumInfo, + ): + pseudo_album_info = PseudoAlbumInfo( + pseudo_release=pseudo_release_info, + official_release=official_release_info, + data_source=mbpseudo_plugin.data_source, + ) + pseudo_album_info.use_official_as_ref() + + item = Item() + item["title"] = "百花繚乱" + + match = AlbumMatch( + distance=Distance(), + info=pseudo_album_info, + mapping={item: pseudo_album_info.tracks[0]}, + extra_items=[], + extra_tracks=[], + ) + + mbpseudo_plugin._adjust_final_album_match(match) + + assert match.info.data_source == "MusicBrainz" + assert match.info.album_id == "pseudo" + assert match.info.album == "In Bloom" + + +class TestMBPseudoPluginCustomTagsOnly(PluginMixin): + plugin = "mbpseudo" + + @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 test_custom_tags( + self, + 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"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + assert album_info["album_transl"] == "In Bloom" + assert album_info["album_artist_transl"] == "Lilas Ikuta" + assert album_info.tracks[0]["title_transl"] == "In Bloom" + assert album_info.tracks[0]["artist_transl"] == "Lilas Ikuta" + + def test_custom_tags_with_import_languages( + self, + 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"]) + assert not isinstance(album_info, PseudoAlbumInfo) + assert album_info.data_source == "MusicBrainzPseudoRelease" + assert album_info["album_transl"] == "In Bloom" + assert album_info["album_artist_transl"] == "Lilas Ikuta" + assert album_info.tracks[0]["title_transl"] == "In Bloom" + assert album_info.tracks[0]["artist_transl"] == "Lilas Ikuta" diff --git a/test/rsrc/mbpseudo/official_release.json b/test/rsrc/mbpseudo/official_release.json new file mode 100644 index 000000000..63f1d60dd --- /dev/null +++ b/test/rsrc/mbpseudo/official_release.json @@ -0,0 +1,841 @@ +{ + "release": { + "id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103", + "title": "百花繚乱", + "status": "Official", + "quality": "normal", + "packaging": "None", + "text-representation": { + "language": "jpn", + "script": "Jpan" + }, + "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" + } + ] + } + } + ], + "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": "幾田りら" + }, + "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", + "asin": "B0DR8Y2YDC", + "cover-art-archive": { + "artwork": "true", + "count": "1", + "front": "true", + "back": "false" + }, + "label-info-list": [ + { + "catalog-number": "Lilas-020", + "label": { + "id": "157afde4-4bf5-4039-8ad2-5a15acc85176", + "type": "Production", + "name": "[no label]", + "sort-name": "[no label]", + "disambiguation": "Special purpose label – white labels, self-published releases and other “no label” releases", + "alias-list": [ + { + "sort-name": "2636621 Records DK", + "alias": "2636621 Records DK" + }, + { + "sort-name": "Auto production", + "type": "Search hint", + "alias": "Auto production" + }, + { + "sort-name": "Auto-Edición", + "type": "Search hint", + "alias": "Auto-Edición" + }, + { + "sort-name": "Auto-Product", + "type": "Search hint", + "alias": "Auto-Product" + }, + { + "sort-name": "Autoedición", + "type": "Search hint", + "alias": "Autoedición" + }, + { + "sort-name": "Autoeditado", + "type": "Search hint", + "alias": "Autoeditado" + }, + { + "sort-name": "Autoproduit", + "type": "Search hint", + "alias": "Autoproduit" + }, + { + "sort-name": "D.I.Y.", + "type": "Search hint", + "alias": "D.I.Y." + }, + { + "sort-name": "Demo", + "type": "Search hint", + "alias": "Demo" + }, + { + "sort-name": "DistroKid", + "type": "Search hint", + "alias": "DistroKid" + }, + { + "sort-name": "Eigenverlag", + "type": "Search hint", + "alias": "Eigenverlag" + }, + { + "sort-name": "Eigenvertrieb", + "type": "Search hint", + "alias": "Eigenvertrieb" + }, + { + "sort-name": "GRIND MODE", + "alias": "GRIND MODE" + }, + { + "sort-name": "INDIPENDANT", + "type": "Search hint", + "alias": "INDIPENDANT" + }, + { + "sort-name": "Indepandant", + "type": "Search hint", + "alias": "Indepandant" + }, + { + "sort-name": "Independant release", + "type": "Search hint", + "alias": "Independant release" + }, + { + "sort-name": "Independent", + "type": "Search hint", + "alias": "Independent" + }, + { + "sort-name": "Independente", + "type": "Search hint", + "alias": "Independente" + }, + { + "sort-name": "Independiente", + "type": "Search hint", + "alias": "Independiente" + }, + { + "sort-name": "Indie", + "type": "Search hint", + "alias": "Indie" + }, + { + "sort-name": "Joost Klein", + "alias": "Joost Klein" + }, + { + "sort-name": "MoroseSound", + "alias": "MoroseSound" + }, + { + "sort-name": "N/A", + "type": "Search hint", + "alias": "N/A" + }, + { + "sort-name": "No Label", + "type": "Search hint", + "alias": "No Label" + }, + { + "sort-name": "None", + "type": "Search hint", + "alias": "None" + }, + { + "sort-name": "Not On A Lebel", + "type": "Search hint", + "alias": "Not On A Lebel" + }, + { + "sort-name": "Not On Label", + "type": "Search hint", + "alias": "Not On Label" + }, + { + "sort-name": "P2019", + "alias": "P2019" + }, + { + "sort-name": "P2020", + "alias": "P2020" + }, + { + "sort-name": "P2021", + "alias": "P2021" + }, + { + "sort-name": "P2022", + "alias": "P2022" + }, + { + "sort-name": "P2023", + "alias": "P2023" + }, + { + "sort-name": "P2024", + "alias": "P2024" + }, + { + "sort-name": "P2025", + "alias": "P2025" + }, + { + "sort-name": "Records DK", + "type": "Search hint", + "alias": "Records DK" + }, + { + "sort-name": "Self Digital", + "type": "Search hint", + "alias": "Self Digital" + }, + { + "sort-name": "Self Release", + "type": "Search hint", + "alias": "Self Release" + }, + { + "sort-name": "Self Released", + "type": "Search hint", + "alias": "Self Released" + }, + { + "sort-name": "Self-release", + "type": "Search hint", + "alias": "Self-release" + }, + { + "sort-name": "Self-released", + "type": "Search hint", + "alias": "Self-released" + }, + { + "sort-name": "Self-released/independent", + "type": "Search hint", + "alias": "Self-released/independent" + }, + { + "sort-name": "Sevdaliza", + "alias": "Sevdaliza" + }, + { + "sort-name": "TOMMY CASH", + "alias": "TOMMY CASH" + }, + { + "sort-name": "Talwiinder", + "alias": "Talwiinder" + }, + { + "sort-name": "Unsigned", + "type": "Search hint", + "alias": "Unsigned" + }, + { + "locale": "fi", + "sort-name": "ei levymerkkiä", + "type": "Label name", + "primary": "primary", + "alias": "[ei levymerkkiä]" + }, + { + "locale": "nl", + "sort-name": "[geen platenmaatschappij]", + "type": "Label name", + "primary": "primary", + "alias": "[geen platenmaatschappij]" + }, + { + "locale": "et", + "sort-name": "[ilma plaadifirmata]", + "type": "Label name", + "alias": "[ilma plaadifirmata]" + }, + { + "locale": "es", + "sort-name": "[nada]", + "type": "Label name", + "primary": "primary", + "alias": "[nada]" + }, + { + "locale": "en", + "sort-name": "[no label]", + "type": "Label name", + "primary": "primary", + "alias": "[no label]" + }, + { + "sort-name": "[nolabel]", + "type": "Search hint", + "alias": "[nolabel]" + }, + { + "sort-name": "[none]", + "type": "Search hint", + "alias": "[none]" + }, + { + "locale": "lt", + "sort-name": "[nėra leidybinės kompanijos]", + "type": "Label name", + "alias": "[nėra leidybinės kompanijos]" + }, + { + "locale": "lt", + "sort-name": "[nėra leidyklos]", + "type": "Label name", + "alias": "[nėra leidyklos]" + }, + { + "locale": "lt", + "sort-name": "[nėra įrašų kompanijos]", + "type": "Label name", + "primary": "primary", + "alias": "[nėra įrašų kompanijos]" + }, + { + "locale": "et", + "sort-name": "[puudub]", + "type": "Label name", + "alias": "[puudub]" + }, + { + "locale": "ru", + "sort-name": "samizdat", + "type": "Label name", + "alias": "[самиздат]" + }, + { + "locale": "ja", + "sort-name": "[レーベルなし]", + "type": "Label name", + "primary": "primary", + "alias": "[レーベルなし]" + }, + { + "sort-name": "auto-release", + "type": "Search hint", + "alias": "auto-release" + }, + { + "sort-name": "autoprod.", + "type": "Search hint", + "alias": "autoprod." + }, + { + "sort-name": "blank", + "type": "Search hint", + "alias": "blank" + }, + { + "sort-name": "d.silvestre", + "alias": "d.silvestre" + }, + { + "sort-name": "independent release", + "type": "Search hint", + "alias": "independent release" + }, + { + "sort-name": "nyamura", + "alias": "nyamura" + }, + { + "sort-name": "pls dnt stp", + "alias": "pls dnt stp" + }, + { + "sort-name": "self", + "type": "Search hint", + "alias": "self" + }, + { + "sort-name": "self issued", + "type": "Search hint", + "alias": "self issued" + }, + { + "sort-name": "self-issued", + "type": "Search hint", + "alias": "self-issued" + }, + { + "sort-name": "white label", + "type": "Search hint", + "alias": "white label" + }, + { + "sort-name": "но лабел", + "type": "Search hint", + "alias": "но лабел" + }, + { + "sort-name": "独立发行", + "type": "Search hint", + "alias": "独立发行" + } + ], + "alias-count": 71, + "tag-list": [ + { + "count": "12", + "name": "special purpose" + }, + { + "count": "18", + "name": "special purpose label" + } + ] + } + } + ], + "label-info-count": 1, + "medium-list": [ + { + "position": "1", + "format": "Digital Media", + "track-list": [ + { + "id": "0bd01e8b-18e1-4708-b0a3-c9603b89ab97", + "position": "1", + "number": "1", + "length": "179239", + "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": "幾田りら" + }, + "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": "幾田りら", + "track_or_recording_length": "179239" + } + ], + "track-count": 1 + } + ], + "medium-count": 1, + "artist-relation-list": [ + { + "type": "copyright", + "type-id": "730b5251-7432-4896-8fc6-e1cba943bfe1", + "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": "phonographic copyright", + "type-id": "01d3488d-8d2a-4cff-9226-5250404db4dc", + "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" + } + ], + "release-relation-list": [ + { + "type": "transl-tracklisting", + "type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644", + "target": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", + "direction": "forward", + "release": { + "id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43", + "title": "In Bloom", + "quality": "normal", + "text-representation": { + "language": "eng", + "script": "Latn" + }, + "artist-credit": [ + { + "name": "Lilas Ikuta", + "artist": { + "id": "55e42264-ef27-49d8-93fd-29f930dc96e4", + "name": "幾田りら", + "sort-name": "Ikuta, Lilas", + "country": "JP" + } + } + ], + "medium-list": [], + "medium-count": 0, + "artist-credit-phrase": "Lilas Ikuta" + } + } + ], + "url-relation-list": [ + { + "type": "amazon asin", + "type-id": "4f2e710d-166c-480c-a293-2e2c8d658d87", + "target": "https://www.amazon.co.jp/gp/product/B0DR8Y2YDC", + "direction": "forward" + }, + { + "type": "free streaming", + "type-id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", + "target": "https://open.spotify.com/album/3LDV2xGL9HiqCsQujEPQLb", + "direction": "forward" + }, + { + "type": "free streaming", + "type-id": "08445ccf-7b99-4438-9f9a-fb9ac18099ee", + "target": "https://www.deezer.com/album/687686261", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://mora.jp/package/43000011/199066336168/", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://mora.jp/package/43000011/199066336168_HD/", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://mora.jp/package/43000011/199066336168_LL/", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://music.apple.com/jp/album/1786972161", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://ototoy.jp/_/default/p/2501951", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/fl9tx2j78reza", + "direction": "forward" + }, + { + "type": "purchase for download", + "type-id": "98e08c20-8402-4163-8970-53504bb6a1e4", + "target": "https://www.qobuz.com/jp-ja/album/lilas-ikuta-/l1dnc4xoi6l7a", + "direction": "forward" + }, + { + "type": "streaming", + "type-id": "320adf26-96fa-4183-9045-1f5f32f833cb", + "target": "https://music.amazon.co.jp/albums/B0DR8Y2YDC", + "direction": "forward" + }, + { + "type": "streaming", + "type-id": "320adf26-96fa-4183-9045-1f5f32f833cb", + "target": "https://music.apple.com/jp/album/1786972161", + "direction": "forward" + }, + { + "type": "vgmdb", + "type-id": "6af0134a-df6a-425a-96e2-895f9cd342ba", + "target": "https://vgmdb.net/album/145936", + "direction": "forward" + } + ], + "artist-credit-phrase": "幾田りら" + } +} diff --git a/test/rsrc/mbpseudo/pseudo_release.json b/test/rsrc/mbpseudo/pseudo_release.json new file mode 100644 index 000000000..99fa0b417 --- /dev/null +++ b/test/rsrc/mbpseudo/pseudo_release.json @@ -0,0 +1,346 @@ +{ + "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": [ + { + "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" + } + ] + } + } + ], + "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", + "artist-credit": [ + { + "name": "Lilas Ikuta", + "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" + } + ] + } + } + ], + "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": "幾田りら" + }, + "artist-credit-phrase": "Lilas Ikuta", + "track_or_recording_length": "179239" + } + ], + "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" + } +} \ No newline at end of file