From e257b7a82e12c3e8449158995e3a6b439955e280 Mon Sep 17 00:00:00 2001 From: Brock Grassy Date: Wed, 4 Feb 2026 16:14:54 -0500 Subject: [PATCH 1/2] Change missing plugin to allow for filtering albums by release type --- beetsplug/_utils/musicbrainz.py | 5 + beetsplug/missing.py | 44 ++++++- docs/changelog.rst | 9 ++ docs/plugins/missing.rst | 21 +++- test/plugins/test_missing.py | 213 +++++++++++++++++++++++++++----- 5 files changed, 257 insertions(+), 35 deletions(-) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 2fc821df9..57e5860d4 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -76,6 +76,7 @@ class BrowseReleaseGroupsKwargs(BrowseKwargs, total=False): artist: NotRequired[str] collection: NotRequired[str] release: NotRequired[str] + release_type: NotRequired[list[str]] class BrowseRecordingsKwargs(BrowseReleaseGroupsKwargs, total=False): @@ -235,7 +236,11 @@ class MusicBrainzAPI(RequestHandler): """Browse release groups related to the given entities. At least one of artist, collection, or release must be provided. + Optionally filter by release_type (e.g., ["album", "ep"]). """ + # MusicBrainz API uses "type" parameter for release type filtering + if release_type := kwargs.pop("release_type", None): + kwargs["type"] = "|".join(release_type) return self._get_resource("release-group", **kwargs)["release-groups"] @singledispatchmethod diff --git a/beetsplug/missing.py b/beetsplug/missing.py index d2aae14e9..164c34e28 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -22,6 +22,25 @@ from typing import TYPE_CHECKING, ClassVar import requests +# Valid MusicBrainz release types for filtering release groups +VALID_RELEASE_TYPES = [ + "nat", + "album", + "single", + "ep", + "broadcast", + "other", + "compilation", + "soundtrack", + "spokenword", + "interview", + "audiobook", + "live", + "remix", + "dj-mix", + "mixtape/street", +] + from beets import config, metadata_plugins from beets.dbcore import types from beets.library import Item @@ -108,6 +127,7 @@ class MissingPlugin(MusicBrainzAPIMixin, BeetsPlugin): "count": False, "total": False, "album": False, + "release_type": ["album"], } ) @@ -133,7 +153,19 @@ class MissingPlugin(MusicBrainzAPIMixin, BeetsPlugin): "--album", dest="album", action="store_true", - help="show missing albums for artist instead of tracks", + help=( + "show missing release for artist instead of tracks. Defaults " + "to only releases of type 'album'" + ), + ) + self._command.parser.add_option( + "--release-type", + dest="release_type", + action="append", + help=( + "select release types for missing albums for artist " + f"from ({', '.join(VALID_RELEASE_TYPES)})" + ), ) self._command.parser.add_format_option() @@ -181,7 +213,7 @@ class MissingPlugin(MusicBrainzAPIMixin, BeetsPlugin): """ query.append(MB_ARTIST_QUERY) - # build dict mapping artist to set of their album ids in library + # build dict mapping artist to set of their release group ids in library album_ids_by_artist = defaultdict(set) for album in lib.albums(query): # TODO(@snejus): Some releases have different `albumartist` for the @@ -191,13 +223,17 @@ class MissingPlugin(MusicBrainzAPIMixin, BeetsPlugin): # reporting the same set of missing albums. Instead, we should # group by `mb_albumartistid` field only. artist = (album["albumartist"], album["mb_albumartistid"]) - album_ids_by_artist[artist].add(album) + album_ids_by_artist[artist].add(album["mb_releasegroupid"]) total_missing = 0 + release_type = self.config["release_type"].get() or ["album"] calculating_total = self.config["total"].get() for (artist, artist_id), album_ids in album_ids_by_artist.items(): try: - resp = self.mb_api.browse_release_groups(artist=artist_id) + resp = self.mb_api.browse_release_groups( + artist=artist_id, + release_type=release_type, + ) except requests.exceptions.RequestException: self._log.info( "Couldn't fetch info for artist '{}' ({})", diff --git a/docs/changelog.rst b/docs/changelog.rst index 25a0c1365..317067817 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,8 +9,17 @@ Unreleased New features: +- :doc:`plugins/missing`: When running in missing album mode, allows users to + specify MusicBrainz release types to show using the ``--release-type`` flag. + The default behavior is also changed to just show releases of type ``album``. + :bug:`2661` + Bug fixes: +- :doc:`plugins/missing`: Fix ``--album`` mode incorrectly reporting albums + already in the library as missing. The comparison now correctly uses + ``mb_releasegroupid``. + For packagers: Other changes: diff --git a/docs/plugins/missing.rst b/docs/plugins/missing.rst index d286e43cc..96f2584e8 100644 --- a/docs/plugins/missing.rst +++ b/docs/plugins/missing.rst @@ -25,12 +25,18 @@ library: -c, --count count missing tracks per album -t, --total count totals across the entire library -a, --album show missing albums for artist instead of tracks for album + --release-type show only missing albums of specified release type. + You can provide this argument multiple times to + specify multiple release types to filter to. If not + provided, defaults to just the "album" release type. …or by editing the corresponding configuration options. .. warning:: - Option ``-c`` is ignored when used with ``-a``. + Option ``-c`` is ignored when used with ``-a``, and ``--release-type`` is + ignored when not used with ``-a``. Valid release types can be shown by + running ``beet missing -h``. Configuration ------------- @@ -108,6 +114,19 @@ Print out a count of the total number of missing tracks: beet missing -t +List all missing albums of release type "compilation" in your collection: + +:: + + beet missing -a --release-type compilation + +List all missing albums of release type "compilation" and album in your +collection: + +:: + + beet missing -a --release-type compilation --release-type album + Call this plugin from other beet commands: :: diff --git a/test/plugins/test_missing.py b/test/plugins/test_missing.py index d12f2b4cf..1f492872d 100644 --- a/test/plugins/test_missing.py +++ b/test/plugins/test_missing.py @@ -1,29 +1,26 @@ +"""Tests for the `missing` plugin.""" + +import re import uuid +from unittest.mock import patch import pytest -from beets.library import Album +from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.library import Album, Item from beets.test.helper import PluginMixin, TestHelper -@pytest.fixture -def helper(): - helper = TestHelper() - helper.setup_beets() +class TestMissingAlbums(PluginMixin, TestHelper): + """Tests for missing albums functionality.""" - yield helper - - helper.teardown_beets() - - -class TestMissingAlbums(PluginMixin): plugin = "missing" - album_in_lib = Album( - album="Album", - albumartist="Artist", - mb_albumartistid=str(uuid.uuid4()), - mb_albumid="album", - ) + + @pytest.fixture(autouse=True) + def _setup(self): + self.setup_beets() + yield + self.teardown_beets() @pytest.mark.parametrize( "release_from_mb,expected_output", @@ -34,28 +31,184 @@ class TestMissingAlbums(PluginMixin): id="missing", ), pytest.param( - {"id": album_in_lib.mb_albumid, "title": album_in_lib.album}, + {"id": "release_group_in_lib", "title": "Album"}, "", - marks=pytest.mark.xfail( - reason=( - "Album in lib must not be reported as missing." - " Needs fixing." - ) - ), id="not missing", ), ], ) def test_missing_artist_albums( - self, requests_mock, helper, release_from_mb, expected_output + self, requests_mock, release_from_mb, expected_output ): - helper.lib.add(self.album_in_lib) + artist_mbid = str(uuid.uuid4()) + self.lib.add( + Album( + album="Album", + albumartist="Artist", + mb_albumartistid=artist_mbid, + mb_albumid="album", + mb_releasegroupid="release_group_in_lib", + ) + ) requests_mock.get( - f"/ws/2/release-group?artist={self.album_in_lib.mb_albumartistid}", + f"/ws/2/release-group?artist={artist_mbid}", json={"release-groups": [release_from_mb]}, ) - with self.configure_plugin({}): - assert ( - helper.run_with_output("missing", "--album") == expected_output + assert self.run_with_output("missing", "--album") == expected_output + + def test_release_type_filters_results(self, requests_mock): + """Test --release-type filters to only show specified type.""" + artist_mbid = str(uuid.uuid4()) + self.lib.add( + Album( + album="album", + albumartist="artist", + mb_albumartistid=artist_mbid, + mb_albumid="album", + mb_releasegroupid="album_id", ) + ) + requests_mock.get( + re.compile(r"/ws/2/release-group.*type=compilation"), + json={ + "release-groups": [ + {"id": "compilation_id", "title": "compilation"} + ] + }, + ) + + output = self.run_with_output( + "missing", "-a", "--release-type", "compilation" + ) + + assert "artist - compilation" in output + + def test_release_type_multiple_types(self, requests_mock): + """Test multiple --release-type flags include all specified types.""" + artist_mbid = str(uuid.uuid4()) + self.lib.add( + Album( + album="album", + albumartist="artist", + mb_albumartistid=artist_mbid, + mb_albumid="album", + mb_releasegroupid="album_id", + ) + ) + requests_mock.get( + re.compile(r"/ws/2/release-group.*type=compilation%7Calbum"), + json={ + "release-groups": [ + {"id": "album2_id", "title": "title 2"}, + {"id": "compilation_id", "title": "compilation"}, + ] + }, + ) + + output = self.run_with_output( + "missing", + "-a", + "--release-type", + "compilation", + "--release-type", + "album", + ) + + assert "artist - compilation" in output + assert "artist - title 2" in output + + def test_missing_albums_total(self, requests_mock): + """Test -t flag with --album shows total count of missing albums.""" + artist_mbid = str(uuid.uuid4()) + self.lib.add( + Album( + album="album", + albumartist="artist", + mb_albumartistid=artist_mbid, + mb_albumid="album", + mb_releasegroupid="album_id", + ) + ) + requests_mock.get( + f"/ws/2/release-group?artist={artist_mbid}", + json={ + "release-groups": [ + {"id": "album_id", "title": "album"}, + {"id": "other_id", "title": "other"}, + ] + }, + ) + + output = self.run_with_output("missing", "-a", "-t") + + assert output == "1\n" + + +class TestMissingTracks(PluginMixin, TestHelper): + """Tests for missing tracks functionality.""" + + plugin = "missing" + + @pytest.fixture(autouse=True) + def _setup(self): + self.setup_beets() + yield + self.teardown_beets() + + @pytest.mark.parametrize( + "total,count,expected", + [ + (True, False, "1\n"), + (False, True, "artist - album: 1"), + ], + ) + @patch("beets.metadata_plugins.album_for_id") + def test_missing_tracks(self, album_for_id, total, count, expected): + """Test getting missing tracks works with expected output.""" + artist_mbid = str(uuid.uuid4()) + album_items = [ + Item( + album="album", + mb_albumid="81ae60d4-5b75-38df-903a-db2cfa51c2c6", + mb_releasegroupid="album_id", + mb_trackid="track_1", + mb_albumartistid=artist_mbid, + albumartist="artist", + tracktotal=3, + ), + Item( + album="album", + mb_albumid="81ae60d4-5b75-38df-903a-db2cfa51c2c6", + mb_releasegroupid="album_id", + mb_albumartistid=artist_mbid, + albumartist="artist", + tracktotal=3, + ), + Item( + album="album", + mb_albumid="81ae60d4-5b75-38df-903a-db2cfa51c2c6", + mb_releasegroupid="album_id", + mb_trackid="track_3", + mb_albumartistid=artist_mbid, + albumartist="artist", + tracktotal=3, + ), + ] + self.lib.add_album(album_items[:2]) + + album_for_id.return_value = AlbumInfo( + album_id="album_id", + album="album", + tracks=[ + TrackInfo(track_id=item.mb_trackid) for item in album_items + ], + ) + + command = ["missing"] + if total: + command.append("-t") + if count: + command.append("-c") + + assert expected in self.run_with_output(*command) From dda1e8329e0d93c59d35e4c9705c35c0240bf66e Mon Sep 17 00:00:00 2001 From: Brock Grassy Date: Wed, 4 Feb 2026 16:25:46 -0500 Subject: [PATCH 2/2] Fix lint and mypy --- beetsplug/_utils/musicbrainz.py | 9 ++++++--- beetsplug/missing.py | 26 +++++++++++++------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/beetsplug/_utils/musicbrainz.py b/beetsplug/_utils/musicbrainz.py index 57e5860d4..33fb22380 100644 --- a/beetsplug/_utils/musicbrainz.py +++ b/beetsplug/_utils/musicbrainz.py @@ -238,10 +238,13 @@ class MusicBrainzAPI(RequestHandler): At least one of artist, collection, or release must be provided. Optionally filter by release_type (e.g., ["album", "ep"]). """ + api_params: dict[str, Any] = dict(kwargs) # MusicBrainz API uses "type" parameter for release type filtering - if release_type := kwargs.pop("release_type", None): - kwargs["type"] = "|".join(release_type) - return self._get_resource("release-group", **kwargs)["release-groups"] + if release_type := api_params.pop("release_type", None): + api_params["type"] = "|".join(release_type) + return self._get_resource("release-group", **api_params)[ + "release-groups" + ] @singledispatchmethod @classmethod diff --git a/beetsplug/missing.py b/beetsplug/missing.py index 164c34e28..d5ac037dc 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -22,6 +22,19 @@ from typing import TYPE_CHECKING, ClassVar import requests +from beets import config, metadata_plugins +from beets.dbcore import types +from beets.library import Item +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand, print_ + +from ._utils.musicbrainz import MusicBrainzAPIMixin + +if TYPE_CHECKING: + from collections.abc import Iterator + + from beets.library import Album, Library + # Valid MusicBrainz release types for filtering release groups VALID_RELEASE_TYPES = [ "nat", @@ -41,19 +54,6 @@ VALID_RELEASE_TYPES = [ "mixtape/street", ] -from beets import config, metadata_plugins -from beets.dbcore import types -from beets.library import Item -from beets.plugins import BeetsPlugin -from beets.ui import Subcommand, print_ - -from ._utils.musicbrainz import MusicBrainzAPIMixin - -if TYPE_CHECKING: - from collections.abc import Iterator - - from beets.library import Album, Library - MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$"