mirror of
https://github.com/beetbox/beets.git
synced 2026-02-08 08:25:23 +01:00
Merge db30e6b3b9 into 680473b9e5
This commit is contained in:
commit
d63c82d16c
12 changed files with 2176 additions and 1728 deletions
|
|
@ -86,6 +86,406 @@ P = ParamSpec("P")
|
|||
R = TypeVar("R")
|
||||
|
||||
|
||||
class _Period(TypedDict):
|
||||
begin: str | None
|
||||
end: str | None
|
||||
ended: bool
|
||||
|
||||
|
||||
class Alias(_Period):
|
||||
locale: str | None
|
||||
name: str
|
||||
primary: bool | None
|
||||
sort_name: str
|
||||
type: (
|
||||
Literal[
|
||||
"Artist name",
|
||||
"Label name",
|
||||
"Legal name",
|
||||
"Recording name",
|
||||
"Release name",
|
||||
"Release group name",
|
||||
"Search hint",
|
||||
]
|
||||
| None
|
||||
)
|
||||
type_id: str | None
|
||||
|
||||
|
||||
class Artist(TypedDict):
|
||||
country: str | None
|
||||
disambiguation: str
|
||||
id: str
|
||||
name: str
|
||||
sort_name: str
|
||||
type: (
|
||||
Literal["Character", "Choir", "Group", "Orchestra", "Other", "Person"]
|
||||
| None
|
||||
)
|
||||
type_id: str | None
|
||||
aliases: NotRequired[list[Alias]]
|
||||
genres: NotRequired[list[Genre]]
|
||||
tags: NotRequired[list[Tag]]
|
||||
|
||||
|
||||
class ArtistCredit(TypedDict):
|
||||
artist: Artist
|
||||
joinphrase: str
|
||||
name: str
|
||||
|
||||
|
||||
class Genre(TypedDict):
|
||||
count: int
|
||||
disambiguation: str
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class Tag(TypedDict):
|
||||
count: int
|
||||
name: str
|
||||
|
||||
|
||||
ReleaseStatus = Literal[
|
||||
"Bootleg",
|
||||
"Cancelled",
|
||||
"Expunged",
|
||||
"Official",
|
||||
"Promotion",
|
||||
"Pseudo-Release",
|
||||
"Withdrawn",
|
||||
]
|
||||
|
||||
ReleasePackaging = Literal[
|
||||
"Book",
|
||||
"Box",
|
||||
"Cardboard/Paper Sleeve",
|
||||
"Cassette Case",
|
||||
"Clamshell Case",
|
||||
"Digibook",
|
||||
"Digifile",
|
||||
"Digipak",
|
||||
"Discbox Slider",
|
||||
"Fatbox",
|
||||
"Gatefold Cover",
|
||||
"Jewel Case",
|
||||
"None",
|
||||
"Keep Case",
|
||||
"Longbox",
|
||||
"Metal Tin",
|
||||
"Other",
|
||||
"Plastic Sleeve",
|
||||
"Slidepack",
|
||||
"Slipcase",
|
||||
"Snap Case",
|
||||
"SnapPack",
|
||||
"Slim Jewel Case",
|
||||
"Super Jewel Box",
|
||||
]
|
||||
|
||||
|
||||
ReleaseQuality = Literal["high", "low", "normal"]
|
||||
|
||||
|
||||
class ReleaseGroup(TypedDict):
|
||||
aliases: list[Alias]
|
||||
artist_credit: list[ArtistCredit]
|
||||
disambiguation: str
|
||||
first_release_date: str
|
||||
genres: list[Genre]
|
||||
id: str
|
||||
primary_type: Literal["Album", "Broadcast", "EP", "Other", "Single"] | None
|
||||
primary_type_id: str | None
|
||||
secondary_type_ids: list[str]
|
||||
secondary_types: list[
|
||||
Literal[
|
||||
"Audiobook",
|
||||
"Audio drama",
|
||||
"Compilation",
|
||||
"DJ-mix",
|
||||
"Demo",
|
||||
"Field recording",
|
||||
"Interview",
|
||||
"Live",
|
||||
"Mixtape/Street",
|
||||
"Remix",
|
||||
"Soundtrack",
|
||||
"Spokenword",
|
||||
]
|
||||
]
|
||||
tags: list[Tag]
|
||||
title: str
|
||||
|
||||
|
||||
class CoverArtArchive(TypedDict):
|
||||
artwork: bool
|
||||
back: bool
|
||||
count: int
|
||||
darkened: bool
|
||||
front: bool
|
||||
|
||||
|
||||
class TextRepresentation(TypedDict):
|
||||
language: str | None
|
||||
script: str | None
|
||||
|
||||
|
||||
class Area(TypedDict):
|
||||
disambiguation: str
|
||||
id: str
|
||||
iso_3166_1_codes: list[str]
|
||||
iso_3166_2_codes: NotRequired[list[str]]
|
||||
name: str
|
||||
sort_name: str
|
||||
type: None
|
||||
type_id: None
|
||||
|
||||
|
||||
class ReleaseEvent(TypedDict):
|
||||
area: Area | None
|
||||
date: str
|
||||
|
||||
|
||||
class Label(TypedDict):
|
||||
aliases: list[Alias]
|
||||
disambiguation: str
|
||||
genres: list[Genre]
|
||||
id: str
|
||||
label_code: int | None
|
||||
name: str
|
||||
sort_name: str
|
||||
tags: list[Tag]
|
||||
type: (
|
||||
Literal[
|
||||
"Bootleg Production",
|
||||
"Broadcaster",
|
||||
"Distributor",
|
||||
"Holding",
|
||||
"Imprint",
|
||||
"Manufacturer",
|
||||
"Original Production",
|
||||
"Publisher",
|
||||
"Reissue Production",
|
||||
"Rights Society",
|
||||
]
|
||||
| None
|
||||
)
|
||||
type_id: str | None
|
||||
|
||||
|
||||
class LabelInfo(TypedDict):
|
||||
catalog_number: str | None
|
||||
label: Label
|
||||
|
||||
|
||||
class Url(TypedDict):
|
||||
id: str
|
||||
resource: str
|
||||
|
||||
|
||||
class RelationBase(_Period):
|
||||
attribute_ids: dict[str, str]
|
||||
attribute_values: dict[str, str]
|
||||
attributes: list[str]
|
||||
direction: Literal["backward", "forward"]
|
||||
source_credit: str
|
||||
target_credit: str
|
||||
type_id: str
|
||||
|
||||
|
||||
ArtistRelationType = Literal[
|
||||
"arranger",
|
||||
"art direction",
|
||||
"artwork",
|
||||
"composer",
|
||||
"conductor",
|
||||
"copyright",
|
||||
"design",
|
||||
"design/illustration",
|
||||
"editor",
|
||||
"engineer",
|
||||
"graphic design",
|
||||
"illustration",
|
||||
"instrument",
|
||||
"instrument arranger",
|
||||
"liner notes",
|
||||
"lyricist",
|
||||
"mastering",
|
||||
"misc",
|
||||
"mix",
|
||||
"mix-DJ",
|
||||
"performer",
|
||||
"phonographic copyright",
|
||||
"photography",
|
||||
"previous attribution",
|
||||
"producer",
|
||||
"programming",
|
||||
"recording",
|
||||
"remixer",
|
||||
"sound",
|
||||
"vocal",
|
||||
"vocal arranger",
|
||||
"writer",
|
||||
]
|
||||
|
||||
|
||||
class ArtistRelation(RelationBase):
|
||||
type: ArtistRelationType
|
||||
artist: Artist
|
||||
attribute_credits: NotRequired[dict[str, str]]
|
||||
|
||||
|
||||
class UrlRelation(RelationBase):
|
||||
type: Literal[
|
||||
"IMDB samples",
|
||||
"IMDb",
|
||||
"allmusic",
|
||||
"amazon asin",
|
||||
"discography entry",
|
||||
"discogs",
|
||||
"download for free",
|
||||
"fanpage",
|
||||
"free streaming",
|
||||
"lyrics",
|
||||
"other databases",
|
||||
"purchase for download",
|
||||
"purchase for mail-order",
|
||||
"secondhandsongs",
|
||||
"show notes",
|
||||
"songfacts",
|
||||
"streaming",
|
||||
"wikidata",
|
||||
"wikipedia",
|
||||
]
|
||||
url: Url
|
||||
|
||||
|
||||
class WorkRelation(RelationBase):
|
||||
type: Literal[
|
||||
"adaptation",
|
||||
"arrangement",
|
||||
"based on",
|
||||
"included works",
|
||||
"lyrical quotation",
|
||||
"medley",
|
||||
"musical quotation",
|
||||
"named after work",
|
||||
"orchestration",
|
||||
"other version",
|
||||
"parts",
|
||||
"revision of",
|
||||
]
|
||||
ordering_key: NotRequired[int]
|
||||
work: Work
|
||||
|
||||
|
||||
class Work(TypedDict):
|
||||
attributes: list[str]
|
||||
disambiguation: str
|
||||
id: str
|
||||
iswcs: list[str]
|
||||
language: str | None
|
||||
languages: list[str]
|
||||
title: str
|
||||
type: str | None
|
||||
type_id: str | None
|
||||
artist_relations: NotRequired[list[ArtistRelation]]
|
||||
url_relations: NotRequired[list[UrlRelation]]
|
||||
work_relations: NotRequired[list[WorkRelation]]
|
||||
|
||||
|
||||
class Recording(TypedDict):
|
||||
aliases: list[Alias]
|
||||
artist_credit: list[ArtistCredit]
|
||||
disambiguation: str
|
||||
id: str
|
||||
isrcs: list[str]
|
||||
length: int | None
|
||||
title: str
|
||||
video: bool
|
||||
artist_relations: NotRequired[list[ArtistRelation]]
|
||||
first_release_date: NotRequired[str]
|
||||
genres: NotRequired[list[Genre]]
|
||||
tags: NotRequired[list[Tag]]
|
||||
url_relations: NotRequired[list[UrlRelation]]
|
||||
work_relations: NotRequired[list[WorkRelation]]
|
||||
|
||||
|
||||
class Track(TypedDict):
|
||||
artist_credit: list[ArtistCredit]
|
||||
id: str
|
||||
length: int | None
|
||||
number: str
|
||||
position: int
|
||||
recording: Recording
|
||||
title: str
|
||||
|
||||
|
||||
class Medium(TypedDict):
|
||||
format: str | None
|
||||
format_id: str | None
|
||||
id: str
|
||||
position: int
|
||||
title: str
|
||||
track_count: int
|
||||
data_tracks: NotRequired[list[Track]]
|
||||
pregap: NotRequired[Track]
|
||||
track_offset: NotRequired[int]
|
||||
tracks: NotRequired[list[Track]]
|
||||
|
||||
|
||||
class ReleaseRelationRelease(TypedDict):
|
||||
artist_credit: list[ArtistCredit]
|
||||
barcode: str | None
|
||||
country: str | None
|
||||
date: str
|
||||
disambiguation: str
|
||||
id: str
|
||||
media: list[Medium]
|
||||
packaging: ReleasePackaging | None
|
||||
packaging_id: str | None
|
||||
quality: ReleaseQuality
|
||||
release_events: list[ReleaseEvent]
|
||||
release_group: ReleaseGroup
|
||||
status: ReleaseStatus | None
|
||||
status_id: str | None
|
||||
text_representation: TextRepresentation
|
||||
title: str
|
||||
|
||||
|
||||
class ReleaseRelation(RelationBase):
|
||||
type: Literal["remaster", "transl-tracklisting", "replaced by"]
|
||||
release: ReleaseRelationRelease
|
||||
|
||||
|
||||
class Release(TypedDict):
|
||||
aliases: list[Alias]
|
||||
artist_credit: list[ArtistCredit]
|
||||
asin: str | None
|
||||
barcode: str | None
|
||||
cover_art_archive: CoverArtArchive
|
||||
disambiguation: str
|
||||
genres: list[Genre]
|
||||
id: str
|
||||
label_info: list[LabelInfo]
|
||||
media: list[Medium]
|
||||
packaging: ReleasePackaging | None
|
||||
packaging_id: str | None
|
||||
quality: ReleaseQuality
|
||||
release_group: ReleaseGroup
|
||||
status: ReleaseStatus | None
|
||||
status_id: str | None
|
||||
tags: list[Tag]
|
||||
text_representation: TextRepresentation
|
||||
title: str
|
||||
artist_relations: NotRequired[list[ArtistRelation]]
|
||||
country: NotRequired[str | None]
|
||||
date: NotRequired[str]
|
||||
release_events: NotRequired[list[ReleaseEvent]]
|
||||
release_relations: NotRequired[list[ReleaseRelation]]
|
||||
url_relations: NotRequired[list[UrlRelation]]
|
||||
|
||||
|
||||
def require_one_of(*keys: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
required = frozenset(keys)
|
||||
|
||||
|
|
@ -169,16 +569,16 @@ class MusicBrainzAPI(RequestHandler):
|
|||
if includes:
|
||||
kwargs["inc"] = "+".join(includes)
|
||||
|
||||
return self._group_relations(
|
||||
return self._normalize_data(
|
||||
self.get_json(f"{self.api_root}/{resource}", params=kwargs)
|
||||
)
|
||||
|
||||
def _lookup(
|
||||
self, entity: Entity, id_: str, **kwargs: Unpack[LookupKwargs]
|
||||
) -> JSONDict:
|
||||
) -> Any:
|
||||
return self._get_resource(f"{entity}/{id_}", **kwargs)
|
||||
|
||||
def _browse(self, entity: Entity, **kwargs) -> list[JSONDict]:
|
||||
def _browse(self, entity: Entity, **kwargs) -> list[Any]:
|
||||
return self._get_resource(entity, **kwargs).get(f"{entity}s", [])
|
||||
|
||||
def search(
|
||||
|
|
@ -204,24 +604,24 @@ class MusicBrainzAPI(RequestHandler):
|
|||
kwargs["query"] = query
|
||||
return self._get_resource(entity, **kwargs)[f"{entity}s"]
|
||||
|
||||
def get_release(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict:
|
||||
def get_release(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> Release:
|
||||
"""Retrieve a release by its MusicBrainz ID."""
|
||||
return self._lookup("release", id_, **kwargs)
|
||||
|
||||
def get_recording(
|
||||
self, id_: str, **kwargs: Unpack[LookupKwargs]
|
||||
) -> JSONDict:
|
||||
) -> Recording:
|
||||
"""Retrieve a recording by its MusicBrainz ID."""
|
||||
return self._lookup("recording", id_, **kwargs)
|
||||
|
||||
def get_work(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> JSONDict:
|
||||
def get_work(self, id_: str, **kwargs: Unpack[LookupKwargs]) -> Work:
|
||||
"""Retrieve a work by its MusicBrainz ID."""
|
||||
return self._lookup("work", id_, **kwargs)
|
||||
|
||||
@require_one_of("artist", "collection", "release", "work")
|
||||
def browse_recordings(
|
||||
self, **kwargs: Unpack[BrowseRecordingsKwargs]
|
||||
) -> list[JSONDict]:
|
||||
) -> list[Recording]:
|
||||
"""Browse recordings related to the given entities.
|
||||
|
||||
At least one of artist, collection, release, or work must be provided.
|
||||
|
|
@ -231,7 +631,7 @@ class MusicBrainzAPI(RequestHandler):
|
|||
@require_one_of("artist", "collection", "release")
|
||||
def browse_release_groups(
|
||||
self, **kwargs: Unpack[BrowseReleaseGroupsKwargs]
|
||||
) -> list[JSONDict]:
|
||||
) -> list[ReleaseGroup]:
|
||||
"""Browse release groups related to the given entities.
|
||||
|
||||
At least one of artist, collection, or release must be provided.
|
||||
|
|
@ -240,29 +640,39 @@ class MusicBrainzAPI(RequestHandler):
|
|||
|
||||
@singledispatchmethod
|
||||
@classmethod
|
||||
def _group_relations(cls, data: Any) -> Any:
|
||||
"""Normalize MusicBrainz 'relations' into type-keyed fields recursively.
|
||||
def _normalize_data(cls, data: Any) -> Any:
|
||||
"""Normalize MusicBrainz relation structures into easier-to-use shapes.
|
||||
|
||||
This helper rewrites payloads that use a generic 'relations' list into
|
||||
a structure that is easier to consume downstream. When a mapping
|
||||
contains 'relations', those entries are regrouped by their 'target-type'
|
||||
and stored under keys like '<target-type>-relations'. The original
|
||||
'relations' key is removed to avoid ambiguous access patterns.
|
||||
|
||||
The transformation is applied recursively so that nested objects and
|
||||
sequences are normalized consistently, while non-container values are
|
||||
left unchanged.
|
||||
This default handler is a no-op that returns non-container values
|
||||
unchanged. Specialized handlers for sequences and mappings perform the
|
||||
actual transformations described below.
|
||||
"""
|
||||
return data
|
||||
|
||||
@_group_relations.register(list)
|
||||
@_normalize_data.register(list)
|
||||
@classmethod
|
||||
def _(cls, data: list[Any]) -> list[Any]:
|
||||
return [cls._group_relations(i) for i in data]
|
||||
"""Apply normalization to each element of a sequence recursively.
|
||||
|
||||
@_group_relations.register(dict)
|
||||
Sequences received from the MusicBrainz API may contain nested mappings
|
||||
that require transformation. This handler maps the normalization step
|
||||
over the sequence and preserves order.
|
||||
"""
|
||||
return [cls._normalize_data(i) for i in data]
|
||||
|
||||
@_normalize_data.register(dict)
|
||||
@classmethod
|
||||
def _(cls, data: JSONDict) -> JSONDict:
|
||||
"""Transform mappings by regrouping relationships and normalizing keys.
|
||||
|
||||
When a mapping contains a generic 'relations' list, entries are grouped
|
||||
by their 'target-type' and placed under keys like
|
||||
'<target-type>_relations' with the 'target-type' field removed from each
|
||||
entry. All other mapping keys have hyphens converted to underscores and
|
||||
their values are normalized recursively to ensure a consistent shape
|
||||
throughout the payload.
|
||||
"""
|
||||
output_data = {}
|
||||
for k, v in list(data.items()):
|
||||
if k == "relations":
|
||||
get_target_type = operator.methodcaller("get", "target-type")
|
||||
|
|
@ -273,13 +683,12 @@ class MusicBrainzAPI(RequestHandler):
|
|||
{k: v for k, v in item.items() if k != "target-type"}
|
||||
for item in group
|
||||
]
|
||||
data[f"{target_type}-relations"] = cls._group_relations(
|
||||
relations
|
||||
output_data[f"{target_type}_relations"] = (
|
||||
cls._normalize_data(relations)
|
||||
)
|
||||
data.pop("relations")
|
||||
else:
|
||||
data[k] = cls._group_relations(v)
|
||||
return data
|
||||
output_data[k.replace("-", "_")] = cls._normalize_data(v)
|
||||
return output_data
|
||||
|
||||
|
||||
class MusicBrainzAPIMixin:
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ if TYPE_CHECKING:
|
|||
from beets.library import Item
|
||||
from beetsplug._typing import JSONDict
|
||||
|
||||
from ._utils.musicbrainz import (
|
||||
Release,
|
||||
ReleaseRelation,
|
||||
ReleaseRelationRelease,
|
||||
)
|
||||
|
||||
_STATUS_PSEUDO = "Pseudo-Release"
|
||||
|
||||
|
||||
|
|
@ -133,7 +139,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
|
|||
yield album_info
|
||||
|
||||
@override
|
||||
def album_info(self, release: JSONDict) -> AlbumInfo:
|
||||
def album_info(self, release: Release) -> AlbumInfo:
|
||||
official_release = super().album_info(release)
|
||||
|
||||
if release.get("status") == _STATUS_PSEUDO:
|
||||
|
|
@ -161,22 +167,22 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
|
|||
else:
|
||||
return official_release
|
||||
|
||||
def _intercept_mb_release(self, data: JSONDict) -> list[str]:
|
||||
def _intercept_mb_release(self, data: Release) -> list[str]:
|
||||
album_id = data["id"] if "id" in data else None
|
||||
if self._has_desired_script(data) or not isinstance(album_id, str):
|
||||
return []
|
||||
|
||||
return [
|
||||
pr_id
|
||||
for rel in data.get("release-relations", [])
|
||||
for rel in data.get("release_relations", [])
|
||||
if (pr_id := self._wanted_pseudo_release_id(album_id, rel))
|
||||
is not None
|
||||
]
|
||||
|
||||
def _has_desired_script(self, release: JSONDict) -> bool:
|
||||
def _has_desired_script(self, release: Release) -> bool:
|
||||
if len(self._scripts) == 0:
|
||||
return False
|
||||
elif script := release.get("text-representation", {}).get("script"):
|
||||
elif script := release.get("text_representation", {}).get("script"):
|
||||
return script in self._scripts
|
||||
else:
|
||||
return False
|
||||
|
|
@ -184,7 +190,7 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
|
|||
def _wanted_pseudo_release_id(
|
||||
self,
|
||||
album_id: str,
|
||||
relation: JSONDict,
|
||||
relation: ReleaseRelation,
|
||||
) -> str | None:
|
||||
if (
|
||||
len(self._scripts) == 0
|
||||
|
|
@ -216,9 +222,9 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
|
|||
if len(config["import"]["languages"].as_str_seq()) > 0:
|
||||
return
|
||||
|
||||
lang = raw_pseudo_release.get("text-representation", {}).get("language")
|
||||
artist_credits = raw_pseudo_release.get("release-group", {}).get(
|
||||
"artist-credit", []
|
||||
lang = raw_pseudo_release.get("text_representation", {}).get("language")
|
||||
artist_credits = raw_pseudo_release.get("release_group", {}).get(
|
||||
"artist_credit", []
|
||||
)
|
||||
aliases = [
|
||||
artist_credit.get("artist", {}).get("aliases", [])
|
||||
|
|
|
|||
|
|
@ -16,14 +16,15 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from collections import defaultdict
|
||||
from contextlib import suppress
|
||||
from functools import cached_property
|
||||
from itertools import product
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Literal, TypedDict
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from confuse.exceptions import NotFoundError
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
import beets
|
||||
import beets.autotag.hooks
|
||||
|
|
@ -37,18 +38,27 @@ from ._utils.requests import HTTPNotFoundError
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Sequence
|
||||
from typing import Literal
|
||||
|
||||
from beets.library import Item
|
||||
|
||||
from ._typing import JSONDict
|
||||
from ._utils.musicbrainz import (
|
||||
Alias,
|
||||
ArtistCredit,
|
||||
ArtistRelation,
|
||||
ArtistRelationType,
|
||||
LabelInfo,
|
||||
Medium,
|
||||
Recording,
|
||||
Release,
|
||||
ReleaseGroup,
|
||||
UrlRelation,
|
||||
)
|
||||
|
||||
VARIOUS_ARTISTS_ID = "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
|
||||
BASE_URL = "https://musicbrainz.org/"
|
||||
|
||||
SKIPPED_TRACKS = ["[data track]"]
|
||||
|
||||
FIELDS_TO_MB_KEYS = {
|
||||
"barcode": "barcode",
|
||||
"catalognum": "catno",
|
||||
|
|
@ -97,187 +107,116 @@ BROWSE_CHUNKSIZE = 100
|
|||
BROWSE_MAXTRACKS = 500
|
||||
|
||||
|
||||
UrlSource = Literal[
|
||||
"discogs", "bandcamp", "spotify", "deezer", "tidal", "beatport"
|
||||
]
|
||||
|
||||
|
||||
class ArtistInfo(TypedDict):
|
||||
artist: str
|
||||
artist_id: str
|
||||
artist_sort: str
|
||||
artist_credit: str
|
||||
artists: list[str]
|
||||
artists_ids: list[str]
|
||||
artists_sort: list[str]
|
||||
artists_credit: list[str]
|
||||
|
||||
|
||||
class ReleaseGroupInfo(TypedDict):
|
||||
albumtype: str | None
|
||||
albumtypes: list[str]
|
||||
releasegroup_id: str
|
||||
release_group_title: str | None
|
||||
releasegroupdisambig: str | None
|
||||
original_year: int | None
|
||||
original_month: int | None
|
||||
original_day: int | None
|
||||
|
||||
|
||||
class LabelInfoInfo(TypedDict):
|
||||
label: str | None
|
||||
catalognum: str | None
|
||||
|
||||
|
||||
class ExternalIdsInfo(TypedDict):
|
||||
discogs_album_id: NotRequired[str | None]
|
||||
bandcamp_album_id: NotRequired[str | None]
|
||||
spotify_album_id: NotRequired[str | None]
|
||||
deezer_album_id: NotRequired[str | None]
|
||||
tidal_album_id: NotRequired[str | None]
|
||||
beatport_album_id: NotRequired[str | None]
|
||||
|
||||
|
||||
def _preferred_alias(
|
||||
aliases: list[JSONDict], languages: list[str] | None = None
|
||||
) -> JSONDict | None:
|
||||
"""Given a list of alias structures for an artist credit, select
|
||||
and return the user's preferred alias or None if no matching
|
||||
"""
|
||||
aliases: list[Alias], languages: list[str] | None = None
|
||||
) -> Alias | None:
|
||||
"""Select the most appropriate alias based on user preferences."""
|
||||
if not aliases:
|
||||
return None
|
||||
|
||||
# Only consider aliases that have locales set.
|
||||
valid_aliases = [a for a in aliases if "locale" in a]
|
||||
|
||||
# Get any ignored alias types and lower case them to prevent case issues
|
||||
ignored_alias_types = config["import"]["ignored_alias_types"].as_str_seq()
|
||||
ignored_alias_types = [a.lower() for a in ignored_alias_types]
|
||||
ignored_alias_types = {
|
||||
a.lower() for a in config["import"]["ignored_alias_types"].as_str_seq()
|
||||
}
|
||||
|
||||
# Search configured locales in order.
|
||||
if languages is None:
|
||||
languages = config["import"]["languages"].as_str_seq()
|
||||
languages = languages or config["import"]["languages"].as_str_seq()
|
||||
|
||||
for locale in languages:
|
||||
matches = (
|
||||
al
|
||||
for locale in languages
|
||||
for al in aliases
|
||||
# Find matching primary aliases for this locale that are not
|
||||
# being ignored
|
||||
matches = []
|
||||
for alias in valid_aliases:
|
||||
if (
|
||||
alias["locale"] == locale
|
||||
and alias.get("primary")
|
||||
and (alias.get("type") or "").lower() not in ignored_alias_types
|
||||
):
|
||||
matches.append(alias)
|
||||
|
||||
# Skip to the next locale if we have no matches
|
||||
if not matches:
|
||||
continue
|
||||
|
||||
return matches[0]
|
||||
|
||||
return None
|
||||
if (
|
||||
al["locale"] == locale
|
||||
and al["primary"]
|
||||
and (al["type"] or "").lower() not in ignored_alias_types
|
||||
)
|
||||
)
|
||||
return next(matches, None)
|
||||
|
||||
|
||||
def _multi_artist_credit(
|
||||
credit: list[JSONDict], include_join_phrase: bool
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Given a list representing an ``artist-credit`` block, accumulate
|
||||
data into a triple of joined artist name lists: canonical, sort, and
|
||||
credit.
|
||||
"""
|
||||
artist_parts = []
|
||||
artist_sort_parts = []
|
||||
artist_credit_parts = []
|
||||
for el in credit:
|
||||
alias = _preferred_alias(el["artist"].get("aliases", ()))
|
||||
|
||||
# An artist.
|
||||
if alias:
|
||||
cur_artist_name = alias["name"]
|
||||
else:
|
||||
cur_artist_name = el["artist"]["name"]
|
||||
artist_parts.append(cur_artist_name)
|
||||
|
||||
# Artist sort name.
|
||||
if alias:
|
||||
artist_sort_parts.append(alias["sort-name"])
|
||||
elif "sort-name" in el["artist"]:
|
||||
artist_sort_parts.append(el["artist"]["sort-name"])
|
||||
else:
|
||||
artist_sort_parts.append(cur_artist_name)
|
||||
|
||||
# Artist credit.
|
||||
if "name" in el:
|
||||
artist_credit_parts.append(el["name"])
|
||||
else:
|
||||
artist_credit_parts.append(cur_artist_name)
|
||||
|
||||
if include_join_phrase and (joinphrase := el.get("joinphrase")):
|
||||
artist_parts.append(joinphrase)
|
||||
artist_sort_parts.append(joinphrase)
|
||||
artist_credit_parts.append(joinphrase)
|
||||
|
||||
return (
|
||||
artist_parts,
|
||||
artist_sort_parts,
|
||||
artist_credit_parts,
|
||||
def _get_related_artist_names(
|
||||
relations: list[ArtistRelation], relation_type: ArtistRelationType
|
||||
) -> str:
|
||||
"""Return a comma-separated list of artist names for a relation type."""
|
||||
return ", ".join(
|
||||
r["artist"]["name"] for r in relations if r["type"] == relation_type
|
||||
)
|
||||
|
||||
|
||||
def track_url(trackid: str) -> str:
|
||||
return urljoin(BASE_URL, f"recording/{trackid}")
|
||||
def _preferred_release_event(release: Release) -> tuple[str | None, str | None]:
|
||||
"""Select the most relevant release country and date for matching.
|
||||
|
||||
|
||||
def _flatten_artist_credit(credit: list[JSONDict]) -> tuple[str, str, str]:
|
||||
"""Given a list representing an ``artist-credit`` block, flatten the
|
||||
data into a triple of joined artist name strings: canonical, sort, and
|
||||
credit.
|
||||
Fall back to the default release event if a preferred event is not found.
|
||||
"""
|
||||
artist_parts, artist_sort_parts, artist_credit_parts = _multi_artist_credit(
|
||||
credit, include_join_phrase=True
|
||||
)
|
||||
return (
|
||||
"".join(artist_parts),
|
||||
"".join(artist_sort_parts),
|
||||
"".join(artist_credit_parts),
|
||||
)
|
||||
|
||||
|
||||
def _artist_ids(credit: list[JSONDict]) -> list[str]:
|
||||
"""
|
||||
Given a list representing an ``artist-credit``,
|
||||
return a list of artist IDs
|
||||
"""
|
||||
artist_ids: list[str] = []
|
||||
for el in credit:
|
||||
if isinstance(el, dict):
|
||||
artist_ids.append(el["artist"]["id"])
|
||||
|
||||
return artist_ids
|
||||
|
||||
|
||||
def _get_related_artist_names(relations, relation_type):
|
||||
"""Given a list representing the artist relationships extract the names of
|
||||
the remixers and concatenate them.
|
||||
"""
|
||||
related_artists = []
|
||||
|
||||
for relation in relations:
|
||||
if relation["type"] == relation_type:
|
||||
related_artists.append(relation["artist"]["name"])
|
||||
|
||||
return ", ".join(related_artists)
|
||||
|
||||
|
||||
def album_url(albumid: str) -> str:
|
||||
return urljoin(BASE_URL, f"release/{albumid}")
|
||||
|
||||
|
||||
def _preferred_release_event(
|
||||
release: dict[str, Any],
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Given a release, select and return the user's preferred release
|
||||
event as a tuple of (country, release_date). Fall back to the
|
||||
default release event if a preferred event is not found.
|
||||
"""
|
||||
preferred_countries: Sequence[str] = config["match"]["preferred"][
|
||||
"countries"
|
||||
].as_str_seq()
|
||||
preferred_countries = config["match"]["preferred"]["countries"].as_str_seq()
|
||||
|
||||
for country in preferred_countries:
|
||||
for event in release.get("release-events", {}):
|
||||
try:
|
||||
if area := event.get("area"):
|
||||
if country in area["iso-3166-1-codes"]:
|
||||
return country, event["date"]
|
||||
except KeyError:
|
||||
pass
|
||||
for event in release.get("release_events", []):
|
||||
if (area := event["area"]) and country in area["iso_3166_1_codes"]:
|
||||
return country, event["date"]
|
||||
|
||||
return release.get("country"), release.get("date")
|
||||
|
||||
|
||||
def _set_date_str(
|
||||
info: beets.autotag.hooks.AlbumInfo,
|
||||
date_str: str,
|
||||
original: bool = False,
|
||||
):
|
||||
"""Given a (possibly partial) YYYY-MM-DD string and an AlbumInfo
|
||||
object, set the object's release date fields appropriately. If
|
||||
`original`, then set the original_year, etc., fields.
|
||||
"""
|
||||
if date_str:
|
||||
date_parts = date_str.split("-")
|
||||
for key in ("year", "month", "day"):
|
||||
if date_parts:
|
||||
date_part = date_parts.pop(0)
|
||||
try:
|
||||
date_num = int(date_part)
|
||||
except ValueError:
|
||||
continue
|
||||
def _get_date(date_str: str) -> tuple[int | None, int | None, int | None]:
|
||||
"""Parse a partial `YYYY-MM-DD` string into numeric date parts.
|
||||
|
||||
if original:
|
||||
key = f"original_{key}"
|
||||
setattr(info, key, date_num)
|
||||
Missing components are returned as `None`. Invalid components are ignored.
|
||||
"""
|
||||
if not date_str:
|
||||
return None, None, None
|
||||
|
||||
parts = list(map(int, date_str.split("-")))
|
||||
|
||||
return (
|
||||
parts[0] if len(parts) > 0 else None,
|
||||
parts[1] if len(parts) > 1 else None,
|
||||
parts[2] if len(parts) > 2 else None,
|
||||
)
|
||||
|
||||
|
||||
def _merge_pseudo_and_actual_album(
|
||||
|
|
@ -322,10 +261,14 @@ def _merge_pseudo_and_actual_album(
|
|||
|
||||
class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
|
||||
@cached_property
|
||||
def genres_field(self) -> str:
|
||||
return f"{self.config['genres_tag'].as_choice(['genre', 'tag'])}s"
|
||||
def genres_field(self) -> Literal["genres", "tags"]:
|
||||
choices: list[Literal["genre", "tag"]] = ["genre", "tag"]
|
||||
choice = self.config["genres_tag"].as_choice(choices)
|
||||
if choice == "genre":
|
||||
return "genres"
|
||||
return "tags"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Set up the python-musicbrainz-ngs module according to settings
|
||||
from the beets configuration. This should be called at startup.
|
||||
"""
|
||||
|
|
@ -355,102 +298,119 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
|
|||
"'musicbrainz.search_limit'",
|
||||
)
|
||||
|
||||
def track_info(
|
||||
self,
|
||||
recording: JSONDict,
|
||||
index: int | None = None,
|
||||
medium: int | None = None,
|
||||
medium_index: int | None = None,
|
||||
medium_total: int | None = None,
|
||||
) -> beets.autotag.hooks.TrackInfo:
|
||||
"""Translates a MusicBrainz recording result dictionary into a beets
|
||||
``TrackInfo`` object. Three parameters are optional and are used
|
||||
only for tracks that appear on releases (non-singletons): ``index``,
|
||||
the overall track number; ``medium``, the disc number;
|
||||
``medium_index``, the track's index on its medium; ``medium_total``,
|
||||
the number of tracks on the medium. Each number is a 1-based index.
|
||||
@staticmethod
|
||||
def _parse_artist_credits(artist_credits: list[ArtistCredit]) -> ArtistInfo:
|
||||
"""Normalize MusicBrainz artist-credit data into tag-friendly fields.
|
||||
|
||||
MusicBrainz represents credits as a sequence of credited artists, each
|
||||
with a display name and a `joinphrase` (for example `' & '`, `' feat.
|
||||
'`, or `''`). This helper converts that structured representation into
|
||||
both:
|
||||
|
||||
- Single string values suitable for common tags (concatenated names with
|
||||
joinphrases preserved).
|
||||
- Parallel lists that keep the per-artist granularity for callers that
|
||||
need to reason about individual credited artists.
|
||||
|
||||
When available, a preferred alias is used for the canonical artist name
|
||||
and sort name, while the credit name preserves the exact credited text
|
||||
from the release.
|
||||
"""
|
||||
artist_parts: list[str] = []
|
||||
artist_sort_parts: list[str] = []
|
||||
artist_credit_parts: list[str] = []
|
||||
artists: list[str] = []
|
||||
artists_sort: list[str] = []
|
||||
artists_credit: list[str] = []
|
||||
artists_ids: list[str] = []
|
||||
|
||||
for el in artist_credits:
|
||||
artists_ids.append(el["artist"]["id"])
|
||||
alias = _preferred_alias(el["artist"].get("aliases", []))
|
||||
artist_object = alias or el["artist"]
|
||||
|
||||
joinphrase = el["joinphrase"]
|
||||
for name, parts, multi in (
|
||||
(artist_object["name"], artist_parts, artists),
|
||||
(artist_object["sort_name"], artist_sort_parts, artists_sort),
|
||||
(el["name"], artist_credit_parts, artists_credit),
|
||||
):
|
||||
parts.extend([name, joinphrase])
|
||||
multi.append(name)
|
||||
|
||||
return {
|
||||
"artist": "".join(artist_parts),
|
||||
"artist_id": artists_ids[0],
|
||||
"artist_sort": "".join(artist_sort_parts),
|
||||
"artist_credit": "".join(artist_credit_parts),
|
||||
"artists": artists,
|
||||
"artists_ids": artists_ids,
|
||||
"artists_sort": artists_sort,
|
||||
"artists_credit": artists_credit,
|
||||
}
|
||||
|
||||
def track_info(self, recording: Recording) -> beets.autotag.hooks.TrackInfo:
|
||||
"""Build a `TrackInfo` object from a MusicBrainz recording payload.
|
||||
|
||||
This is the main translation layer between MusicBrainz's recording model
|
||||
and beets' internal autotag representation. It gathers core identifying
|
||||
metadata (title, MBIDs, URLs), timing information, and artist-credit
|
||||
fields, then enriches the result with relationship-derived roles (such
|
||||
as remixers and arrangers) and work-level credits (such as lyricists and
|
||||
composers).
|
||||
"""
|
||||
info = beets.autotag.hooks.TrackInfo(
|
||||
title=recording["title"],
|
||||
track_id=recording["id"],
|
||||
index=index,
|
||||
medium=medium,
|
||||
medium_index=medium_index,
|
||||
medium_total=medium_total,
|
||||
data_source=self.data_source,
|
||||
data_url=track_url(recording["id"]),
|
||||
data_url=urljoin(BASE_URL, f"recording/{recording['id']}"),
|
||||
length=(
|
||||
int(length) / 1000.0
|
||||
if (length := recording["length"])
|
||||
else None
|
||||
),
|
||||
trackdisambig=recording["disambiguation"] or None,
|
||||
isrc=(
|
||||
";".join(isrcs) if (isrcs := recording.get("isrcs")) else None
|
||||
),
|
||||
**self._parse_artist_credits(recording["artist_credit"]),
|
||||
)
|
||||
|
||||
if recording.get("artist-credit"):
|
||||
# Get the artist names.
|
||||
(
|
||||
info.artist,
|
||||
info.artist_sort,
|
||||
info.artist_credit,
|
||||
) = _flatten_artist_credit(recording["artist-credit"])
|
||||
if artist_relations := recording.get("artist_relations"):
|
||||
if remixer := _get_related_artist_names(
|
||||
artist_relations, "remixer"
|
||||
):
|
||||
info.remixer = remixer
|
||||
if arranger := _get_related_artist_names(
|
||||
artist_relations, "arranger"
|
||||
):
|
||||
info.arranger = arranger
|
||||
|
||||
(
|
||||
info.artists,
|
||||
info.artists_sort,
|
||||
info.artists_credit,
|
||||
) = _multi_artist_credit(
|
||||
recording["artist-credit"], include_join_phrase=False
|
||||
)
|
||||
|
||||
info.artists_ids = _artist_ids(recording["artist-credit"])
|
||||
info.artist_id = info.artists_ids[0]
|
||||
|
||||
if recording.get("artist-relations"):
|
||||
info.remixer = _get_related_artist_names(
|
||||
recording["artist-relations"], relation_type="remixer"
|
||||
)
|
||||
|
||||
if recording.get("length"):
|
||||
info.length = int(recording["length"]) / 1000.0
|
||||
|
||||
info.trackdisambig = recording.get("disambiguation")
|
||||
|
||||
if recording.get("isrcs"):
|
||||
info.isrc = ";".join(recording["isrcs"])
|
||||
|
||||
lyricist = []
|
||||
composer = []
|
||||
composer_sort = []
|
||||
for work_relation in recording.get("work-relations", ()):
|
||||
lyricist: list[str] = []
|
||||
composer: list[str] = []
|
||||
composer_sort: list[str] = []
|
||||
for work_relation in recording.get("work_relations", ()):
|
||||
if work_relation["type"] != "performance":
|
||||
continue
|
||||
info.work = work_relation["work"]["title"]
|
||||
info.mb_workid = work_relation["work"]["id"]
|
||||
if "disambiguation" in work_relation["work"]:
|
||||
info.work_disambig = work_relation["work"]["disambiguation"]
|
||||
|
||||
for artist_relation in work_relation["work"].get(
|
||||
"artist-relations", ()
|
||||
):
|
||||
if "type" in artist_relation:
|
||||
type = artist_relation["type"]
|
||||
if type == "lyricist":
|
||||
lyricist.append(artist_relation["artist"]["name"])
|
||||
elif type == "composer":
|
||||
composer.append(artist_relation["artist"]["name"])
|
||||
composer_sort.append(
|
||||
artist_relation["artist"]["sort-name"]
|
||||
)
|
||||
work = work_relation["work"]
|
||||
info.work = work["title"]
|
||||
info.mb_workid = work["id"]
|
||||
if "disambiguation" in work:
|
||||
info.work_disambig = work["disambiguation"]
|
||||
|
||||
for artist_relation in work.get("artist_relations", ()):
|
||||
if (rel_type := artist_relation["type"]) == "lyricist":
|
||||
lyricist.append(artist_relation["artist"]["name"])
|
||||
elif rel_type == "composer":
|
||||
composer.append(artist_relation["artist"]["name"])
|
||||
composer_sort.append(artist_relation["artist"]["sort_name"])
|
||||
if lyricist:
|
||||
info.lyricist = ", ".join(lyricist)
|
||||
if composer:
|
||||
info.composer = ", ".join(composer)
|
||||
info.composer_sort = ", ".join(composer_sort)
|
||||
|
||||
arranger = []
|
||||
for artist_relation in recording.get("artist-relations", ()):
|
||||
if "type" in artist_relation:
|
||||
type = artist_relation["type"]
|
||||
if type == "arranger":
|
||||
arranger.append(artist_relation["artist"]["name"])
|
||||
if arranger:
|
||||
info.arranger = ", ".join(arranger)
|
||||
|
||||
# Supplementary fields provided by plugins
|
||||
extra_trackdatas = plugins.send("mb_track_extract", data=recording)
|
||||
for extra_trackdata in extra_trackdatas:
|
||||
|
|
@ -458,23 +418,141 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
|
|||
|
||||
return info
|
||||
|
||||
def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo:
|
||||
@staticmethod
|
||||
def _parse_release_group(release_group: ReleaseGroup) -> ReleaseGroupInfo:
|
||||
albumtype = None
|
||||
albumtypes = []
|
||||
if reltype := release_group["primary_type"]:
|
||||
albumtype = reltype.lower()
|
||||
albumtypes.append(albumtype)
|
||||
|
||||
year, month, day = _get_date(release_group["first_release_date"])
|
||||
return ReleaseGroupInfo(
|
||||
albumtype=albumtype,
|
||||
albumtypes=[
|
||||
*albumtypes,
|
||||
*(st.lower() for st in release_group["secondary_types"]),
|
||||
],
|
||||
releasegroup_id=release_group["id"],
|
||||
release_group_title=release_group["title"],
|
||||
releasegroupdisambig=release_group["disambiguation"] or None,
|
||||
original_year=year,
|
||||
original_month=month,
|
||||
original_day=day,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_label_infos(label_infos: list[LabelInfo]) -> LabelInfoInfo:
|
||||
catalognum = label = None
|
||||
if label_infos:
|
||||
label_info = label_infos[0]
|
||||
catalognum = label_info["catalog_number"]
|
||||
if (_label := label_info["label"]["name"]) != "[no label]":
|
||||
label = _label
|
||||
|
||||
return {"label": label, "catalognum": catalognum}
|
||||
|
||||
def _parse_genre(self, release: Release) -> str | None:
|
||||
if self.config["genres"]:
|
||||
genres = [
|
||||
*release["release_group"][self.genres_field],
|
||||
*release.get(self.genres_field, []),
|
||||
]
|
||||
count_by_genre: dict[str, int] = defaultdict(int)
|
||||
for genre in genres:
|
||||
count_by_genre[genre["name"]] += int(genre["count"])
|
||||
|
||||
return "; ".join(
|
||||
g
|
||||
for g, _ in sorted(count_by_genre.items(), key=lambda g: -g[1])
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _parse_external_ids(
|
||||
self, url_relations: list[UrlRelation]
|
||||
) -> ExternalIdsInfo:
|
||||
"""Extract configured external release ids from MusicBrainz URLs.
|
||||
|
||||
MusicBrainz releases can include `url_relations` pointing to third-party
|
||||
sites (for example Bandcamp or Discogs). This helper filters those URL
|
||||
relations to only the sources enabled in configuration, then derives a
|
||||
stable external identifier from each matching URL.
|
||||
"""
|
||||
external_ids = self.config["external_ids"].get()
|
||||
wanted_sources: set[UrlSource] = {
|
||||
site for site, wanted in external_ids.items() if wanted
|
||||
}
|
||||
url_by_source: dict[UrlSource, str] = {}
|
||||
for source, url_relation in product(wanted_sources, url_relations):
|
||||
if f"{source}.com" in (target := url_relation["url"]["resource"]):
|
||||
url_by_source[source] = target
|
||||
self._log.debug(
|
||||
"Found link to {} release via MusicBrainz",
|
||||
source.capitalize(),
|
||||
)
|
||||
|
||||
return {
|
||||
f"{source}_album_id": extract_release_id(source, url)
|
||||
for source, url in url_by_source.items()
|
||||
} # type: ignore[return-value]
|
||||
|
||||
@cached_property
|
||||
def ignored_media(self) -> set[str]:
|
||||
return set(config["match"]["ignored_media"].as_str_seq())
|
||||
|
||||
@cached_property
|
||||
def ignore_data_tracks(self) -> bool:
|
||||
return config["match"]["ignore_data_tracks"].get(bool)
|
||||
|
||||
@cached_property
|
||||
def ignore_video_tracks(self) -> bool:
|
||||
return config["match"]["ignore_video_tracks"].get(bool)
|
||||
|
||||
def get_tracks_from_medium(
|
||||
self, medium: Medium
|
||||
) -> Iterable[beets.autotag.hooks.TrackInfo]:
|
||||
all_tracks = []
|
||||
if pregap := medium.get("pregap"):
|
||||
all_tracks.append(pregap)
|
||||
|
||||
all_tracks.extend(medium.get("tracks", []))
|
||||
|
||||
if not self.ignore_data_tracks:
|
||||
all_tracks.extend(medium.get("data_tracks", []))
|
||||
|
||||
medium_data = {
|
||||
"medium": medium["position"],
|
||||
"medium_total": medium["track_count"],
|
||||
"disctitle": medium["title"],
|
||||
"media": medium["format"],
|
||||
}
|
||||
valid_tracks = [
|
||||
t
|
||||
for t in all_tracks
|
||||
if t["recording"]["title"] != "[data track]"
|
||||
and not (self.ignore_video_tracks and t["recording"]["video"])
|
||||
]
|
||||
for track in valid_tracks:
|
||||
recording = track["recording"]
|
||||
# Prefer track data, where present, over recording data.
|
||||
for key in ("title", "artist_credit", "length"):
|
||||
recording[key] = track[key] or recording[key]
|
||||
|
||||
ti = self.track_info(recording)
|
||||
ti.update(
|
||||
medium_index=int(track["position"]),
|
||||
release_track_id=track["id"],
|
||||
track_alt=track["number"],
|
||||
**medium_data,
|
||||
)
|
||||
|
||||
yield ti
|
||||
|
||||
def album_info(self, release: Release) -> beets.autotag.hooks.AlbumInfo:
|
||||
"""Takes a MusicBrainz release result dictionary and returns a beets
|
||||
AlbumInfo object containing the interesting data about that release.
|
||||
"""
|
||||
# Get artist name using join phrases.
|
||||
artist_name, artist_sort_name, artist_credit_name = (
|
||||
_flatten_artist_credit(release["artist-credit"])
|
||||
)
|
||||
|
||||
(
|
||||
artists_names,
|
||||
artists_sort_names,
|
||||
artists_credit_names,
|
||||
) = _multi_artist_credit(
|
||||
release["artist-credit"], include_join_phrase=False
|
||||
)
|
||||
|
||||
ntracks = sum(len(m["tracks"]) for m in release["media"])
|
||||
|
||||
# The MusicBrainz API omits 'relations'
|
||||
|
|
@ -482,7 +560,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
|
|||
# on chunks of tracks to recover the same information in this case.
|
||||
if ntracks > BROWSE_MAXTRACKS:
|
||||
self._log.debug("Album {} has too many tracks", release["id"])
|
||||
recording_list = []
|
||||
recording_list: list[Recording] = []
|
||||
for i in range(0, ntracks, BROWSE_CHUNKSIZE):
|
||||
self._log.debug("Retrieving tracks starting at {}", i)
|
||||
recording_list.extend(
|
||||
|
|
@ -490,207 +568,63 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
|
|||
release=release["id"], offset=i
|
||||
)
|
||||
)
|
||||
track_map = {r["id"]: r for r in recording_list}
|
||||
recording_by_id = {r["id"]: r for r in recording_list}
|
||||
for medium in release["media"]:
|
||||
for recording in medium["tracks"]:
|
||||
recording_info = track_map[recording["recording"]["id"]]
|
||||
recording["recording"] = recording_info
|
||||
for track in medium["tracks"]:
|
||||
track["recording"] = recording_by_id[
|
||||
track["recording"]["id"]
|
||||
]
|
||||
|
||||
# Basic info.
|
||||
track_infos = []
|
||||
index = 0
|
||||
for medium in release["media"]:
|
||||
disctitle = medium.get("title")
|
||||
format = medium.get("format")
|
||||
valid_media = [
|
||||
m for m in release["media"] if m["format"] not in self.ignored_media
|
||||
]
|
||||
track_infos: list[beets.autotag.hooks.TrackInfo] = []
|
||||
for medium in valid_media:
|
||||
track_infos.extend(self.get_tracks_from_medium(medium))
|
||||
|
||||
if format in config["match"]["ignored_media"].as_str_seq():
|
||||
continue
|
||||
for index, track_info in enumerate(track_infos, 1):
|
||||
track_info.index = index
|
||||
|
||||
all_tracks = medium["tracks"]
|
||||
if (
|
||||
"data-tracks" in medium
|
||||
and not config["match"]["ignore_data_tracks"]
|
||||
):
|
||||
all_tracks += medium["data-tracks"]
|
||||
track_count = len(all_tracks)
|
||||
|
||||
if "pregap" in medium:
|
||||
all_tracks.insert(0, medium["pregap"])
|
||||
|
||||
for track in all_tracks:
|
||||
if (
|
||||
"title" in track["recording"]
|
||||
and track["recording"]["title"] in SKIPPED_TRACKS
|
||||
):
|
||||
continue
|
||||
|
||||
if (
|
||||
"video" in track["recording"]
|
||||
and track["recording"]["video"]
|
||||
and config["match"]["ignore_video_tracks"]
|
||||
):
|
||||
continue
|
||||
|
||||
# Basic information from the recording.
|
||||
index += 1
|
||||
ti = self.track_info(
|
||||
track["recording"],
|
||||
index,
|
||||
int(medium["position"]),
|
||||
int(track["position"]),
|
||||
track_count,
|
||||
)
|
||||
ti.release_track_id = track["id"]
|
||||
ti.disctitle = disctitle
|
||||
ti.media = format
|
||||
ti.track_alt = track["number"]
|
||||
|
||||
# Prefer track data, where present, over recording data.
|
||||
if track.get("title"):
|
||||
ti.title = track["title"]
|
||||
if track.get("artist-credit"):
|
||||
# Get the artist names.
|
||||
(
|
||||
ti.artist,
|
||||
ti.artist_sort,
|
||||
ti.artist_credit,
|
||||
) = _flatten_artist_credit(track["artist-credit"])
|
||||
|
||||
(
|
||||
ti.artists,
|
||||
ti.artists_sort,
|
||||
ti.artists_credit,
|
||||
) = _multi_artist_credit(
|
||||
track["artist-credit"], include_join_phrase=False
|
||||
)
|
||||
|
||||
ti.artists_ids = _artist_ids(track["artist-credit"])
|
||||
ti.artist_id = ti.artists_ids[0]
|
||||
if track.get("length"):
|
||||
ti.length = int(track["length"]) / (1000.0)
|
||||
|
||||
track_infos.append(ti)
|
||||
|
||||
album_artist_ids = _artist_ids(release["artist-credit"])
|
||||
info = beets.autotag.hooks.AlbumInfo(
|
||||
**self._parse_artist_credits(release["artist_credit"]),
|
||||
album=release["title"],
|
||||
album_id=release["id"],
|
||||
artist=artist_name,
|
||||
artist_id=album_artist_ids[0],
|
||||
artists=artists_names,
|
||||
artists_ids=album_artist_ids,
|
||||
tracks=track_infos,
|
||||
media=(
|
||||
medias.pop()
|
||||
if len(medias := {t.media for t in track_infos}) == 1
|
||||
else "Media"
|
||||
),
|
||||
mediums=len(release["media"]),
|
||||
artist_sort=artist_sort_name,
|
||||
artists_sort=artists_sort_names,
|
||||
artist_credit=artist_credit_name,
|
||||
artists_credit=artists_credit_names,
|
||||
data_source=self.data_source,
|
||||
data_url=album_url(release["id"]),
|
||||
data_url=urljoin(BASE_URL, f"release/{release['id']}"),
|
||||
barcode=release.get("barcode"),
|
||||
genre=genre if (genre := self._parse_genre(release)) else None,
|
||||
script=release["text_representation"]["script"],
|
||||
language=release["text_representation"]["language"],
|
||||
asin=release["asin"],
|
||||
albumstatus=release["status"],
|
||||
albumdisambig=release["disambiguation"] or None,
|
||||
**self._parse_release_group(release["release_group"]),
|
||||
**self._parse_label_infos(release["label_info"]),
|
||||
**self._parse_external_ids(release.get("url_relations", [])),
|
||||
)
|
||||
info.va = info.artist_id == VARIOUS_ARTISTS_ID
|
||||
if info.va:
|
||||
info.artist = config["va_name"].as_str()
|
||||
info.asin = release.get("asin")
|
||||
info.releasegroup_id = release["release-group"]["id"]
|
||||
info.albumstatus = release.get("status")
|
||||
|
||||
if release["release-group"].get("title"):
|
||||
info.release_group_title = release["release-group"].get("title")
|
||||
|
||||
# Get the disambiguation strings at the release and release group level.
|
||||
if release["release-group"].get("disambiguation"):
|
||||
info.releasegroupdisambig = release["release-group"].get(
|
||||
"disambiguation"
|
||||
)
|
||||
if release.get("disambiguation"):
|
||||
info.albumdisambig = release.get("disambiguation")
|
||||
|
||||
if reltype := release["release-group"].get("primary-type"):
|
||||
info.albumtype = reltype.lower()
|
||||
|
||||
# Set the new-style "primary" and "secondary" release types.
|
||||
albumtypes = []
|
||||
if "primary-type" in release["release-group"]:
|
||||
rel_primarytype = release["release-group"]["primary-type"]
|
||||
if rel_primarytype:
|
||||
albumtypes.append(rel_primarytype.lower())
|
||||
if "secondary-types" in release["release-group"]:
|
||||
if release["release-group"]["secondary-types"]:
|
||||
for sec_type in release["release-group"]["secondary-types"]:
|
||||
albumtypes.append(sec_type.lower())
|
||||
info.albumtypes = albumtypes
|
||||
|
||||
# Release events.
|
||||
info.country, release_date = _preferred_release_event(release)
|
||||
release_group_date = release["release-group"].get("first-release-date")
|
||||
if not release_date:
|
||||
# Fall back if release-specific date is not available.
|
||||
release_date = release_group_date
|
||||
|
||||
if release_date:
|
||||
_set_date_str(info, release_date, False)
|
||||
_set_date_str(info, release_group_date, True)
|
||||
|
||||
# Label name.
|
||||
if release.get("label-info"):
|
||||
label_info = release["label-info"][0]
|
||||
if label_info.get("label"):
|
||||
label = label_info["label"]["name"]
|
||||
if label != "[no label]":
|
||||
info.label = label
|
||||
info.catalognum = label_info.get("catalog-number")
|
||||
|
||||
# Text representation data.
|
||||
if release.get("text-representation"):
|
||||
rep = release["text-representation"]
|
||||
info.script = rep.get("script")
|
||||
info.language = rep.get("language")
|
||||
|
||||
# Media (format).
|
||||
if release["media"]:
|
||||
# If all media are the same, use that medium name
|
||||
if len({m.get("format") for m in release["media"]}) == 1:
|
||||
info.media = release["media"][0].get("format")
|
||||
# Otherwise, let's just call it "Media"
|
||||
else:
|
||||
info.media = "Media"
|
||||
|
||||
if self.config["genres"]:
|
||||
sources = [
|
||||
release["release-group"].get(self.genres_field, []),
|
||||
release.get(self.genres_field, []),
|
||||
]
|
||||
genres: Counter[str] = Counter()
|
||||
for source in sources:
|
||||
for genreitem in source:
|
||||
genres[genreitem["name"]] += int(genreitem["count"])
|
||||
info.genre = "; ".join(
|
||||
genre
|
||||
for genre, _count in sorted(genres.items(), key=lambda g: -g[1])
|
||||
info.year, info.month, info.day = (
|
||||
_get_date(release_date)
|
||||
if release_date
|
||||
else (
|
||||
info.original_year,
|
||||
info.original_month,
|
||||
info.original_day,
|
||||
)
|
||||
|
||||
# We might find links to external sources (Discogs, Bandcamp, ...)
|
||||
external_ids = self.config["external_ids"].get()
|
||||
wanted_sources = {
|
||||
site for site, wanted in external_ids.items() if wanted
|
||||
}
|
||||
if wanted_sources and (url_rels := release.get("url-relations")):
|
||||
urls = {}
|
||||
|
||||
for source, url in product(wanted_sources, url_rels):
|
||||
if f"{source}.com" in (target := url["url"]["resource"]):
|
||||
urls[source] = target
|
||||
self._log.debug(
|
||||
"Found link to {} release via MusicBrainz",
|
||||
source.capitalize(),
|
||||
)
|
||||
|
||||
for source, url in urls.items():
|
||||
setattr(
|
||||
info, f"{source}_album_id", extract_release_id(source, url)
|
||||
)
|
||||
)
|
||||
|
||||
extra_albumdatas = plugins.send("mb_album_extract", data=release)
|
||||
for extra_albumdata in extra_albumdatas:
|
||||
|
|
@ -771,10 +705,9 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
|
|||
self, item: Item, artist: str, title: str
|
||||
) -> Iterable[beets.autotag.hooks.TrackInfo]:
|
||||
criteria = {"artist": artist, "recording": title, "alias": title}
|
||||
ids = (r["id"] for r in self._search_api("recording", criteria))
|
||||
|
||||
yield from filter(
|
||||
None, map(self.track_info, self._search_api("recording", criteria))
|
||||
)
|
||||
return filter(None, map(self.track_for_id, ids))
|
||||
|
||||
def album_for_id(
|
||||
self, album_id: str
|
||||
|
|
@ -800,7 +733,7 @@ class MusicBrainzPlugin(MusicBrainzAPIMixin, MetadataSourcePlugin):
|
|||
actual_res = None
|
||||
|
||||
if res.get("status") == "Pseudo-Release" and (
|
||||
relations := res.get("release-relations")
|
||||
relations := res.get("release_relations")
|
||||
):
|
||||
for rel in relations:
|
||||
if (
|
||||
|
|
|
|||
77
poetry.lock
generated
77
poetry.lock
generated
|
|
@ -1000,6 +1000,41 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
|
|||
[package.extras]
|
||||
test = ["pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "factory-boy"
|
||||
version = "3.3.3"
|
||||
description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc"},
|
||||
{file = "factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
Faker = ">=0.7.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["Django", "Pillow", "SQLAlchemy", "coverage", "flake8", "isort", "mongoengine", "mongomock", "mypy", "tox", "wheel (>=0.32.0)", "zest.releaser[recommended]"]
|
||||
doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"]
|
||||
|
||||
[[package]]
|
||||
name = "faker"
|
||||
version = "40.1.2"
|
||||
description = "Faker is a Python package that generates fake data for you."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "faker-40.1.2-py3-none-any.whl", hash = "sha256:93503165c165d330260e4379fd6dc07c94da90c611ed3191a0174d2ab9966a42"},
|
||||
{file = "faker-40.1.2.tar.gz", hash = "sha256:b76a68163aa5f171d260fc24827a8349bc1db672f6a665359e8d0095e8135d30"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
tzdata = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
|
||||
[package.extras]
|
||||
tzdata = ["tzdata"]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.20.2"
|
||||
|
|
@ -1228,6 +1263,17 @@ check = ["check-manifest", "flake8", "flake8-black", "flake8-deprecated", "flake
|
|||
docs = ["docutils", "sphinx (>=5.0)"]
|
||||
test = ["pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "inflection"
|
||||
version = "0.5.1"
|
||||
description = "A port of Ruby on Rails inflector to Python"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
{file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"},
|
||||
{file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
|
|
@ -2894,6 +2940,24 @@ pytest = ">=7"
|
|||
[package.extras]
|
||||
testing = ["process-tests", "pytest-xdist", "virtualenv"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-factoryboy"
|
||||
version = "2.8.1"
|
||||
description = "Factory Boy support for pytest."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "pytest_factoryboy-2.8.1-py3-none-any.whl", hash = "sha256:91c762cb236bf34b11efdf2e54bafae33114488235621e8b2c4bd9fd77838784"},
|
||||
{file = "pytest_factoryboy-2.8.1.tar.gz", hash = "sha256:2221d48b31b8b8ccaa739c6a162fb50a43a4de6dff6043f249d2807a3462548d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
factory_boy = ">=2.10.0"
|
||||
inflection = "*"
|
||||
packaging = "*"
|
||||
pytest = ">=7.0"
|
||||
typing_extensions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pytest-flask"
|
||||
version = "1.3.0"
|
||||
|
|
@ -4482,6 +4546,17 @@ files = [
|
|||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.3"
|
||||
description = "Provider of IANA time zone data"
|
||||
optional = false
|
||||
python-versions = ">=2"
|
||||
files = [
|
||||
{file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"},
|
||||
{file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unidecode"
|
||||
version = "1.4.0"
|
||||
|
|
@ -4583,4 +4658,4 @@ web = ["flask", "flask-cors"]
|
|||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.10,<4"
|
||||
content-hash = "f8ce55ae74c5e3c5d1d330582f83dae30ef963a0b8dd8c8b79f16c3bcfdb525a"
|
||||
content-hash = "4217bac555b65078de110e4a112dec4124d129a87aa28a7239ad0db00ef80be0"
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ langdetect = "*"
|
|||
pylast = "*"
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
pytest-factoryboy = ">=2.8.1"
|
||||
pytest-flask = "*"
|
||||
python-mpd2 = "*"
|
||||
python3-discogs-client = ">=2.3.15"
|
||||
|
|
|
|||
|
|
@ -53,3 +53,12 @@ check_untyped_defs = true
|
|||
[[mypy-beets.metadata_plugins]]
|
||||
disallow_untyped_decorators = true
|
||||
check_untyped_defs = true
|
||||
|
||||
[[mypy-beetsplug.musicbrainz]]
|
||||
strict = true
|
||||
|
||||
[[mypy-beetsplug.mbpseudo]]
|
||||
strict = true
|
||||
|
||||
[[mypy-beetsplug._utils]]
|
||||
strict = true
|
||||
|
|
|
|||
260
test/plugins/factories/musicbrainz.py
Normal file
260
test/plugins/factories/musicbrainz.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import factory
|
||||
from factory.fuzzy import FuzzyChoice
|
||||
|
||||
from beetsplug._utils.musicbrainz import ArtistRelationType
|
||||
|
||||
|
||||
class _SortNameFactory(factory.DictFactory):
|
||||
name: str
|
||||
sort_name = factory.LazyAttribute(lambda o: f"{o.name}, The")
|
||||
|
||||
|
||||
class _PeriodFactory(factory.DictFactory):
|
||||
begin: str | None = None
|
||||
end: str | None = None
|
||||
ended = factory.LazyAttribute(lambda obj: obj.end is not None)
|
||||
|
||||
|
||||
class _IdFactory(factory.DictFactory):
|
||||
class Params:
|
||||
id_base = 0
|
||||
index = 1
|
||||
|
||||
id = factory.LazyAttribute(
|
||||
lambda o: f"00000000-0000-0000-0000-{o.id_base + o.index:012d}"
|
||||
)
|
||||
|
||||
|
||||
class AliasFactory(_SortNameFactory, _PeriodFactory):
|
||||
class Params:
|
||||
suffix = ""
|
||||
|
||||
locale: str | None = None
|
||||
name = factory.LazyAttribute(lambda o: f"Alias {o.suffix}")
|
||||
primary = False
|
||||
type = "Artist name"
|
||||
type_id = factory.LazyAttribute(
|
||||
lambda o: {
|
||||
"Artist name": "894afba6-2816-3c24-8072-eadb66bd04bc",
|
||||
"Label name": "3a1a0c48-d885-3b89-87b2-9e8a483c5675",
|
||||
"Legal name": "d4dcd0c0-b341-3612-a332-c0ce797b25cf",
|
||||
"Recording name": "5d564c8f-97de-3572-94bb-7f40ad661499",
|
||||
"Release group name": "156e24ca-8746-3cfc-99ae-0a867c765c67",
|
||||
"Release name": "df187855-059b-3514-9d5e-d240de0b4228",
|
||||
"Search hint": "abc2db8a-7386-354d-82f4-252c0213cbe4",
|
||||
}[o.type]
|
||||
)
|
||||
|
||||
|
||||
class ArtistFactory(_SortNameFactory, _IdFactory):
|
||||
class Params:
|
||||
id_base = 0
|
||||
|
||||
country: str | None = None
|
||||
disambiguation = ""
|
||||
name = "Artist"
|
||||
type = "Person"
|
||||
type_id = "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
|
||||
|
||||
class ArtistCreditFactory(factory.DictFactory):
|
||||
artist = factory.SubFactory(ArtistFactory)
|
||||
joinphrase = ""
|
||||
name = factory.LazyAttribute(lambda o: f"{o.artist['name']} Credit")
|
||||
|
||||
|
||||
class ArtistRelationFactory(_PeriodFactory):
|
||||
artist = factory.SubFactory(
|
||||
ArtistFactory,
|
||||
name=factory.LazyAttribute(
|
||||
lambda o: f"{o.factory_parent.type.capitalize()} Artist"
|
||||
),
|
||||
)
|
||||
attribute_ids = factory.Dict({})
|
||||
attribute_credits = factory.Dict({})
|
||||
attributes = factory.List([])
|
||||
direction = "backward"
|
||||
source_credit = ""
|
||||
target_credit = ""
|
||||
type = FuzzyChoice(ArtistRelationType.__args__) # type: ignore[attr-defined]
|
||||
type_id = factory.Faker("uuid4")
|
||||
|
||||
|
||||
class AreaFactory(factory.DictFactory):
|
||||
disambiguation = ""
|
||||
id = factory.Faker("uuid4")
|
||||
iso_3166_1_codes = factory.List([])
|
||||
iso_3166_2_codes = factory.List([])
|
||||
name = "Area"
|
||||
sort_name = "Area, The"
|
||||
type: None = None
|
||||
type_id: None = None
|
||||
|
||||
|
||||
class ReleaseEventFactory(factory.DictFactory):
|
||||
area = factory.SubFactory(AreaFactory)
|
||||
date = factory.Faker("date")
|
||||
|
||||
|
||||
class ReleaseGroupFactory(_IdFactory):
|
||||
class Params:
|
||||
id_base = 100
|
||||
|
||||
aliases = factory.List(
|
||||
[factory.SubFactory(AliasFactory, type="Release group name")]
|
||||
)
|
||||
artist_credit = factory.List([factory.SubFactory(ArtistCreditFactory)])
|
||||
disambiguation = factory.LazyAttribute(
|
||||
lambda o: f"{o.title} Disambiguation"
|
||||
)
|
||||
first_release_date = factory.Faker("date")
|
||||
genres = factory.List([])
|
||||
primary_type = "Album"
|
||||
primary_type_id = "f529b476-6e62-324f-b0aa-1f3e33d313fc"
|
||||
secondary_type_ids = factory.List([])
|
||||
secondary_types = factory.List([])
|
||||
tags = factory.List([])
|
||||
title = "Release Group"
|
||||
|
||||
|
||||
class GenreFactory(factory.DictFactory):
|
||||
id = factory.Faker("uuid4")
|
||||
count = 1
|
||||
disambiguation = ""
|
||||
name = "Genre"
|
||||
|
||||
|
||||
class TagFactory(GenreFactory):
|
||||
name = "Tag"
|
||||
|
||||
|
||||
class LabelFactory(_SortNameFactory, _IdFactory):
|
||||
aliases = factory.List([])
|
||||
disambiguation = ""
|
||||
genres = factory.List([])
|
||||
label_code: str | None = None
|
||||
name = "Label"
|
||||
tags = factory.List([])
|
||||
type = "Imprint"
|
||||
type_id = "b6285b2a-3514-3d43-80df-fcf528824ded"
|
||||
|
||||
|
||||
class LabelInfoFactory(factory.DictFactory):
|
||||
catalog_number = factory.LazyAttribute(
|
||||
lambda o: f"{o.label['name'][:3].upper()}123"
|
||||
)
|
||||
label = factory.SubFactory(LabelFactory)
|
||||
|
||||
|
||||
class TextRepresentationFactory(factory.DictFactory):
|
||||
language = "eng"
|
||||
script = "Latn"
|
||||
|
||||
|
||||
class RecordingFactory(_IdFactory):
|
||||
class Params:
|
||||
id_base = 1000
|
||||
|
||||
aliases = factory.List([])
|
||||
artist_credit = factory.List(
|
||||
[
|
||||
factory.SubFactory(
|
||||
ArtistCreditFactory, artist__name="Recording Artist"
|
||||
)
|
||||
]
|
||||
)
|
||||
disambiguation = ""
|
||||
isrcs = factory.List([])
|
||||
length = 360
|
||||
title = "Recording"
|
||||
video = False
|
||||
genres = factory.List([])
|
||||
tags = factory.List([])
|
||||
|
||||
|
||||
class TrackFactory(_IdFactory):
|
||||
class Params:
|
||||
id_base = 10000
|
||||
|
||||
artist_credit = factory.List([])
|
||||
length = factory.LazyAttribute(lambda o: o.recording["length"])
|
||||
number = "A1"
|
||||
position = 1
|
||||
recording = factory.SubFactory(RecordingFactory)
|
||||
title = factory.LazyAttribute(
|
||||
lambda o: f"{'Video: ' if o.recording['video'] else ''}{o.recording['title']}" # noqa: E501
|
||||
)
|
||||
|
||||
|
||||
class MediumFactory(_IdFactory):
|
||||
class Params:
|
||||
id_base = 100000
|
||||
|
||||
format = "Digital Media"
|
||||
format_id = "907a28d9-b3b2-3ef6-89a8-7b18d91d4794"
|
||||
position = 1
|
||||
title = "Medium"
|
||||
track_count = 1
|
||||
data_tracks = factory.List([])
|
||||
track_offset: int | None = None
|
||||
|
||||
@factory.post_generation
|
||||
def tracks(self, create, _tracks, **kwargs):
|
||||
if not create:
|
||||
return
|
||||
|
||||
if not _tracks:
|
||||
_tracks = [TrackFactory() for _ in range(kwargs.get("count", 1))]
|
||||
|
||||
for index, track in enumerate(_tracks, 1):
|
||||
track["position"] = index
|
||||
|
||||
self["tracks"] = _tracks # type: ignore[index]
|
||||
|
||||
|
||||
class ReleaseFactory(_IdFactory):
|
||||
class Params:
|
||||
id_base = 1000000
|
||||
|
||||
aliases = factory.List([])
|
||||
artist_credit = factory.List(
|
||||
[factory.SubFactory(ArtistCreditFactory, artist__id_base=10)]
|
||||
)
|
||||
asin = factory.LazyAttribute(lambda o: f"{o.title} Asin")
|
||||
barcode = "0000000000000"
|
||||
cover_art_archive = factory.Dict(
|
||||
{
|
||||
"artwork": False,
|
||||
"back": False,
|
||||
"count": 0,
|
||||
"darkened": False,
|
||||
"front": False,
|
||||
}
|
||||
)
|
||||
disambiguation = factory.LazyAttribute(
|
||||
lambda o: f"{o.title} Disambiguation"
|
||||
)
|
||||
genres = factory.List([factory.SubFactory(GenreFactory)])
|
||||
label_info = factory.List([factory.SubFactory(LabelInfoFactory)])
|
||||
media = factory.List([factory.SubFactory(MediumFactory)])
|
||||
packaging: str | None = None
|
||||
packaging_id: str | None = None
|
||||
quality = "normal"
|
||||
release_events = factory.List(
|
||||
[
|
||||
factory.SubFactory(
|
||||
ReleaseEventFactory, area=None, date="2021-03-26"
|
||||
),
|
||||
factory.SubFactory(
|
||||
ReleaseEventFactory,
|
||||
area__iso_3166_1_codes=["US"],
|
||||
date="2020-01-01",
|
||||
),
|
||||
]
|
||||
)
|
||||
release_group = factory.SubFactory(ReleaseGroupFactory)
|
||||
status = "Official"
|
||||
status_id = "4e304316-386d-3409-af2e-78857eec5cfe"
|
||||
tags = factory.List([factory.SubFactory(TagFactory)])
|
||||
text_representation = factory.SubFactory(TextRepresentationFactory)
|
||||
title = "Album"
|
||||
|
|
@ -163,7 +163,7 @@ class TestMBPseudoPlugin(TestMBPseudoMixin):
|
|||
official_release: JSONDict,
|
||||
json_key: str,
|
||||
):
|
||||
del official_release["release-relations"][0][json_key]
|
||||
del official_release["release_relations"][0][json_key]
|
||||
|
||||
album_info = mbpseudo_plugin.album_info(official_release)
|
||||
assert not isinstance(album_info, PseudoAlbumInfo)
|
||||
|
|
@ -174,8 +174,8 @@ class TestMBPseudoPlugin(TestMBPseudoMixin):
|
|||
mbpseudo_plugin: MusicBrainzPseudoReleasePlugin,
|
||||
official_release: JSONDict,
|
||||
):
|
||||
official_release["release-relations"][0]["release"][
|
||||
"text-representation"
|
||||
official_release["release_relations"][0]["release"][
|
||||
"text_representation"
|
||||
]["script"] = "Null"
|
||||
|
||||
album_info = mbpseudo_plugin.album_info(official_release)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,26 +1,44 @@
|
|||
from beetsplug._utils.musicbrainz import MusicBrainzAPI
|
||||
|
||||
|
||||
def test_group_relations():
|
||||
def test_normalize_data():
|
||||
raw_release = {
|
||||
"id": "r1",
|
||||
"relations": [
|
||||
{"target-type": "artist", "type": "vocal", "name": "A"},
|
||||
{"target-type": "url", "type": "streaming", "url": "http://s"},
|
||||
{"target-type": "url", "type": "purchase", "url": "http://p"},
|
||||
{
|
||||
"target-type": "artist",
|
||||
"type": "vocal",
|
||||
"type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa",
|
||||
"name": "A",
|
||||
},
|
||||
{
|
||||
"target-type": "url",
|
||||
"type": "streaming",
|
||||
"type-id": "b5f3058a-666c-406f-aafb-f9249fc7b122",
|
||||
"url": "http://s",
|
||||
},
|
||||
{
|
||||
"target-type": "url",
|
||||
"type": "purchase for download",
|
||||
"type-id": "92777657-504c-4acb-bd33-51a201bd57e1",
|
||||
"url": "http://p",
|
||||
},
|
||||
{
|
||||
"target-type": "work",
|
||||
"type": "performance",
|
||||
"type-id": "a3005666-a872-32c3-ad06-98af558e99b0",
|
||||
"work": {
|
||||
"relations": [
|
||||
{
|
||||
"artist": {"name": "幾田りら"},
|
||||
"target-type": "artist",
|
||||
"type": "composer",
|
||||
"type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f",
|
||||
},
|
||||
{
|
||||
"target-type": "url",
|
||||
"type": "lyrics",
|
||||
"type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
|
||||
"url": {
|
||||
"resource": "https://utaten.com/lyric/tt24121002/"
|
||||
},
|
||||
|
|
@ -29,10 +47,12 @@ def test_group_relations():
|
|||
"artist": {"name": "幾田りら"},
|
||||
"target-type": "artist",
|
||||
"type": "lyricist",
|
||||
"type-id": "3e48faba-ec01-47fd-8e89-30e81161661c",
|
||||
},
|
||||
{
|
||||
"target-type": "url",
|
||||
"type": "lyrics",
|
||||
"type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
|
||||
"url": {
|
||||
"resource": "https://www.uta-net.com/song/366579/"
|
||||
},
|
||||
|
|
@ -45,30 +65,59 @@ def test_group_relations():
|
|||
],
|
||||
}
|
||||
|
||||
assert MusicBrainzAPI._group_relations(raw_release) == {
|
||||
assert MusicBrainzAPI._normalize_data(raw_release) == {
|
||||
"id": "r1",
|
||||
"artist-relations": [{"type": "vocal", "name": "A"}],
|
||||
"url-relations": [
|
||||
{"type": "streaming", "url": "http://s"},
|
||||
{"type": "purchase", "url": "http://p"},
|
||||
"artist_relations": [
|
||||
{
|
||||
"type": "vocal",
|
||||
"type_id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa",
|
||||
"name": "A",
|
||||
}
|
||||
],
|
||||
"work-relations": [
|
||||
"url_relations": [
|
||||
{
|
||||
"type": "streaming",
|
||||
"type_id": "b5f3058a-666c-406f-aafb-f9249fc7b122",
|
||||
"url": "http://s",
|
||||
},
|
||||
{
|
||||
"type": "purchase for download",
|
||||
"type_id": "92777657-504c-4acb-bd33-51a201bd57e1",
|
||||
"url": "http://p",
|
||||
},
|
||||
],
|
||||
"work_relations": [
|
||||
{
|
||||
"type": "performance",
|
||||
"type_id": "a3005666-a872-32c3-ad06-98af558e99b0",
|
||||
"work": {
|
||||
"artist-relations": [
|
||||
{"type": "composer", "artist": {"name": "幾田りら"}},
|
||||
{"type": "lyricist", "artist": {"name": "幾田りら"}},
|
||||
"artist_relations": [
|
||||
{
|
||||
"type": "composer",
|
||||
"type_id": "d59d99ea-23d4-4a80-b066-edca32ee158f",
|
||||
"artist": {
|
||||
"name": "幾田りら",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "lyricist",
|
||||
"type_id": "3e48faba-ec01-47fd-8e89-30e81161661c",
|
||||
"artist": {
|
||||
"name": "幾田りら",
|
||||
},
|
||||
},
|
||||
],
|
||||
"url-relations": [
|
||||
"url_relations": [
|
||||
{
|
||||
"type": "lyrics",
|
||||
"type_id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
|
||||
"url": {
|
||||
"resource": "https://utaten.com/lyric/tt24121002/"
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "lyrics",
|
||||
"type_id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
|
||||
"url": {
|
||||
"resource": "https://www.uta-net.com/song/366579/"
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"aliases": [],
|
||||
"artist-credit": [
|
||||
"artist_credit": [
|
||||
{
|
||||
"artist": {
|
||||
"aliases": [
|
||||
|
|
@ -11,9 +11,9 @@
|
|||
"locale": "en",
|
||||
"name": "Lilas Ikuta",
|
||||
"primary": true,
|
||||
"sort-name": "Ikuta, Lilas",
|
||||
"sort_name": "Ikuta, Lilas",
|
||||
"type": "Artist name",
|
||||
"type-id": "894afba6-2816-3c24-8072-eadb66bd04bc"
|
||||
"type_id": "894afba6-2816-3c24-8072-eadb66bd04bc"
|
||||
}
|
||||
],
|
||||
"country": "JP",
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
],
|
||||
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
|
||||
"name": "幾田りら",
|
||||
"sort-name": "Ikuta, Lilas",
|
||||
"sort_name": "Ikuta, Lilas",
|
||||
"tags": [
|
||||
{
|
||||
"count": 1,
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
}
|
||||
],
|
||||
"type": "Person",
|
||||
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
},
|
||||
"joinphrase": "",
|
||||
"name": "Lilas Ikuta"
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
],
|
||||
"asin": null,
|
||||
"barcode": null,
|
||||
"cover-art-archive": {
|
||||
"cover_art_archive": {
|
||||
"artwork": false,
|
||||
"back": false,
|
||||
"count": 0,
|
||||
|
|
@ -64,19 +64,19 @@
|
|||
"disambiguation": "",
|
||||
"genres": [],
|
||||
"id": "dc3ee2df-0bc1-49eb-b8c4-34473d279a43",
|
||||
"label-info": [],
|
||||
"label_info": [],
|
||||
"media": [
|
||||
{
|
||||
"format": "Digital Media",
|
||||
"format-id": "907a28d9-b3b2-3ef6-89a8-7b18d91d4794",
|
||||
"format_id": "907a28d9-b3b2-3ef6-89a8-7b18d91d4794",
|
||||
"id": "606faab7-60fa-3a8b-a40f-2c66150cce81",
|
||||
"position": 1,
|
||||
"title": "",
|
||||
"track-count": 1,
|
||||
"track-offset": 0,
|
||||
"track_count": 1,
|
||||
"track_offset": 0,
|
||||
"tracks": [
|
||||
{
|
||||
"artist-credit": [
|
||||
"artist_credit": [
|
||||
{
|
||||
"artist": {
|
||||
"aliases": [
|
||||
|
|
@ -87,18 +87,18 @@
|
|||
"locale": "en",
|
||||
"name": "Lilas Ikuta",
|
||||
"primary": true,
|
||||
"sort-name": "Ikuta, Lilas",
|
||||
"sort_name": "Ikuta, Lilas",
|
||||
"type": "Artist name",
|
||||
"type-id": "894afba6-2816-3c24-8072-eadb66bd04bc"
|
||||
"type_id": "894afba6-2816-3c24-8072-eadb66bd04bc"
|
||||
}
|
||||
],
|
||||
"country": "JP",
|
||||
"disambiguation": "",
|
||||
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
|
||||
"name": "幾田りら",
|
||||
"sort-name": "Ikuta, Lilas",
|
||||
"sort_name": "Ikuta, Lilas",
|
||||
"type": "Person",
|
||||
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
},
|
||||
"joinphrase": "",
|
||||
"name": "Lilas Ikuta"
|
||||
|
|
@ -110,43 +110,43 @@
|
|||
"position": 1,
|
||||
"recording": {
|
||||
"aliases": [],
|
||||
"artist-credit": [
|
||||
"artist_credit": [
|
||||
{
|
||||
"artist": {
|
||||
"country": "JP",
|
||||
"disambiguation": "",
|
||||
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
|
||||
"name": "幾田りら",
|
||||
"sort-name": "Ikuta, Lilas",
|
||||
"sort_name": "Ikuta, Lilas",
|
||||
"type": "Person",
|
||||
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
},
|
||||
"joinphrase": "",
|
||||
"name": "幾田りら"
|
||||
}
|
||||
],
|
||||
"artist-relations": [
|
||||
"artist_relations": [
|
||||
{
|
||||
"artist": {
|
||||
"country": "JP",
|
||||
"disambiguation": "Japanese composer/arranger/guitarist, agehasprings",
|
||||
"id": "f24241fb-4d89-4bf2-8336-3f2a7d2c0025",
|
||||
"name": "KOHD",
|
||||
"sort-name": "KOHD",
|
||||
"sort_name": "KOHD",
|
||||
"type": "Person",
|
||||
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
},
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": null,
|
||||
"direction": "backward",
|
||||
"end": null,
|
||||
"ended": false,
|
||||
"source-credit": "",
|
||||
"target-credit": "",
|
||||
"source_credit": "",
|
||||
"target_credit": "",
|
||||
"type": "arranger",
|
||||
"type-id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d"
|
||||
"type_id": "22661fb8-cdb7-4f67-8385-b2a8be6c9f0d"
|
||||
},
|
||||
{
|
||||
"artist": {
|
||||
|
|
@ -154,21 +154,21 @@
|
|||
"disambiguation": "",
|
||||
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
|
||||
"name": "幾田りら",
|
||||
"sort-name": "Ikuta, Lilas",
|
||||
"sort_name": "Ikuta, Lilas",
|
||||
"type": "Person",
|
||||
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
},
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": "2025",
|
||||
"direction": "backward",
|
||||
"end": "2025",
|
||||
"ended": true,
|
||||
"source-credit": "",
|
||||
"target-credit": "Lilas Ikuta",
|
||||
"source_credit": "",
|
||||
"target_credit": "Lilas Ikuta",
|
||||
"type": "phonographic copyright",
|
||||
"type-id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30"
|
||||
"type_id": "7fd5fbc0-fbf4-4d04-be23-417d50a4dc30"
|
||||
},
|
||||
{
|
||||
"artist": {
|
||||
|
|
@ -176,21 +176,21 @@
|
|||
"disambiguation": "",
|
||||
"id": "1d27ab8a-a0df-47cf-b4cc-d2d7a0712a05",
|
||||
"name": "山本秀哉",
|
||||
"sort-name": "Yamamoto, Shuya",
|
||||
"sort_name": "Yamamoto, Shuya",
|
||||
"type": "Person",
|
||||
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
},
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": null,
|
||||
"direction": "backward",
|
||||
"end": null,
|
||||
"ended": false,
|
||||
"source-credit": "",
|
||||
"target-credit": "",
|
||||
"source_credit": "",
|
||||
"target_credit": "",
|
||||
"type": "producer",
|
||||
"type-id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0"
|
||||
"type_id": "5c0ceac3-feb4-41f0-868d-dc06f6e27fc0"
|
||||
},
|
||||
{
|
||||
"artist": {
|
||||
|
|
@ -198,25 +198,25 @@
|
|||
"disambiguation": "",
|
||||
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
|
||||
"name": "幾田りら",
|
||||
"sort-name": "Ikuta, Lilas",
|
||||
"sort_name": "Ikuta, Lilas",
|
||||
"type": "Person",
|
||||
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
},
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": null,
|
||||
"direction": "backward",
|
||||
"end": null,
|
||||
"ended": false,
|
||||
"source-credit": "",
|
||||
"target-credit": "",
|
||||
"source_credit": "",
|
||||
"target_credit": "",
|
||||
"type": "vocal",
|
||||
"type-id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa"
|
||||
"type_id": "0fdbe3c6-7700-4a31-ae54-b53f06ae1cfa"
|
||||
}
|
||||
],
|
||||
"disambiguation": "",
|
||||
"first-release-date": "2025-01-10",
|
||||
"first_release_date": "2025-01-10",
|
||||
"genres": [],
|
||||
"id": "781724c1-a039-41e6-bd9b-770c3b9d5b8e",
|
||||
"isrcs": [
|
||||
|
|
@ -225,53 +225,53 @@
|
|||
"length": 179546,
|
||||
"tags": [],
|
||||
"title": "百花繚乱",
|
||||
"url-relations": [
|
||||
"url_relations": [
|
||||
{
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": null,
|
||||
"direction": "forward",
|
||||
"end": null,
|
||||
"ended": false,
|
||||
"source-credit": "",
|
||||
"target-credit": "",
|
||||
"source_credit": "",
|
||||
"target_credit": "",
|
||||
"type": "free streaming",
|
||||
"type-id": "7e41ef12-a124-4324-afdb-fdbae687a89c",
|
||||
"type_id": "7e41ef12-a124-4324-afdb-fdbae687a89c",
|
||||
"url": {
|
||||
"id": "d076eaf9-5fde-4f6e-a946-cde16b67aa3b",
|
||||
"resource": "https://open.spotify.com/track/782PTXsbAWB70ySDZ5NHmP"
|
||||
}
|
||||
},
|
||||
{
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": null,
|
||||
"direction": "forward",
|
||||
"end": null,
|
||||
"ended": false,
|
||||
"source-credit": "",
|
||||
"target-credit": "",
|
||||
"source_credit": "",
|
||||
"target_credit": "",
|
||||
"type": "purchase for download",
|
||||
"type-id": "92777657-504c-4acb-bd33-51a201bd57e1",
|
||||
"type_id": "92777657-504c-4acb-bd33-51a201bd57e1",
|
||||
"url": {
|
||||
"id": "64879627-6eca-4755-98b5-b2234a8dbc61",
|
||||
"resource": "https://music.apple.com/jp/song/1857886416"
|
||||
}
|
||||
},
|
||||
{
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": null,
|
||||
"direction": "forward",
|
||||
"end": null,
|
||||
"ended": false,
|
||||
"source-credit": "",
|
||||
"target-credit": "",
|
||||
"source_credit": "",
|
||||
"target_credit": "",
|
||||
"type": "streaming",
|
||||
"type-id": "b5f3058a-666c-406f-aafb-f9249fc7b122",
|
||||
"type_id": "b5f3058a-666c-406f-aafb-f9249fc7b122",
|
||||
"url": {
|
||||
"id": "64879627-6eca-4755-98b5-b2234a8dbc61",
|
||||
"resource": "https://music.apple.com/jp/song/1857886416"
|
||||
|
|
@ -279,42 +279,42 @@
|
|||
}
|
||||
],
|
||||
"video": false,
|
||||
"work-relations": [
|
||||
"work_relations": [
|
||||
{
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": null,
|
||||
"direction": "forward",
|
||||
"end": null,
|
||||
"ended": false,
|
||||
"source-credit": "",
|
||||
"target-credit": "",
|
||||
"source_credit": "",
|
||||
"target_credit": "",
|
||||
"type": "performance",
|
||||
"type-id": "a3005666-a872-32c3-ad06-98af558e99b0",
|
||||
"type_id": "a3005666-a872-32c3-ad06-98af558e99b0",
|
||||
"work": {
|
||||
"artist-relations": [
|
||||
"artist_relations": [
|
||||
{
|
||||
"artist": {
|
||||
"country": "JP",
|
||||
"disambiguation": "",
|
||||
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
|
||||
"name": "幾田りら",
|
||||
"sort-name": "Ikuta, Lilas",
|
||||
"sort_name": "Ikuta, Lilas",
|
||||
"type": "Person",
|
||||
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
},
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": null,
|
||||
"direction": "backward",
|
||||
"end": null,
|
||||
"ended": false,
|
||||
"source-credit": "",
|
||||
"target-credit": "",
|
||||
"source_credit": "",
|
||||
"target_credit": "",
|
||||
"type": "composer",
|
||||
"type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f"
|
||||
"type_id": "d59d99ea-23d4-4a80-b066-edca32ee158f"
|
||||
},
|
||||
{
|
||||
"artist": {
|
||||
|
|
@ -322,21 +322,21 @@
|
|||
"disambiguation": "",
|
||||
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
|
||||
"name": "幾田りら",
|
||||
"sort-name": "Ikuta, Lilas",
|
||||
"sort_name": "Ikuta, Lilas",
|
||||
"type": "Person",
|
||||
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
},
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": null,
|
||||
"direction": "backward",
|
||||
"end": null,
|
||||
"ended": false,
|
||||
"source-credit": "",
|
||||
"target-credit": "",
|
||||
"source_credit": "",
|
||||
"target_credit": "",
|
||||
"type": "lyricist",
|
||||
"type-id": "3e48faba-ec01-47fd-8e89-30e81161661c"
|
||||
"type_id": "3e48faba-ec01-47fd-8e89-30e81161661c"
|
||||
}
|
||||
],
|
||||
"attributes": [],
|
||||
|
|
@ -349,37 +349,37 @@
|
|||
],
|
||||
"title": "百花繚乱",
|
||||
"type": "Song",
|
||||
"type-id": "f061270a-2fd6-32f1-a641-f0f8676d14e6",
|
||||
"url-relations": [
|
||||
"type_id": "f061270a-2fd6-32f1-a641-f0f8676d14e6",
|
||||
"url_relations": [
|
||||
{
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": null,
|
||||
"direction": "backward",
|
||||
"end": null,
|
||||
"ended": false,
|
||||
"source-credit": "",
|
||||
"target-credit": "",
|
||||
"source_credit": "",
|
||||
"target_credit": "",
|
||||
"type": "lyrics",
|
||||
"type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
|
||||
"type_id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
|
||||
"url": {
|
||||
"id": "dfac3640-6b23-4991-a59c-7cb80e8eb950",
|
||||
"resource": "https://utaten.com/lyric/tt24121002/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": null,
|
||||
"direction": "backward",
|
||||
"end": null,
|
||||
"ended": false,
|
||||
"source-credit": "",
|
||||
"target-credit": "",
|
||||
"source_credit": "",
|
||||
"target_credit": "",
|
||||
"type": "lyrics",
|
||||
"type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
|
||||
"type_id": "e38e65aa-75e0-42ba-ace0-072aeb91a538",
|
||||
"url": {
|
||||
"id": "b1b5d5df-e79d-4cda-bb2a-8014e5505415",
|
||||
"resource": "https://www.uta-net.com/song/366579/"
|
||||
|
|
@ -396,11 +396,11 @@
|
|||
}
|
||||
],
|
||||
"packaging": null,
|
||||
"packaging-id": null,
|
||||
"packaging_id": null,
|
||||
"quality": "normal",
|
||||
"release-group": {
|
||||
"release_group": {
|
||||
"aliases": [],
|
||||
"artist-credit": [
|
||||
"artist_credit": [
|
||||
{
|
||||
"artist": {
|
||||
"aliases": [
|
||||
|
|
@ -411,54 +411,54 @@
|
|||
"locale": "en",
|
||||
"name": "Lilas Ikuta",
|
||||
"primary": true,
|
||||
"sort-name": "Ikuta, Lilas",
|
||||
"sort_name": "Ikuta, Lilas",
|
||||
"type": "Artist name",
|
||||
"type-id": "894afba6-2816-3c24-8072-eadb66bd04bc"
|
||||
"type_id": "894afba6-2816-3c24-8072-eadb66bd04bc"
|
||||
}
|
||||
],
|
||||
"country": "JP",
|
||||
"disambiguation": "",
|
||||
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
|
||||
"name": "幾田りら",
|
||||
"sort-name": "Ikuta, Lilas",
|
||||
"sort_name": "Ikuta, Lilas",
|
||||
"type": "Person",
|
||||
"type-id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
"type_id": "b6e035f4-3ce9-331c-97df-83397230b0df"
|
||||
},
|
||||
"joinphrase": "",
|
||||
"name": "幾田りら"
|
||||
}
|
||||
],
|
||||
"disambiguation": "",
|
||||
"first-release-date": "2025-01-10",
|
||||
"first_release_date": "2025-01-10",
|
||||
"genres": [],
|
||||
"id": "da0d6bbb-f44b-4fff-8739-9d72db0402a1",
|
||||
"primary-type": "Single",
|
||||
"primary-type-id": "d6038452-8ee0-3f68-affc-2de9a1ede0b9",
|
||||
"secondary-type-ids": [],
|
||||
"secondary-types": [],
|
||||
"primary_type": "Single",
|
||||
"primary_type_id": "d6038452-8ee0-3f68-affc-2de9a1ede0b9",
|
||||
"secondary_type_ids": [],
|
||||
"secondary_types": [],
|
||||
"tags": [],
|
||||
"title": "百花繚乱"
|
||||
},
|
||||
"release-relations": [
|
||||
"release_relations": [
|
||||
{
|
||||
"attribute-ids": {},
|
||||
"attribute-values": {},
|
||||
"attribute_ids": {},
|
||||
"attribute_values": {},
|
||||
"attributes": [],
|
||||
"begin": null,
|
||||
"direction": "backward",
|
||||
"end": null,
|
||||
"ended": false,
|
||||
"release": {
|
||||
"artist-credit": [
|
||||
"artist_credit": [
|
||||
{
|
||||
"artist": {
|
||||
"country": "JP",
|
||||
"disambiguation": "",
|
||||
"id": "55e42264-ef27-49d8-93fd-29f930dc96e4",
|
||||
"name": "幾田りら",
|
||||
"sort-name": "Ikuta, Lilas",
|
||||
"sort_name": "Ikuta, Lilas",
|
||||
"type": null,
|
||||
"type-id": null
|
||||
"type_id": null
|
||||
},
|
||||
"joinphrase": "",
|
||||
"name": "幾田りら"
|
||||
|
|
@ -471,43 +471,43 @@
|
|||
"id": "a5ce1d11-2e32-45a4-b37f-c1589d46b103",
|
||||
"media": [],
|
||||
"packaging": null,
|
||||
"packaging-id": null,
|
||||
"packaging_id": null,
|
||||
"quality": "normal",
|
||||
"release-events": [
|
||||
"release_events": [
|
||||
{
|
||||
"area": {
|
||||
"disambiguation": "",
|
||||
"id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
|
||||
"iso-3166-1-codes": [
|
||||
"iso_3166_1_codes": [
|
||||
"XW"
|
||||
],
|
||||
"name": "[Worldwide]",
|
||||
"sort-name": "[Worldwide]",
|
||||
"sort_name": "[Worldwide]",
|
||||
"type": null,
|
||||
"type-id": null
|
||||
"type_id": null
|
||||
},
|
||||
"date": "2025-01-10"
|
||||
}
|
||||
],
|
||||
"release-group": null,
|
||||
"release_group": null,
|
||||
"status": null,
|
||||
"status-id": null,
|
||||
"text-representation": {
|
||||
"status_id": null,
|
||||
"text_representation": {
|
||||
"language": "jpn",
|
||||
"script": "Jpan"
|
||||
},
|
||||
"title": "百花繚乱"
|
||||
},
|
||||
"source-credit": "",
|
||||
"target-credit": "",
|
||||
"source_credit": "",
|
||||
"target_credit": "",
|
||||
"type": "transl-tracklisting",
|
||||
"type-id": "fc399d47-23a7-4c28-bfcf-0607a562b644"
|
||||
"type_id": "fc399d47-23a7-4c28-bfcf-0607a562b644"
|
||||
}
|
||||
],
|
||||
"status": "Pseudo-Release",
|
||||
"status-id": "41121bb9-3413-3818-8a9a-9742318349aa",
|
||||
"status_id": "41121bb9-3413-3818-8a9a-9742318349aa",
|
||||
"tags": [],
|
||||
"text-representation": {
|
||||
"text_representation": {
|
||||
"language": "eng",
|
||||
"script": "Latn"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue