Skip to content
38 changes: 32 additions & 6 deletions beetsplug/musicbrainz.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def get_message(self):
"isrcs",
"work-level-rels",
"artist-rels",
"releases",
}
& set(musicbrainzngs.VALID_INCLUDES["recording"])
)
Expand Down Expand Up @@ -521,6 +522,23 @@ def track_info(
for extra_trackdata in extra_trackdatas:
info.update(extra_trackdata)

# If this recording includes a release list, attach album metadata so
# recording-based matches still write album/album_id.
rel_list = recording.get("release-list") or recording.get(
"release_list"
)
if rel_list and len(rel_list) > 0:
# 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)
album_title = rel0_dict.get("title")
album_id = rel0_dict.get("id")
if album_title:
info.album = album_title
if album_id:
info.album_id = album_id

return info

def album_info(self, release: JSONDict) -> beets.autotag.hooks.AlbumInfo:
Expand Down Expand Up @@ -848,17 +866,25 @@ 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)

yield from filter(
None, map(self.track_info, 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 tests where get_recording_by_id isn't mocked
yield self.track_info(r)

def album_for_id(
self, album_id: str
) -> beets.autotag.hooks.AlbumInfo | None:
"""Fetches an album by its MusicBrainz ID and returns an AlbumInfo
object or None if the album is not found. May raise a
MusicBrainzAPIError.
"""Fetches an album by its MusicBrainz ID and returns an
AlbumInfo object or None if the album is not found.
"""
self._log.debug("Requesting MusicBrainz release {}", album_id)
if not (albumid := self._extract_id(album_id)):
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ Bug fixes:
the default config path. :bug:`5652`
- :doc:`plugins/lyrics`: Accepts strings for lyrics sources (previously only
accepted a list of strings). :bug:`5962`
- Fixed a bug where single-track imports using the MusicBrainz plugin failed to
attach album information when the API response included a ``release-list``.
The ``item_candidates()`` function now correctly assigns ``album`` and
``album_id`` from the associated release. :bug:`5886`
- Fix a bug introduced in release 2.4.0 where import from any valid
import-log-file always threw a "none of the paths are importable" error.
- :doc:`/plugins/web`: repair broken `/item/values/…` and `/albums/values/…`
Expand Down
50 changes: 49 additions & 1 deletion test/plugins/test_musicbrainz.py
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +1012,11 @@ class TestMusicBrainzPlugin(PluginMixin):
plugin = "musicbrainz"

mbid = "d2a6f856-b553-40a0-ac54-a321e8e2da99"
RECORDING = {"title": "foo", "id": "bar", "length": 42}
RECORDING = {
"title": "foo",
"id": "90b2fd02-19c8-42b8-8592-d62542604ce9",
"length": 42,
}

@pytest.fixture
def plugin_config(self):
Expand Down Expand Up @@ -1063,6 +1067,50 @@ def test_item_candidates(self, monkeypatch, mb):
assert len(candidates) == 1
assert candidates[0].track_id == self.RECORDING["id"]

def test_item_candidates_includes_album_from_release_list(
self, monkeypatch, mb
):
"""Ensure that item_candidates now attaches album
info when the recording includes a release-list."""
recording_with_release = {
"title": "Beautiful in White",
"id": "d207c6a8-ea13-4da6-9008-cc1db29a8a35",
"length": 180000,
"release-list": [
{
"id": "9ec75bce-60ac-41e9-82a5-3b71a982257d",
"title": "Love Always (Deluxe Edition)",
}
],
}

# Mock MusicBrainz search_recordings to return a recording with a release-list
monkeypatch.setattr(
"musicbrainzngs.search_recordings",
lambda *_, **__: {"recording-list": [recording_with_release]},
)

# Mock get_recording_by_id so track_for_id() retrieves the same dict
monkeypatch.setattr(
"musicbrainzngs.get_recording_by_id",
lambda *_, **__: {"recording": recording_with_release},
)

candidates = list(
mb.item_candidates(Item(), "Shane Filan", "Beautiful in White")
)

# Ensure exactly one candidate was found
assert len(candidates) == 1

candidate = candidates[0]
# 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_candidates(self, monkeypatch, mb):
monkeypatch.setattr(
"musicbrainzngs.search_releases",
Expand Down