mirror of
https://github.com/beetbox/beets.git
synced 2026-01-21 07:35:35 +01:00
237 lines
8.5 KiB
Python
237 lines
8.5 KiB
Python
# This file is part of beets.
|
|
# Copyright 2025, Sarunas Nejus, Henry Oberholtzer.
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining
|
|
# a copy of this software and associated documentation files (the
|
|
# "Software"), to deal in the Software without restriction, including
|
|
# without limitation the rights to use, copy, modify, merge, publish,
|
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
|
# permit persons to whom the Software is furnished to do so, subject to
|
|
# the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be
|
|
# included in all copies or substantial portions of the Software.
|
|
|
|
"""Dataclasses for managing artist credits and tracklists from Discogs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import asdict, dataclass, field
|
|
from functools import cached_property
|
|
from typing import TYPE_CHECKING, NamedTuple
|
|
|
|
from beets import config
|
|
|
|
from .types import ArtistInfo
|
|
|
|
if TYPE_CHECKING:
|
|
from confuse import ConfigView
|
|
|
|
from beets.autotag.hooks import TrackInfo
|
|
|
|
from . import DiscogsPlugin
|
|
from .types import Artist, Track, TracklistInfo
|
|
|
|
DISAMBIGUATION_RE = re.compile(r" \(\d+\)")
|
|
|
|
|
|
@dataclass
|
|
class ArtistState:
|
|
"""Represent Discogs artist credits.
|
|
|
|
This object centralizes the plugin's policy for which Discogs artist fields
|
|
to prefer (name vs. ANV), how to treat 'Various', how to format join
|
|
phrases, and how to separate featured artists. It exposes both per-artist
|
|
components and fully joined strings for common tag targets like 'artist' and
|
|
'artist_credit'.
|
|
"""
|
|
|
|
class ValidArtist(NamedTuple):
|
|
"""A normalized, render-ready artist entry extracted from Discogs data.
|
|
|
|
Instances represent the subset of Discogs artist information needed for
|
|
tagging, including the join token following the artist and whether the
|
|
entry is considered a featured appearance.
|
|
"""
|
|
|
|
id: str
|
|
name: str
|
|
credit: str
|
|
join: str
|
|
is_feat: bool
|
|
|
|
def get_artist(self, property_name: str) -> str:
|
|
"""Return the requested display field with its trailing join token.
|
|
|
|
The join token is normalized so commas become ', ' and other join
|
|
phrases are surrounded with spaces, producing a single fragment that
|
|
can be concatenated to form a full artist string.
|
|
"""
|
|
join = {",": ", ", "": ""}.get(self.join, f" {self.join} ")
|
|
return f"{getattr(self, property_name)}{join}"
|
|
|
|
raw_artists: list[Artist]
|
|
use_anv: bool
|
|
use_credit_anv: bool
|
|
featured_string: str
|
|
should_strip_disambiguation: bool
|
|
|
|
@property
|
|
def info(self) -> ArtistInfo:
|
|
"""Expose the state in the shape expected by downstream tag mapping."""
|
|
return {k: getattr(self, k) for k in ArtistInfo.__annotations__} # type: ignore[return-value]
|
|
|
|
def strip_disambiguation(self, text: str) -> str:
|
|
"""Strip Discogs disambiguation suffixes from an artist or label string.
|
|
|
|
This removes Discogs-specific numeric suffixes like 'Name (5)' and can
|
|
be applied to multi-artist strings as well (e.g., 'A (1) & B (2)'). When
|
|
the feature is disabled, the input is returned unchanged.
|
|
"""
|
|
if self.should_strip_disambiguation:
|
|
return DISAMBIGUATION_RE.sub("", text)
|
|
return text
|
|
|
|
@cached_property
|
|
def valid_artists(self) -> list[ValidArtist]:
|
|
"""Build the ordered, filtered list of artists used for rendering.
|
|
|
|
The resulting list normalizes Discogs entries by:
|
|
- substituting the configured 'Various Artists' name when Discogs uses
|
|
'Various'
|
|
- choosing between name and ANV according to plugin settings
|
|
- excluding non-empty roles unless they indicate a featured appearance
|
|
- capturing join tokens so the original credit formatting is preserved
|
|
"""
|
|
va_name = config["va_name"].as_str()
|
|
return [
|
|
self.ValidArtist(
|
|
str(a["id"]),
|
|
self.strip_disambiguation(anv if self.use_anv else name),
|
|
self.strip_disambiguation(anv if self.use_credit_anv else name),
|
|
a["join"].strip(),
|
|
is_feat,
|
|
)
|
|
for a in self.raw_artists
|
|
if (
|
|
(name := va_name if a["name"] == "Various" else a["name"])
|
|
and (anv := a["anv"] or name)
|
|
and (
|
|
(is_feat := ("featuring" in a["role"].lower()))
|
|
or not a["role"]
|
|
)
|
|
)
|
|
]
|
|
|
|
@property
|
|
def artists_ids(self) -> list[str]:
|
|
"""Return Discogs artist IDs for all valid artists, preserving order."""
|
|
return [a.id for a in self.valid_artists]
|
|
|
|
@property
|
|
def artist_id(self) -> str:
|
|
"""Return the primary Discogs artist ID."""
|
|
return self.artists_ids[0]
|
|
|
|
@property
|
|
def artists(self) -> list[str]:
|
|
"""Return the per-artist display names used for the 'artist' field."""
|
|
return [a.name for a in self.valid_artists]
|
|
|
|
@property
|
|
def artists_credit(self) -> list[str]:
|
|
"""Return the per-artist display names used for the credit field."""
|
|
return [a.credit for a in self.valid_artists]
|
|
|
|
@property
|
|
def artist(self) -> str:
|
|
"""Return the fully rendered artist string using display names."""
|
|
return self.join_artists("name")
|
|
|
|
@property
|
|
def artist_credit(self) -> str:
|
|
"""Return the fully rendered artist credit string."""
|
|
return self.join_artists("credit")
|
|
|
|
def join_artists(self, property_name: str) -> str:
|
|
"""Render a single artist string with join phrases and featured artists.
|
|
|
|
Non-featured artists are concatenated using their join tokens. Featured
|
|
artists are appended after the configured 'featured' marker, preserving
|
|
Discogs order while keeping featured credits separate from the main
|
|
artist string.
|
|
"""
|
|
non_featured = [a for a in self.valid_artists if not a.is_feat]
|
|
featured = [a for a in self.valid_artists if a.is_feat]
|
|
|
|
artist = "".join(a.get_artist(property_name) for a in non_featured)
|
|
if featured:
|
|
if "feat" not in artist:
|
|
artist += f" {self.featured_string} "
|
|
|
|
artist += ", ".join(a.get_artist(property_name) for a in featured)
|
|
|
|
return artist
|
|
|
|
@classmethod
|
|
def from_config(
|
|
cls,
|
|
config: ConfigView,
|
|
artists: list[Artist],
|
|
for_album_artist: bool = False,
|
|
) -> ArtistState:
|
|
return cls(
|
|
artists,
|
|
config["anv"]["album_artist" if for_album_artist else "artist"].get(
|
|
bool
|
|
),
|
|
config["anv"]["artist_credit"].get(bool),
|
|
config["featured_string"].as_str(),
|
|
config["strip_disambiguation"].get(bool),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class TracklistState:
|
|
index: int = 0
|
|
index_tracks: dict[int, str] = field(default_factory=dict)
|
|
tracks: list[TrackInfo] = field(default_factory=list)
|
|
divisions: list[str] = field(default_factory=list)
|
|
next_divisions: list[str] = field(default_factory=list)
|
|
mediums: list[str | None] = field(default_factory=list)
|
|
medium_indices: list[str | None] = field(default_factory=list)
|
|
|
|
@property
|
|
def info(self) -> TracklistInfo:
|
|
return asdict(self) # type: ignore[return-value]
|
|
|
|
@classmethod
|
|
def build(
|
|
cls,
|
|
plugin: DiscogsPlugin,
|
|
clean_tracklist: list[Track],
|
|
albumartistinfo: ArtistState,
|
|
) -> TracklistState:
|
|
state = cls()
|
|
for track in clean_tracklist:
|
|
if track["position"]:
|
|
state.index += 1
|
|
if state.next_divisions:
|
|
state.divisions += state.next_divisions
|
|
state.next_divisions.clear()
|
|
track_info, medium, medium_index = plugin.get_track_info(
|
|
track, state.index, state.divisions, albumartistinfo
|
|
)
|
|
track_info.track_alt = track["position"]
|
|
state.tracks.append(track_info)
|
|
state.mediums.append(medium or None)
|
|
state.medium_indices.append(medium_index or None)
|
|
else:
|
|
state.next_divisions.append(track["title"])
|
|
try:
|
|
state.divisions.pop()
|
|
except IndexError:
|
|
pass
|
|
state.index_tracks[state.index + 1] = track["title"]
|
|
return state
|