mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 16:42:42 +01:00
Discogs: artist name variation support + artist credit support. (#6050)
Implements #3354, updates #6040. This PR also improves typehints for the discogs plugin. The name variations provided by Discogs can now be used by the auto-tagger through 3 config options. By default, the plugin will write the variation to the tag `artist_credit`, but through three config options can also write it to the album artist tag, the track's artist tag, or any combination of the three. This PR contains a small addition on #6040, letting the string used to join featured artists be customized. The new configuration options available, and their defaults, are as follows: ```yaml discogs: featured_string: "Feat." anv: artist_credit: True album_artist: False artist: False ```
This commit is contained in:
commit
08d9f34df5
4 changed files with 332 additions and 74 deletions
|
|
@ -27,13 +27,13 @@ import time
|
||||||
import traceback
|
import traceback
|
||||||
from functools import cache
|
from functools import cache
|
||||||
from string import ascii_lowercase
|
from string import ascii_lowercase
|
||||||
from typing import TYPE_CHECKING, Sequence
|
from typing import TYPE_CHECKING, Sequence, cast
|
||||||
|
|
||||||
import confuse
|
import confuse
|
||||||
from discogs_client import Client, Master, Release
|
from discogs_client import Client, Master, Release
|
||||||
from discogs_client.exceptions import DiscogsAPIError
|
from discogs_client.exceptions import DiscogsAPIError
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import NotRequired, TypedDict
|
||||||
|
|
||||||
import beets
|
import beets
|
||||||
import beets.ui
|
import beets.ui
|
||||||
|
|
@ -85,6 +85,42 @@ class ReleaseFormat(TypedDict):
|
||||||
descriptions: list[str] | None
|
descriptions: list[str] | None
|
||||||
|
|
||||||
|
|
||||||
|
class Artist(TypedDict):
|
||||||
|
name: str
|
||||||
|
anv: str
|
||||||
|
join: str
|
||||||
|
role: str
|
||||||
|
tracks: str
|
||||||
|
id: str
|
||||||
|
resource_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class Track(TypedDict):
|
||||||
|
position: str
|
||||||
|
type_: str
|
||||||
|
title: str
|
||||||
|
duration: str
|
||||||
|
artists: list[Artist]
|
||||||
|
extraartists: NotRequired[list[Artist]]
|
||||||
|
|
||||||
|
|
||||||
|
class TrackWithSubtracks(Track):
|
||||||
|
sub_tracks: list[TrackWithSubtracks]
|
||||||
|
|
||||||
|
|
||||||
|
class IntermediateTrackInfo(TrackInfo):
|
||||||
|
"""Allows work with string mediums from
|
||||||
|
get_track_info"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
medium_str: str | None,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
self.medium_str = medium_str
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class DiscogsPlugin(MetadataSourcePlugin):
|
class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -97,8 +133,14 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
"user_token": "",
|
"user_token": "",
|
||||||
"separator": ", ",
|
"separator": ", ",
|
||||||
"index_tracks": False,
|
"index_tracks": False,
|
||||||
|
"featured_string": "Feat.",
|
||||||
"append_style_genre": False,
|
"append_style_genre": False,
|
||||||
"strip_disambiguation": True,
|
"strip_disambiguation": True,
|
||||||
|
"anv": {
|
||||||
|
"artist_credit": True,
|
||||||
|
"artist": False,
|
||||||
|
"album_artist": False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.config["apikey"].redact = True
|
self.config["apikey"].redact = True
|
||||||
|
|
@ -106,7 +148,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
self.config["user_token"].redact = True
|
self.config["user_token"].redact = True
|
||||||
self.setup()
|
self.setup()
|
||||||
|
|
||||||
def setup(self, session=None):
|
def setup(self, session=None) -> None:
|
||||||
"""Create the `discogs_client` field. Authenticate if necessary."""
|
"""Create the `discogs_client` field. Authenticate if necessary."""
|
||||||
c_key = self.config["apikey"].as_str()
|
c_key = self.config["apikey"].as_str()
|
||||||
c_secret = self.config["apisecret"].as_str()
|
c_secret = self.config["apisecret"].as_str()
|
||||||
|
|
@ -132,16 +174,16 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
|
|
||||||
self.discogs_client = Client(USER_AGENT, c_key, c_secret, token, secret)
|
self.discogs_client = Client(USER_AGENT, c_key, c_secret, token, secret)
|
||||||
|
|
||||||
def reset_auth(self):
|
def reset_auth(self) -> None:
|
||||||
"""Delete token file & redo the auth steps."""
|
"""Delete token file & redo the auth steps."""
|
||||||
os.remove(self._tokenfile())
|
os.remove(self._tokenfile())
|
||||||
self.setup()
|
self.setup()
|
||||||
|
|
||||||
def _tokenfile(self):
|
def _tokenfile(self) -> str:
|
||||||
"""Get the path to the JSON file for storing the OAuth token."""
|
"""Get the path to the JSON file for storing the OAuth token."""
|
||||||
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
|
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
|
||||||
|
|
||||||
def authenticate(self, c_key, c_secret):
|
def authenticate(self, c_key: str, c_secret: str) -> tuple[str, str]:
|
||||||
# Get the link for the OAuth page.
|
# Get the link for the OAuth page.
|
||||||
auth_client = Client(USER_AGENT, c_key, c_secret)
|
auth_client = Client(USER_AGENT, c_key, c_secret)
|
||||||
try:
|
try:
|
||||||
|
|
@ -302,7 +344,26 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
|
|
||||||
return media, albumtype
|
return media, albumtype
|
||||||
|
|
||||||
def get_album_info(self, result):
|
def get_artist_with_anv(
|
||||||
|
self, artists: list[Artist], use_anv: bool = False
|
||||||
|
) -> tuple[str, str | None]:
|
||||||
|
"""Iterates through a discogs result, fetching data
|
||||||
|
if the artist anv is to be used, maps that to the name.
|
||||||
|
Calls the parent class get_artist method."""
|
||||||
|
artist_list: list[dict[str | int, str]] = []
|
||||||
|
for artist_data in artists:
|
||||||
|
a: dict[str | int, str] = {
|
||||||
|
"name": artist_data["name"],
|
||||||
|
"id": artist_data["id"],
|
||||||
|
"join": artist_data.get("join", ""),
|
||||||
|
}
|
||||||
|
if use_anv and (anv := artist_data.get("anv", "")):
|
||||||
|
a["name"] = anv
|
||||||
|
artist_list.append(a)
|
||||||
|
artist, artist_id = self.get_artist(artist_list, join_key="join")
|
||||||
|
return self.strip_disambiguation(artist), artist_id
|
||||||
|
|
||||||
|
def get_album_info(self, result: Release) -> AlbumInfo | None:
|
||||||
"""Returns an AlbumInfo object for a discogs Release object."""
|
"""Returns an AlbumInfo object for a discogs Release object."""
|
||||||
# Explicitly reload the `Release` fields, as they might not be yet
|
# Explicitly reload the `Release` fields, as they might not be yet
|
||||||
# present if the result is from a `discogs_client.search()`.
|
# present if the result is from a `discogs_client.search()`.
|
||||||
|
|
@ -330,16 +391,29 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
self._log.warning("Release does not contain the required fields")
|
self._log.warning("Release does not contain the required fields")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
artist, artist_id = self.get_artist(
|
artist_data = [a.data for a in result.artists]
|
||||||
[a.data for a in result.artists], join_key="join"
|
album_artist, album_artist_id = self.get_artist_with_anv(artist_data)
|
||||||
|
album_artist_anv, _ = self.get_artist_with_anv(
|
||||||
|
artist_data, use_anv=True
|
||||||
)
|
)
|
||||||
|
artist_credit = album_artist_anv
|
||||||
|
|
||||||
album = re.sub(r" +", " ", result.title)
|
album = re.sub(r" +", " ", result.title)
|
||||||
album_id = result.data["id"]
|
album_id = result.data["id"]
|
||||||
# Use `.data` to access the tracklist directly instead of the
|
# Use `.data` to access the tracklist directly instead of the
|
||||||
# convenient `.tracklist` property, which will strip out useful artist
|
# convenient `.tracklist` property, which will strip out useful artist
|
||||||
# information and leave us with skeleton `Artist` objects that will
|
# information and leave us with skeleton `Artist` objects that will
|
||||||
# each make an API call just to get the same data back.
|
# each make an API call just to get the same data back.
|
||||||
tracks = self.get_tracks(result.data["tracklist"], artist, artist_id)
|
tracks = self.get_tracks(
|
||||||
|
result.data["tracklist"],
|
||||||
|
(album_artist, album_artist_anv, album_artist_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assign ANV to the proper fields for tagging
|
||||||
|
if not self.config["anv"]["artist_credit"]:
|
||||||
|
artist_credit = album_artist
|
||||||
|
if self.config["anv"]["album_artist"]:
|
||||||
|
album_artist = album_artist_anv
|
||||||
|
|
||||||
# Extract information for the optional AlbumInfo fields, if possible.
|
# Extract information for the optional AlbumInfo fields, if possible.
|
||||||
va = result.data["artists"][0].get("name", "").lower() == "various"
|
va = result.data["artists"][0].get("name", "").lower() == "various"
|
||||||
|
|
@ -376,9 +450,9 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
# Additional cleanups
|
# Additional cleanups
|
||||||
# (various artists name, catalog number, media, disambiguation).
|
# (various artists name, catalog number, media, disambiguation).
|
||||||
if va:
|
if va:
|
||||||
artist = config["va_name"].as_str()
|
va_name = config["va_name"].as_str()
|
||||||
else:
|
album_artist = va_name
|
||||||
artist = self.strip_disambiguation(artist)
|
artist_credit = va_name
|
||||||
if catalogno == "none":
|
if catalogno == "none":
|
||||||
catalogno = None
|
catalogno = None
|
||||||
# Explicitly set the `media` for the tracks, since it is expected by
|
# Explicitly set the `media` for the tracks, since it is expected by
|
||||||
|
|
@ -401,8 +475,9 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
return AlbumInfo(
|
return AlbumInfo(
|
||||||
album=album,
|
album=album,
|
||||||
album_id=album_id,
|
album_id=album_id,
|
||||||
artist=artist,
|
artist=album_artist,
|
||||||
artist_id=artist_id,
|
artist_credit=artist_credit,
|
||||||
|
artist_id=album_artist_id,
|
||||||
tracks=tracks,
|
tracks=tracks,
|
||||||
albumtype=albumtype,
|
albumtype=albumtype,
|
||||||
va=va,
|
va=va,
|
||||||
|
|
@ -420,11 +495,11 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
data_url=data_url,
|
data_url=data_url,
|
||||||
discogs_albumid=discogs_albumid,
|
discogs_albumid=discogs_albumid,
|
||||||
discogs_labelid=labelid,
|
discogs_labelid=labelid,
|
||||||
discogs_artistid=artist_id,
|
discogs_artistid=album_artist_id,
|
||||||
cover_art_url=cover_art_url,
|
cover_art_url=cover_art_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
def select_cover_art(self, result):
|
def select_cover_art(self, result: Release) -> str | None:
|
||||||
"""Returns the best candidate image, if any, from a Discogs `Release` object."""
|
"""Returns the best candidate image, if any, from a Discogs `Release` object."""
|
||||||
if result.data.get("images") and len(result.data.get("images")) > 0:
|
if result.data.get("images") and len(result.data.get("images")) > 0:
|
||||||
# The first image in this list appears to be the one displayed first
|
# The first image in this list appears to be the one displayed first
|
||||||
|
|
@ -434,7 +509,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def format(self, classification):
|
def format(self, classification: Iterable[str]) -> str | None:
|
||||||
if classification:
|
if classification:
|
||||||
return (
|
return (
|
||||||
self.config["separator"].as_str().join(sorted(classification))
|
self.config["separator"].as_str().join(sorted(classification))
|
||||||
|
|
@ -442,22 +517,17 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_tracks(self, tracklist, album_artist, album_artist_id):
|
def _process_clean_tracklist(
|
||||||
"""Returns a list of TrackInfo objects for a discogs tracklist."""
|
self,
|
||||||
try:
|
clean_tracklist: list[Track],
|
||||||
clean_tracklist = self.coalesce_tracks(tracklist)
|
album_artist_data: tuple[str, str, str | None],
|
||||||
except Exception as exc:
|
) -> tuple[list[TrackInfo], dict[int, str], int, list[str], list[str]]:
|
||||||
# FIXME: this is an extra precaution for making sure there are no
|
# Distinct works and intra-work divisions, as defined by index tracks.
|
||||||
# side effects after #2222. It should be removed after further
|
tracks: list[TrackInfo] = []
|
||||||
# testing.
|
|
||||||
self._log.debug("{}", traceback.format_exc())
|
|
||||||
self._log.error("uncaught exception in coalesce_tracks: {}", exc)
|
|
||||||
clean_tracklist = tracklist
|
|
||||||
tracks = []
|
|
||||||
index_tracks = {}
|
index_tracks = {}
|
||||||
index = 0
|
index = 0
|
||||||
# Distinct works and intra-work divisions, as defined by index tracks.
|
divisions: list[str] = []
|
||||||
divisions, next_divisions = [], []
|
next_divisions: list[str] = []
|
||||||
for track in clean_tracklist:
|
for track in clean_tracklist:
|
||||||
# Only real tracks have `position`. Otherwise, it's an index track.
|
# Only real tracks have `position`. Otherwise, it's an index track.
|
||||||
if track["position"]:
|
if track["position"]:
|
||||||
|
|
@ -468,7 +538,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
divisions += next_divisions
|
divisions += next_divisions
|
||||||
del next_divisions[:]
|
del next_divisions[:]
|
||||||
track_info = self.get_track_info(
|
track_info = self.get_track_info(
|
||||||
track, index, divisions, album_artist, album_artist_id
|
track, index, divisions, album_artist_data
|
||||||
)
|
)
|
||||||
track_info.track_alt = track["position"]
|
track_info.track_alt = track["position"]
|
||||||
tracks.append(track_info)
|
tracks.append(track_info)
|
||||||
|
|
@ -481,7 +551,29 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
except IndexError:
|
except IndexError:
|
||||||
pass
|
pass
|
||||||
index_tracks[index + 1] = track["title"]
|
index_tracks[index + 1] = track["title"]
|
||||||
|
return tracks, index_tracks, index, divisions, next_divisions
|
||||||
|
|
||||||
|
def get_tracks(
|
||||||
|
self,
|
||||||
|
tracklist: list[Track],
|
||||||
|
album_artist_data: tuple[str, str, str | None],
|
||||||
|
) -> list[TrackInfo]:
|
||||||
|
"""Returns a list of TrackInfo objects for a discogs tracklist."""
|
||||||
|
try:
|
||||||
|
clean_tracklist: list[Track] = self.coalesce_tracks(
|
||||||
|
cast(list[TrackWithSubtracks], tracklist)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# FIXME: this is an extra precaution for making sure there are no
|
||||||
|
# side effects after #2222. It should be removed after further
|
||||||
|
# testing.
|
||||||
|
self._log.debug("{}", traceback.format_exc())
|
||||||
|
self._log.error("uncaught exception in coalesce_tracks: {}", exc)
|
||||||
|
clean_tracklist = tracklist
|
||||||
|
processed = self._process_clean_tracklist(
|
||||||
|
clean_tracklist, album_artist_data
|
||||||
|
)
|
||||||
|
tracks, index_tracks, index, divisions, next_divisions = processed
|
||||||
# Fix up medium and medium_index for each track. Discogs position is
|
# Fix up medium and medium_index for each track. Discogs position is
|
||||||
# unreliable, but tracks are in order.
|
# unreliable, but tracks are in order.
|
||||||
medium = None
|
medium = None
|
||||||
|
|
@ -490,8 +582,8 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
|
|
||||||
# If a medium has two sides (ie. vinyl or cassette), each pair of
|
# If a medium has two sides (ie. vinyl or cassette), each pair of
|
||||||
# consecutive sides should belong to the same medium.
|
# consecutive sides should belong to the same medium.
|
||||||
if all([track.medium is not None for track in tracks]):
|
if all([track.medium_str is not None for track in tracks]):
|
||||||
m = sorted({track.medium.lower() for track in tracks})
|
m = sorted({track.medium_str.lower() for track in tracks})
|
||||||
# If all track.medium are single consecutive letters, assume it is
|
# If all track.medium are single consecutive letters, assume it is
|
||||||
# a 2-sided medium.
|
# a 2-sided medium.
|
||||||
if "".join(m) in ascii_lowercase:
|
if "".join(m) in ascii_lowercase:
|
||||||
|
|
@ -505,17 +597,17 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
# side_count is the number of mediums or medium sides (in the case
|
# side_count is the number of mediums or medium sides (in the case
|
||||||
# of two-sided mediums) that were seen before.
|
# of two-sided mediums) that were seen before.
|
||||||
medium_is_index = (
|
medium_is_index = (
|
||||||
track.medium
|
track.medium_str
|
||||||
and not track.medium_index
|
and not track.medium_index
|
||||||
and (
|
and (
|
||||||
len(track.medium) != 1
|
len(track.medium_str) != 1
|
||||||
or
|
or
|
||||||
# Not within standard incremental medium values (A, B, C, ...).
|
# Not within standard incremental medium values (A, B, C, ...).
|
||||||
ord(track.medium) - 64 != side_count + 1
|
ord(track.medium_str) - 64 != side_count + 1
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not medium_is_index and medium != track.medium:
|
if not medium_is_index and medium != track.medium_str:
|
||||||
side_count += 1
|
side_count += 1
|
||||||
if sides_per_medium == 2:
|
if sides_per_medium == 2:
|
||||||
if side_count % sides_per_medium:
|
if side_count % sides_per_medium:
|
||||||
|
|
@ -526,7 +618,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
# Medium changed. Reset index_count.
|
# Medium changed. Reset index_count.
|
||||||
medium_count += 1
|
medium_count += 1
|
||||||
index_count = 0
|
index_count = 0
|
||||||
medium = track.medium
|
medium = track.medium_str
|
||||||
|
|
||||||
index_count += 1
|
index_count += 1
|
||||||
medium_count = 1 if medium_count == 0 else medium_count
|
medium_count = 1 if medium_count == 0 else medium_count
|
||||||
|
|
@ -542,15 +634,20 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
disctitle = None
|
disctitle = None
|
||||||
track.disctitle = disctitle
|
track.disctitle = disctitle
|
||||||
|
|
||||||
return tracks
|
return cast(list[TrackInfo], tracks)
|
||||||
|
|
||||||
def coalesce_tracks(self, raw_tracklist):
|
def coalesce_tracks(
|
||||||
|
self, raw_tracklist: list[TrackWithSubtracks]
|
||||||
|
) -> list[Track]:
|
||||||
"""Pre-process a tracklist, merging subtracks into a single track. The
|
"""Pre-process a tracklist, merging subtracks into a single track. The
|
||||||
title for the merged track is the one from the previous index track,
|
title for the merged track is the one from the previous index track,
|
||||||
if present; otherwise it is a combination of the subtracks titles.
|
if present; otherwise it is a combination of the subtracks titles.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def add_merged_subtracks(tracklist, subtracks):
|
def add_merged_subtracks(
|
||||||
|
tracklist: list[TrackWithSubtracks],
|
||||||
|
subtracks: list[TrackWithSubtracks],
|
||||||
|
) -> None:
|
||||||
"""Modify `tracklist` in place, merging a list of `subtracks` into
|
"""Modify `tracklist` in place, merging a list of `subtracks` into
|
||||||
a single track into `tracklist`."""
|
a single track into `tracklist`."""
|
||||||
# Calculate position based on first subtrack, without subindex.
|
# Calculate position based on first subtrack, without subindex.
|
||||||
|
|
@ -590,8 +687,8 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
tracklist.append(track)
|
tracklist.append(track)
|
||||||
|
|
||||||
# Pre-process the tracklist, trying to identify subtracks.
|
# Pre-process the tracklist, trying to identify subtracks.
|
||||||
subtracks = []
|
subtracks: list[TrackWithSubtracks] = []
|
||||||
tracklist = []
|
tracklist: list[TrackWithSubtracks] = []
|
||||||
prev_subindex = ""
|
prev_subindex = ""
|
||||||
for track in raw_tracklist:
|
for track in raw_tracklist:
|
||||||
# Regular subtrack (track with subindex).
|
# Regular subtrack (track with subindex).
|
||||||
|
|
@ -626,7 +723,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
if subtracks:
|
if subtracks:
|
||||||
add_merged_subtracks(tracklist, subtracks)
|
add_merged_subtracks(tracklist, subtracks)
|
||||||
|
|
||||||
return tracklist
|
return cast(list[Track], tracklist)
|
||||||
|
|
||||||
def strip_disambiguation(self, text: str) -> str:
|
def strip_disambiguation(self, text: str) -> str:
|
||||||
"""Removes discogs specific disambiguations from a string.
|
"""Removes discogs specific disambiguations from a string.
|
||||||
|
|
@ -637,9 +734,21 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
return DISAMBIGUATION_RE.sub("", text)
|
return DISAMBIGUATION_RE.sub("", text)
|
||||||
|
|
||||||
def get_track_info(
|
def get_track_info(
|
||||||
self, track, index, divisions, album_artist, album_artist_id
|
self,
|
||||||
):
|
track: Track,
|
||||||
|
index: int,
|
||||||
|
divisions: list[str],
|
||||||
|
album_artist_data: tuple[str, str, str | None],
|
||||||
|
) -> IntermediateTrackInfo:
|
||||||
"""Returns a TrackInfo object for a discogs track."""
|
"""Returns a TrackInfo object for a discogs track."""
|
||||||
|
|
||||||
|
artist, artist_anv, artist_id = album_artist_data
|
||||||
|
artist_credit = artist_anv
|
||||||
|
if not self.config["anv"]["artist_credit"]:
|
||||||
|
artist_credit = artist
|
||||||
|
if self.config["anv"]["artist"]:
|
||||||
|
artist = artist_anv
|
||||||
|
|
||||||
title = track["title"]
|
title = track["title"]
|
||||||
if self.config["index_tracks"]:
|
if self.config["index_tracks"]:
|
||||||
prefix = ", ".join(divisions)
|
prefix = ", ".join(divisions)
|
||||||
|
|
@ -647,32 +756,44 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
title = f"{prefix}: {title}"
|
title = f"{prefix}: {title}"
|
||||||
track_id = None
|
track_id = None
|
||||||
medium, medium_index, _ = self.get_track_index(track["position"])
|
medium, medium_index, _ = self.get_track_index(track["position"])
|
||||||
artist, artist_id = self.get_artist(
|
|
||||||
track.get("artists", []), join_key="join"
|
# If artists are found on the track, we will use those instead
|
||||||
|
if artists := track.get("artists", []):
|
||||||
|
artist, artist_id = self.get_artist_with_anv(
|
||||||
|
artists, self.config["anv"]["artist"]
|
||||||
|
)
|
||||||
|
artist_credit, _ = self.get_artist_with_anv(
|
||||||
|
artists, self.config["anv"]["artist_credit"]
|
||||||
)
|
)
|
||||||
# If no artist and artist is returned, set to match album artist
|
|
||||||
if not artist:
|
|
||||||
artist = album_artist
|
|
||||||
artist_id = album_artist_id
|
|
||||||
length = self.get_track_length(track["duration"])
|
length = self.get_track_length(track["duration"])
|
||||||
|
|
||||||
# Add featured artists
|
# Add featured artists
|
||||||
extraartists = track.get("extraartists", [])
|
if extraartists := track.get("extraartists", []):
|
||||||
featured = [
|
featured_list = [
|
||||||
artist["name"]
|
artist
|
||||||
for artist in extraartists
|
for artist in extraartists
|
||||||
if "Featuring" in artist["role"]
|
if "Featuring" in artist["role"]
|
||||||
]
|
]
|
||||||
|
featured, _ = self.get_artist_with_anv(
|
||||||
|
featured_list, self.config["anv"]["artist"]
|
||||||
|
)
|
||||||
|
featured_credit, _ = self.get_artist_with_anv(
|
||||||
|
featured_list, self.config["anv"]["artist_credit"]
|
||||||
|
)
|
||||||
if featured:
|
if featured:
|
||||||
artist = f"{artist} feat. {', '.join(featured)}"
|
artist += f" {self.config['featured_string']} {featured}"
|
||||||
artist = self.strip_disambiguation(artist)
|
artist_credit += (
|
||||||
return TrackInfo(
|
f" {self.config['featured_string']} {featured_credit}"
|
||||||
|
)
|
||||||
|
return IntermediateTrackInfo(
|
||||||
title=title,
|
title=title,
|
||||||
track_id=track_id,
|
track_id=track_id,
|
||||||
|
artist_credit=artist_credit,
|
||||||
artist=artist,
|
artist=artist,
|
||||||
artist_id=artist_id,
|
artist_id=artist_id,
|
||||||
length=length,
|
length=length,
|
||||||
index=index,
|
index=index,
|
||||||
medium=medium,
|
medium_str=medium,
|
||||||
medium_index=medium_index,
|
medium_index=medium_index,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -693,7 +814,7 @@ class DiscogsPlugin(MetadataSourcePlugin):
|
||||||
|
|
||||||
return medium or None, index or None, subindex or None
|
return medium or None, index or None, subindex or None
|
||||||
|
|
||||||
def get_track_length(self, duration):
|
def get_track_length(self, duration: str) -> int | None:
|
||||||
"""Returns the track length in seconds for a discogs duration."""
|
"""Returns the track length in seconds for a discogs duration."""
|
||||||
try:
|
try:
|
||||||
length = time.strptime(duration, "%M:%S")
|
length = time.strptime(duration, "%M:%S")
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,14 @@ New features:
|
||||||
converted files.
|
converted files.
|
||||||
- :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle
|
- :doc:`plugins/discogs`: New config option `strip_disambiguation` to toggle
|
||||||
stripping discogs numeric disambiguation on artist and label fields.
|
stripping discogs numeric disambiguation on artist and label fields.
|
||||||
- :doc:`plugins/discogs` Added support for featured artists.
|
- :doc:`plugins/discogs` Added support for featured artists. :bug:`6038`
|
||||||
|
- :doc:`plugins/discogs` New configuration option `featured_string` to change
|
||||||
|
the default string used to join featured artists. The default string is
|
||||||
|
`Feat.`.
|
||||||
|
- :doc:`plugins/discogs` Support for `artist_credit` in Discogs tags.
|
||||||
|
:bug:`3354`
|
||||||
|
- :doc:`plugins/discogs` Support for name variations and config options to
|
||||||
|
specify where the variations are written. :bug:`3354`
|
||||||
|
|
||||||
Bug fixes:
|
Bug fixes:
|
||||||
|
|
||||||
|
|
@ -28,12 +35,10 @@ Bug fixes:
|
||||||
- :doc:`plugins/spotify` Removed old and undocumented config options
|
- :doc:`plugins/spotify` Removed old and undocumented config options
|
||||||
`artist_field`, `album_field` and `track` that were causing issues with track
|
`artist_field`, `album_field` and `track` that were causing issues with track
|
||||||
matching. :bug:`5189`
|
matching. :bug:`5189`
|
||||||
- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from
|
|
||||||
artists but not labels. :bug:`5366`
|
|
||||||
- :doc:`plugins/discogs` Fixed issue with ignoring featured artists in the
|
|
||||||
extraartists field.
|
|
||||||
- :doc:`plugins/spotify` Fixed an issue where candidate lookup would not find
|
- :doc:`plugins/spotify` Fixed an issue where candidate lookup would not find
|
||||||
matches due to query escaping (single vs double quotes).
|
matches due to query escaping (single vs double quotes).
|
||||||
|
- :doc:`plugins/discogs` Fixed inconsistency in stripping disambiguation from
|
||||||
|
artists but not labels. :bug:`5366`
|
||||||
- :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by
|
- :doc:`plugins/chroma` :doc:`plugins/bpsync` Fix plugin loading issue caused by
|
||||||
an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033`
|
an import of another :class:`beets.plugins.BeetsPlugin` class. :bug:`6033`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,22 @@ Other configurations available under ``discogs:`` are:
|
||||||
- **strip_disambiguation**: Discogs uses strings like ``"(4)"`` to mark distinct
|
- **strip_disambiguation**: Discogs uses strings like ``"(4)"`` to mark distinct
|
||||||
artists and labels with the same name. If you'd like to use the discogs
|
artists and labels with the same name. If you'd like to use the discogs
|
||||||
disambiguation in your tags, you can disable it. Default: ``True``
|
disambiguation in your tags, you can disable it. Default: ``True``
|
||||||
|
- **featured_string**: Configure the string used for noting featured artists.
|
||||||
|
Useful if you prefer ``Featuring`` or ``ft.``. Default: ``Feat.``
|
||||||
|
- **anv**: These configuration option are dedicated to handling Artist Name
|
||||||
|
Variations (ANVs). Sometimes a release credits artists differently compared to
|
||||||
|
the majority of their work. For example, "Basement Jaxx" may be credited as
|
||||||
|
"Tha Jaxx" or "The Basement Jaxx".You can select any combination of these
|
||||||
|
config options to control where beets writes and stores the variation credit.
|
||||||
|
The default, shown below, writes variations to the artist_credit field.
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
discogs:
|
||||||
|
anv:
|
||||||
|
artist_credit: True
|
||||||
|
artist: False
|
||||||
|
album_artist: False
|
||||||
|
|
||||||
.. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings
|
.. _discogs guidelines: https://support.discogs.com/hc/en-us/articles/360005055373-Database-Guidelines-12-Tracklisting#Index_Tracks_And_Headings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -453,6 +453,116 @@ class DGAlbumInfoTest(BeetsTestCase):
|
||||||
config["discogs"]["strip_disambiguation"] = True
|
config["discogs"]["strip_disambiguation"] = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"track_artist_anv,track_artist",
|
||||||
|
[(False, "ARTIST Feat. PERFORMER"), (True, "VARIATION Feat. VARIATION")],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"album_artist_anv,album_artist",
|
||||||
|
[(False, "ARTIST & SOLOIST"), (True, "VARIATION & VARIATION")],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"artist_credit_anv,track_artist_credit,album_artist_credit",
|
||||||
|
[
|
||||||
|
(False, "ARTIST Feat. PERFORMER", "ARTIST & SOLOIST"),
|
||||||
|
(True, "VARIATION Feat. VARIATION", "VARIATION & VARIATION"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock())
|
||||||
|
def test_anv(
|
||||||
|
track_artist_anv,
|
||||||
|
track_artist,
|
||||||
|
album_artist_anv,
|
||||||
|
album_artist,
|
||||||
|
artist_credit_anv,
|
||||||
|
track_artist_credit,
|
||||||
|
album_artist_credit,
|
||||||
|
):
|
||||||
|
"""Test using artist name variations."""
|
||||||
|
data = {
|
||||||
|
"id": 123,
|
||||||
|
"uri": "https://www.discogs.com/release/123456-something",
|
||||||
|
"tracklist": [
|
||||||
|
{
|
||||||
|
"title": "track",
|
||||||
|
"position": "A",
|
||||||
|
"type_": "track",
|
||||||
|
"duration": "5:44",
|
||||||
|
"artists": [
|
||||||
|
{
|
||||||
|
"name": "ARTIST",
|
||||||
|
"tracks": "",
|
||||||
|
"anv": "VARIATION",
|
||||||
|
"id": 11146,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extraartists": [
|
||||||
|
{
|
||||||
|
"name": "PERFORMER",
|
||||||
|
"role": "Featuring",
|
||||||
|
"anv": "VARIATION",
|
||||||
|
"id": 787,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"artists": [
|
||||||
|
{"name": "ARTIST (4)", "anv": "VARIATION", "id": 321, "join": "&"},
|
||||||
|
{"name": "SOLOIST", "anv": "VARIATION", "id": 445, "join": ""},
|
||||||
|
],
|
||||||
|
"title": "title",
|
||||||
|
}
|
||||||
|
release = Bag(
|
||||||
|
data=data,
|
||||||
|
title=data["title"],
|
||||||
|
artists=[Bag(data=d) for d in data["artists"]],
|
||||||
|
)
|
||||||
|
config["discogs"]["anv"]["album_artist"] = album_artist_anv
|
||||||
|
config["discogs"]["anv"]["artist"] = track_artist_anv
|
||||||
|
config["discogs"]["anv"]["artist_credit"] = artist_credit_anv
|
||||||
|
r = DiscogsPlugin().get_album_info(release)
|
||||||
|
assert r.artist == album_artist
|
||||||
|
assert r.artist_credit == album_artist_credit
|
||||||
|
assert r.tracks[0].artist == track_artist
|
||||||
|
assert r.tracks[0].artist_credit == track_artist_credit
|
||||||
|
|
||||||
|
|
||||||
|
@patch("beetsplug.discogs.DiscogsPlugin.setup", Mock())
|
||||||
|
def test_anv_album_artist():
|
||||||
|
"""Test using artist name variations when the album artist
|
||||||
|
is the same as the track artist, but only the track artist
|
||||||
|
should use the artist name variation."""
|
||||||
|
data = {
|
||||||
|
"id": 123,
|
||||||
|
"uri": "https://www.discogs.com/release/123456-something",
|
||||||
|
"tracklist": [
|
||||||
|
{
|
||||||
|
"title": "track",
|
||||||
|
"position": "A",
|
||||||
|
"type_": "track",
|
||||||
|
"duration": "5:44",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"artists": [
|
||||||
|
{"name": "ARTIST (4)", "anv": "VARIATION", "id": 321},
|
||||||
|
],
|
||||||
|
"title": "title",
|
||||||
|
}
|
||||||
|
release = Bag(
|
||||||
|
data=data,
|
||||||
|
title=data["title"],
|
||||||
|
artists=[Bag(data=d) for d in data["artists"]],
|
||||||
|
)
|
||||||
|
config["discogs"]["anv"]["album_artist"] = False
|
||||||
|
config["discogs"]["anv"]["artist"] = True
|
||||||
|
config["discogs"]["anv"]["artist_credit"] = False
|
||||||
|
r = DiscogsPlugin().get_album_info(release)
|
||||||
|
assert r.artist == "ARTIST"
|
||||||
|
assert r.artist_credit == "ARTIST"
|
||||||
|
assert r.tracks[0].artist == "VARIATION"
|
||||||
|
assert r.tracks[0].artist_credit == "ARTIST"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"track, expected_artist",
|
"track, expected_artist",
|
||||||
[
|
[
|
||||||
|
|
@ -469,23 +579,27 @@ class DGAlbumInfoTest(BeetsTestCase):
|
||||||
"extraartists": [
|
"extraartists": [
|
||||||
{
|
{
|
||||||
"name": "SOLOIST",
|
"name": "SOLOIST",
|
||||||
|
"id": 3,
|
||||||
"role": "Featuring",
|
"role": "Featuring",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "PERFORMER (1)",
|
"name": "PERFORMER (1)",
|
||||||
|
"id": 5,
|
||||||
"role": "Other Role, Featuring",
|
"role": "Other Role, Featuring",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "RANDOM",
|
"name": "RANDOM",
|
||||||
|
"id": 8,
|
||||||
"role": "Written-By",
|
"role": "Written-By",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "MUSICIAN",
|
"name": "MUSICIAN",
|
||||||
|
"id": 10,
|
||||||
"role": "Featuring [Uncredited]",
|
"role": "Featuring [Uncredited]",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"NEW ARTIST, VOCALIST feat. SOLOIST, PERFORMER, MUSICIAN",
|
"NEW ARTIST, VOCALIST Feat. SOLOIST, PERFORMER, MUSICIAN",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
@ -494,7 +608,9 @@ def test_parse_featured_artists(track, expected_artist):
|
||||||
"""Tests the plugins ability to parse a featured artist.
|
"""Tests the plugins ability to parse a featured artist.
|
||||||
Initial check with one featured artist, two featured artists,
|
Initial check with one featured artist, two featured artists,
|
||||||
and three. Ignores artists that are not listed as featured."""
|
and three. Ignores artists that are not listed as featured."""
|
||||||
t = DiscogsPlugin().get_track_info(track, 1, 1, "ARTIST", 2)
|
t = DiscogsPlugin().get_track_info(
|
||||||
|
track, 1, 1, ("ARTIST", "ARTIST CREDIT", 2)
|
||||||
|
)
|
||||||
assert t.artist == expected_artist
|
assert t.artist == expected_artist
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue