beets/beetsplug/discogs/states.py
2026-01-19 12:46:22 -08:00

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