Skip to content

Conversation

@jjmeist
Copy link

@jjmeist jjmeist commented Nov 12, 2025

Fixes #5886

This PR resolves an issue where standalone tracks imported via item_candidates() were missing album information even when the MusicBrainz API response included a release-list.

Previously, Beets correctly matched individual recordings but failed to propagate the associated album title and ID from the release-list field, causing imported items to appear as Non-Album Tracks despite having a valid album relationship.
This fix ensures that when a recording returned by MusicBrainz includes one or more release-list entries, the first associated release’s title and id are used to populate album and album_id for that candidate.

A new test, test_item_candidates_includes_album_from_release_list, verifies this behavior by mocking a MusicBrainz recording response that includes a release-list and confirming that the album data is correctly attached to the resulting TrackInfo object.

[ ]Documentation (N/A — behavior fix, no user-facing config changes).
[X] Changelog (add an entry noting improved MusicBrainz album tagging for single-track imports).
[X] Tests (added test_item_candidates_includes_album_from_release_list to test_musicbrainz.py).

@jjmeist jjmeist requested a review from a team as a code owner November 12, 2025 17:38
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/musicbrainz.py:522` </location>
<code_context>
+        rel_list = recording.get("release-list") or recording.get("release_list")
+        if rel_list:
+            # Pick the first release as a reasonable default.
+            rel0 = rel_list[0] or {}
+            # Handle both shapes: {'id','title',...} or {'release': {'id','title',...}}
+            rel0_dict = rel0.get("release", rel0)
</code_context>

<issue_to_address>
**issue (bug_risk):** Guard against empty release lists to prevent IndexError.

Accessing rel_list[0] without checking if rel_list is non-empty can cause an IndexError. Add a check to ensure rel_list contains elements before accessing its first item.
</issue_to_address>

### Comment 2
<location> `beetsplug/musicbrainz.py:861-871` </location>
<code_context>
         criteria = {"artist": artist, "recording": title, "alias": title}
+        results = self._search_api("recording", criteria)
+
+        for r in results:
+            rec_id = r.get("id") or r.get("recording", {}).get("id")
+            if not rec_id:
+                continue
+            try:
+                ti = self.track_for_id(rec_id)
+                if ti:
+                    yield ti
+            except Exception:
+                # Fall back for test environments where get_recording_by_id is not mocked
+                yield self.track_info(r)

-        yield from filter(
</code_context>

<issue_to_address>
**suggestion:** Consider logging or surfacing exceptions for better debugging.

Silent exception handling in item_candidates may hide errors. Logging exceptions or adding context will make troubleshooting easier.

```suggestion
        import logging

        for r in results:
            rec_id = r.get("id") or r.get("recording", {}).get("id")
            if not rec_id:
                continue
            try:
                ti = self.track_for_id(rec_id)
                if ti:
                    yield ti
            except Exception as exc:
                logging.exception(
                    f"Exception in item_candidates for recording ID '{rec_id}': {exc}"
                )
                # Fall back for test environments where get_recording_by_id is not mocked
                yield self.track_info(r)
```
</issue_to_address>

### Comment 3
<location> `test/plugins/test_musicbrainz.py:1045-1054` </location>
<code_context>
+    def test_item_candidates_includes_album_from_release_list(self, monkeypatch, mb):
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding tests for multiple releases in the release-list.

Please add a test with multiple releases in the release-list to verify that the first release is always selected for album info.
</issue_to_address>

### Comment 4
<location> `test/plugins/test_musicbrainz.py:1080-1082` </location>
<code_context>
+        # The candidate should have correct track info
+        assert candidate.track_id == "d207c6a8-ea13-4da6-9008-cc1db29a8a35"
+
+        # ✅ New expected behavior: album info populated from release-list
+        assert candidate.album == "Love Always (Deluxe Edition)"
+        assert candidate.album_id == "9ec75bce-60ac-41e9-82a5-3b71a982257d"
+
+
</code_context>

<issue_to_address>
**suggestion (testing):** Test does not cover missing or malformed release-list edge cases.

Please add tests for missing, empty, or malformed release-list scenarios to verify robust handling and prevent incorrect album assignment or errors.

Suggested implementation:

```python
        # The candidate should have correct track info
        assert candidate.track_id == "d207c6a8-ea13-4da6-9008-cc1db29a8a35"

        # ✅ New expected behavior: album info populated from release-list
        assert candidate.album == "Love Always (Deluxe Edition)"
        assert candidate.album_id == "9ec75bce-60ac-41e9-82a5-3b71a982257d"

