Skip to content

Conversation

henry-oberholtzer
Copy link
Contributor

@henry-oberholtzer henry-oberholtzer commented Sep 27, 2025

Description

Implements #3354, updates #6040

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:

discogs:
  featured_string: "Feat."
  anv:
    artist_credit: True
    album_artist: False
    artist: False

To Do

Configuration options could be renamed, but I think they're decently self documenting at the moment.

  • Documentation. (If you've added a new command-line flag, for example, find the appropriate page under docs/ to describe it.)
  • Changelog. (Add an entry to docs/changelog.rst to the bottom of one of the lists near the top of the document.)
  • Tests. (Very much encouraged but not strictly required.)

@henry-oberholtzer henry-oberholtzer requested a review from a team as a code owner September 27, 2025 21:37
Copy link
Contributor

sourcery-ai bot commented Sep 27, 2025

Reviewer's Guide

This PR adds support for Discogs-provided artist name variations (ANVs) with configurable application to album artist, track artist, and artist credit tags, introduces a customizable featured artist string, and updates the Discogs plugin implementation, tests, and documentation accordingly.

File-Level Changes

Change Details Files
Introduce new configuration options for ANV and featured string
  • Added featured_string, album_artist_anv, track_artist_anv, and artist_credit_anv to default plugin config
  • Redacted API key as before
beetsplug/discogs.py
Implement get_artist helper to apply ANVs
  • Created get_artist method that replaces names with anv when enabled
  • Invokes parent get_artist with join_key and strips disambiguation
beetsplug/discogs.py
Extend album and track info routines to handle ANVs and credits
  • Modified get_album_info to compute album_artist, album_artist_anv, and artist_credit based on config
  • Refactored get_tracks signature to pass album artist tuple
  • Updated get_track_info to apply ANV flags per track and build artist_credit
  • Incorporated featured_string when formatting featured artists
beetsplug/discogs.py
Update tests for ANV and featured string behavior
  • Added test_anv and test_anv_album_artist to verify tag outputs under various ANV flag combinations
  • Adjusted existing featured-artist parsing test to use new method signature and Feat. casing
test/plugins/test_discogs.py
Refresh documentation and changelog entries
  • Documented new config options in docs/plugins/discogs.rst
  • Added changelog entry under docs/changelog.rst
docs/plugins/discogs.rst
docs/changelog.rst

Possibly linked issues

  • Discogs featured artist fix #6040: The PR directly addresses the issue by implementing the appending of featured artists to track artist names and allowing customization of the 'Feat.' string.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `beetsplug/discogs.py:406-407` </location>
<code_context>
         # Additional cleanups
         # (various artists name, catalog number, media, disambiguation).
         if va:
-            artist = config["va_name"].as_str()
-        else:
-            artist = self.strip_disambiguation(artist)
+            album_artist, artist_credit = config["va_name"].as_str()
         if catalogno == "none":
             catalogno = None
</code_context>

<issue_to_address>
**issue (bug_risk):** Assigning config["va_name"].as_str() to two variables may cause issues.

Unpacking config["va_name"].as_str() into two variables will fail unless it returns a tuple or list. Assign the value to both variables explicitly or ensure the config returns a tuple.
</issue_to_address>

### Comment 2
<location> `beetsplug/discogs.py:695` </location>
<code_context>
-        if featured:
-            artist = f"{artist} feat. {', '.join(featured)}"
-        artist = self.strip_disambiguation(artist)
+        if extraartists := track.get("extraartists", []):
+            featured_list = [
+                artist
</code_context>

<issue_to_address>
**suggestion:** Featured artist logic may not handle empty featured_list gracefully.

Check that calling get_artist with an empty list does not produce unwanted separators or malformed output.
</issue_to_address>

### Comment 3
<location> `test/plugins/test_discogs.py:456-465` </location>
<code_context>
[email protected](
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding edge case tests for missing or empty 'anv' fields.

Please include tests for cases where 'anv' is missing, empty, or None to verify correct fallback behavior.

Suggested implementation:

```python
@pytest.mark.parametrize(
    "config_input,expected_output",
    [
        (
            {
                "track_artist_anv": False,
                "album_artist_anv": False,
                "artist_credit_anv": False,
            },
            {
                "track_artist": "ARTIST Feat. PERFORMER",
        ),
        # Edge case: 'anv' field missing
        (
            {
                "track_artist_anv": True,
                "album_artist_anv": True,
                "artist_credit_anv": True,
                # Simulate missing 'anv' by not providing it in the test data setup
            },
            {
                "track_artist": "ARTIST Feat. PERFORMER",  # Should fallback to default name
            },
        ),
        # Edge case: 'anv' field is empty string
        (
            {
                "track_artist_anv": True,
                "album_artist_anv": True,
                "artist_credit_anv": True,
                "anv": "",
            },
            {
                "track_artist": "ARTIST Feat. PERFORMER",  # Should fallback to default name
            },
        ),
        # Edge case: 'anv' field is None
        (
            {
                "track_artist_anv": True,
                "album_artist_anv": True,
                "artist_credit_anv": True,
                "anv": None,
            },
            {
                "track_artist": "ARTIST Feat. PERFORMER",  # Should fallback to default name
            },
        ),

```

You may need to ensure that the test data setup in the test function correctly simulates the missing, empty, and None 'anv' cases. If the test function expects a specific structure for the input, you might need to adjust how the 'anv' field is injected or omitted in the test setup.
</issue_to_address>

### Comment 4
<location> `test/plugins/test_discogs.py:487-496` </location>
<code_context>
+    assert r.tracks[0].artist_credit == expected_output["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
</code_context>

<issue_to_address>
**suggestion (testing):** Add a test for mixed ANV configuration (e.g., only artist_credit_anv enabled).

Please add a test with only 'artist_credit_anv' set to True and the others False to confirm correct behavior for this configuration.
</issue_to_address>

### Comment 5
<location> `test/plugins/test_discogs.py:565-571` </location>
<code_context>
+    config["discogs"]["track_artist_anv"] = True
+    config["discogs"]["artist_credit_anv"] = 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"
+
+
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding a test for multiple artists with different ANV values.

Please add a test case with multiple artists, each having a distinct ANV, to verify correct handling and joining of artist names.

```suggestion
    config["discogs"]["track_artist_anv"] = True
    config["discogs"]["artist_credit_anv"] = 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"

def test_multiple_artists_with_distinct_anv():
    # Simulate a release with two artists, each with a different ANV
    data = {
        "title": "Test Album",
        "artists": [
            {"name": "Artist One", "anv": "A1"},
            {"name": "Artist Two", "anv": "A2"},
        ],
        "tracklist": [
            {
                "title": "Track 1",
                "artists": [
                    {"name": "Artist One", "anv": "A1"},
                    {"name": "Artist Two", "anv": "A2"},
                ],
            }
        ],
    }
    release = Bag(
        data=data,
        title=data["title"],
        artists=[Bag(data=d) for d in data["artists"]],
    )
    config["discogs"]["album_artist_anv"] = True
    config["discogs"]["track_artist_anv"] = True
    config["discogs"]["artist_credit_anv"] = False
    r = DiscogsPlugin().get_album_info(release)
    # Check that the album artist is joined ANVs
    assert r.artist == "A1, A2"
    # Check that the album artist_credit is joined names
    assert r.artist_credit == "Artist One, Artist Two"
    # Check that the track artist is joined ANVs
    assert r.tracks[0].artist == "A1, A2"
    # Check that the track artist_credit is joined names
    assert r.tracks[0].artist_credit == "Artist One, Artist Two"
```
</issue_to_address>

### Comment 6
<location> `beetsplug/discogs.py:472` </location>
<code_context>
    def get_tracks(self, tracklist, album_artist_data):
        """Returns a list of TrackInfo objects for a discogs tracklist."""
        try:
            clean_tracklist = self.coalesce_tracks(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
        tracks = []
        index_tracks = {}
        index = 0
        # Distinct works and intra-work divisions, as defined by index tracks.
        divisions, next_divisions = [], []
        for track in clean_tracklist:
            # Only real tracks have `position`. Otherwise, it's an index track.
            if track["position"]:
                index += 1
                if next_divisions:
                    # End of a block of index tracks: update the current
                    # divisions.
                    divisions += next_divisions
                    del next_divisions[:]
                track_info = self.get_track_info(
                    track, index, divisions, album_artist_data
                )
                track_info.track_alt = track["position"]
                tracks.append(track_info)
            else:
                next_divisions.append(track["title"])
                # We expect new levels of division at the beginning of the
                # tracklist (and possibly elsewhere).
                try:
                    divisions.pop()
                except IndexError:
                    pass
                index_tracks[index + 1] = track["title"]

        # Fix up medium and medium_index for each track. Discogs position is
        # unreliable, but tracks are in order.
        medium = None
        medium_count, index_count, side_count = 0, 0, 0
        sides_per_medium = 1

        # If a medium has two sides (ie. vinyl or cassette), each pair of
        # consecutive sides should belong to the same medium.
        if all([track.medium is not None for track in tracks]):
            m = sorted({track.medium.lower() for track in tracks})
            # If all track.medium are single consecutive letters, assume it is
            # a 2-sided medium.
            if "".join(m) in ascii_lowercase:
                sides_per_medium = 2

        for track in tracks:
            # Handle special case where a different medium does not indicate a
            # new disc, when there is no medium_index and the ordinal of medium
            # is not sequential. For example, I, II, III, IV, V. Assume these
            # are the track index, not the medium.
            # side_count is the number of mediums or medium sides (in the case
            # of two-sided mediums) that were seen before.
            medium_is_index = (
                track.medium
                and not track.medium_index
                and (
                    len(track.medium) != 1
                    or
                    # Not within standard incremental medium values (A, B, C, ...).
                    ord(track.medium) - 64 != side_count + 1
                )
            )

            if not medium_is_index and medium != track.medium:
                side_count += 1
                if sides_per_medium == 2:
                    if side_count % sides_per_medium:
                        # Two-sided medium changed. Reset index_count.
                        index_count = 0
                        medium_count += 1
                else:
                    # Medium changed. Reset index_count.
                    medium_count += 1
                    index_count = 0
                medium = track.medium

            index_count += 1
            medium_count = 1 if medium_count == 0 else medium_count
            track.medium, track.medium_index = medium_count, index_count

        # Get `disctitle` from Discogs index tracks. Assume that an index track
        # before the first track of each medium is a disc title.
        for track in tracks:
            if track.medium_index == 1:
                if track.index in index_tracks:
                    disctitle = index_tracks[track.index]
                else:
                    disctitle = None
            track.disctitle = disctitle

        return tracks

</code_context>

<issue_to_address>
**issue (code-quality):** We've found these issues:

- Replace unneeded comprehension with generator ([`comprehension-to-generator`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/comprehension-to-generator/))
- Swap positions of nested conditionals ([`swap-nested-ifs`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/swap-nested-ifs/))
- Hoist nested repeated code outside conditional statements ([`hoist-similar-statement-from-if`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/hoist-similar-statement-from-if/))
- Simplify dictionary access using default get ([`default-get`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/default-get/))
- Replace if statement with if expression ([`assign-if-exp`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/assign-if-exp/))
- Low code quality found in DiscogsPlugin.get\_tracks - 21% ([`low-code-quality`](https://docs.sourcery.ai/Reference/Default-Rules/comments/low-code-quality/))

<br/><details><summary>Explanation</summary>




The quality score for this function is below the quality threshold of 25%.
This score is a combination of the method length, cognitive complexity and working memory.

How can you solve this?

It might be worth refactoring this function to make it shorter and more readable.

- Reduce the function length by extracting pieces of functionality out into
  their own functions. This is the most important thing you can do - ideally a
  function should be less than 10 lines.
- Reduce nesting, perhaps by introducing guard clauses to return early.
- Ensure that variables are tightly scoped, so that code using related concepts
  sits together within the function rather than being scattered.</details>
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link
Contributor

@semohr semohr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding so many type hints! I only expected them for the functions you modified, so this was a nice surprise.

In general, using cast isn’t ideal, but I understand why you used it here, avoiding it would require a fair amount of refactoring. Maybe we can tackle that in a future PR 🙃

@semohr semohr merged commit 08d9f34 into beetbox:master Oct 5, 2025
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants