Change missing plugin to allow for filtering albums by release type (#5587)

Addresses #2661.  

Currently, the missing plugin when ran in album mode only allows for
getting all release groups attached to a single artist. Users may want
to restrict this search to only show releases of a specific type (such
as albums or compilations). This CR adds a new `--release-type` flag to
the missing plugin. If users want to filter to a specific type (or set
of types), they simply need to provide this flag for every release type
that they want included.

As part of this change, the default behavior has been shifted to only
select `album` type releases as is suggested in the issue- to avoid
breaking default behavior I could easily switch this back. I am also
wondering if it might make sense to address the following idea
(https://github.com/beetbox/beets/discussions/5101) in a follow-up.
Bug fix: This change also fixes a bug where `--album` mode incorrectly
reported albums already in the library as missing. The original code
compared release group IDs from MusicBrainz against `Album` objects
(which never matched). This now correctly compares against
`mb_releasegroupid`.
This commit is contained in:
Šarūnas Nejus 2026-03-16 06:58:12 +00:00 committed by GitHub
commit 2565a74ad7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 273 additions and 22 deletions

View file

@ -109,6 +109,7 @@ class BrowseReleaseGroupsKwargs(BrowseKwargs, total=False):
artist: NotRequired[str]
collection: NotRequired[str]
release: NotRequired[str]
type: NotRequired[str]
class BrowseRecordingsKwargs(BrowseReleaseGroupsKwargs, total=False):
@ -283,8 +284,9 @@ 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 type (e.g., "album|ep").
"""
return self._get_resource("release-group", **kwargs)["release-groups"]
return self._browse("release-group", **kwargs)
@singledispatchmethod
@classmethod

View file

@ -35,6 +35,25 @@ if TYPE_CHECKING:
from beets.library import Album, Library
# 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",
]
MB_ARTIST_QUERY = r"mb_albumartistid::^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$"
@ -108,6 +127,7 @@ class MissingPlugin(MusicBrainzAPIMixin, BeetsPlugin):
"count": False,
"total": False,
"album": False,
"release_types": ["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 album releases for artist instead of tracks; "
"Defaults to only releases of type 'album'"
),
)
self._command.parser.add_option(
"--release-types",
action="append",
dest="release_types",
help=(
"comma-separated list of release types for missing albums "
f"(valid: {', '.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,19 @@ 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_types = []
for rt in self.config["release_types"].as_str_seq():
release_types.extend(rt.split(","))
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,
type="|".join(release_types),
)
except requests.exceptions.RequestException:
self._log.info(
"Couldn't fetch info for artist '{}' ({})",

View file

@ -20,10 +20,17 @@ New features
library. This is useful when items have been imported in don't copy-move (``-C
-M``) mode in the library but are later passed through the ``convert`` plugin
which will regenerate new paths according to the Beets path format.
- :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``.
- :ref:`replace`: Made ``drive_sep_replace`` regex logic more precise to prevent
edge-case mismatches (e.g., a song titled "1:00 AM" would incorrectly be
considered a Windows drive path).

View file

@ -27,12 +27,20 @@ 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.
provided, it uses the configured
``missing.release_type`` (default: "album").
…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
-------------
@ -110,6 +118,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:
::

View file

@ -1,8 +1,13 @@
"""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 IOMixin, PluginMixin, TestHelper
@ -20,13 +25,9 @@ def helper(request):
@pytest.mark.usefixtures("helper")
class TestMissingAlbums(IOMixin, PluginMixin):
"""Tests for missing albums functionality."""
plugin = "missing"
album_in_lib = Album(
album="Album",
albumartist="Artist",
mb_albumartistid=str(uuid.uuid4()),
mb_albumid="album",
)
@pytest.mark.parametrize(
"release_from_mb,expected_output",
@ -37,14 +38,8 @@ class TestMissingAlbums(IOMixin, 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",
),
],
@ -52,11 +47,199 @@ class TestMissingAlbums(IOMixin, PluginMixin):
def test_missing_artist_albums(
self, requests_mock, release_from_mb, expected_output
):
self.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}",
re.compile(
rf"/ws/2/release-group\?artist={artist_mbid}&.*type=album"
),
json={"release-groups": [release_from_mb]},
)
with self.configure_plugin({}):
assert self.run_with_output("missing", "--album") == expected_output
def test_release_types_filters_results(self, requests_mock):
"""Test --release-types 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"}
]
},
)
with self.configure_plugin({}):
output = self.run_with_output(
"missing", "-a", "--release-types", "compilation"
)
assert "artist - compilation" in output
def test_release_types_comma_separated(self, requests_mock):
"""Test --release-types with comma-separated values."""
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"},
]
},
)
with self.configure_plugin({}):
output = self.run_with_output(
"missing",
"-a",
"--release-types",
"compilation,album",
)
assert "artist - compilation" in output
assert "artist - title 2" in output
def test_empty_release_types_config_sends_empty_type(self, requests_mock):
"""Test that release_types: [] in config sends type="" to the API."""
artist_mbid = str(uuid.uuid4())
self.lib.add(
Album(
album="album",
albumartist="artist",
mb_albumartistid=artist_mbid,
mb_albumid="album",
mb_releasegroupid="album_id",
)
)
adapter = requests_mock.get(
re.compile(r"/ws/2/release-group"),
json={"release-groups": []},
)
with self.configure_plugin({"release_types": []}):
self.run_with_output("missing", "-a")
assert adapter.last_request.qs["type"] == [""]
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(
re.compile(
rf"/ws/2/release-group\?artist={artist_mbid}&.*type=album"
),
json={
"release-groups": [
{"id": "album_id", "title": "album"},
{"id": "other_id", "title": "other"},
]
},
)
with self.configure_plugin({}):
output = self.run_with_output("missing", "-a", "-t")
assert output == "1\n"
@pytest.mark.usefixtures("helper")
class TestMissingTracks(IOMixin, PluginMixin):
"""Tests for missing tracks functionality."""
plugin = "missing"
@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")
with self.configure_plugin({}):
assert expected in self.run_with_output(*command)