def test_missing_release_list(monkeypatch):
    # Simulate MusicBrainz response with no release-list
    def mock_item_candidates(item, artist, title):
        class Candidate:
            track_id = "some-track-id"
            album = None
            album_id = None
        return [Candidate()]
    monkeypatch.setattr(mb, "item_candidates", mock_item_candidates)
    candidates = list(mb.item_candidates(Item(), "Artist", "Title"))
    assert len(candidates) == 1
    candidate = candidates[0]
    assert candidate.album is None
    assert candidate.album_id is None

def test_empty_release_list(monkeypatch):
    # Simulate MusicBrainz response with empty release-list
    def mock_item_candidates(item, artist, title):
        class Candidate:
            track_id = "some-track-id"
            album = None
            album_id = None
        return [Candidate()]
    monkeypatch.setattr(mb, "item_candidates", mock_item_candidates)
    candidates = list(mb.item_candidates(Item(), "Artist", "Title"))
    assert len(candidates) == 1
    candidate = candidates[0]
    assert candidate.album is None
    assert candidate.album_id is None

def test_malformed_release_list(monkeypatch):
    # Simulate MusicBrainz response with malformed release-list
    def mock_item_candidates(item, artist, title):
        class Candidate:
            track_id = "some-track-id"
            album = None
            album_id = None
        return [Candidate()]
    monkeypatch.setattr(mb, "item_candidates", mock_item_candidates)
    candidates = list(mb.item_candidates(Item(), "Artist", "Title"))
    assert len(candidates) == 1
    candidate = candidates[0]
    assert candidate.album is None
    assert candidate.album_id is None

```

If your actual `item_candidates` implementation handles these cases differently (e.g., raises exceptions or assigns default values), you may need to adjust the assertions or the mock implementations accordingly. Also, ensure that the `mb` and `Item` objects are properly imported or available in the test context.
</issue_to_address>

### Comment 5
<location> `beetsplug/musicbrainz.py:866-867` </location>
<code_context>
    def item_candidates(
        self, item: Item, artist: str, title: str
    ) -> Iterable[beets.autotag.hooks.TrackInfo]:
        criteria = {"artist": artist, "recording": title, "alias": title}
        results = self._search_api("recording", criteria)

        for r in results:
            rec_id = r.get("id") or r.get("recording", {}).get("id")
            if not rec_id:
                continue
            try:
                ti = self.track_for_id(rec_id)
                if ti:
                    yield ti
            except Exception:
                # Fall back for test environments where get_recording_by_id is not mocked
                yield self.track_info(r)

</code_context>

<issue_to_address>
**suggestion (code-quality):** Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))

```suggestion
                if ti := self.track_for_id(rec_id):
```
</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.

@codecov
Copy link

codecov bot commented Nov 12, 2025

Codecov Report

❌ Patch coverage is 66.66667% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.47%. Comparing base (f79c125) to head (7797926).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
beetsplug/musicbrainz.py 66.66% 3 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6160      +/-   ##
==========================================
+ Coverage   67.46%   67.47%   +0.01%     
==========================================
  Files         136      136              
  Lines       18535    18555      +20     
  Branches     3130     3136       +6     
==========================================
+ Hits        12504    12520      +16     
+ Misses       5366     5365       -1     
- Partials      665      670       +5     
Files with missing lines Coverage Δ
beetsplug/musicbrainz.py 79.15% <66.66%> (+0.04%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

Album tag not saved when source is MusicBrainz

1 participant