Skip to content

Conversation

@snejus
Copy link
Member

@snejus snejus commented Sep 1, 2025

Add a unified search abstraction across metadata source plugins.

Summary:

  • Introduces SearchApiMetadataSourcePlugin with SearchParams, get_search_filters, and get_search_response hooks to standardize album/track searches.
  • Replaces ad‑hoc _search_api and query construction logic in Deezer, Spotify, MusicBrainz, and Discogs plugins with the new shared implementation.
  • Injects search_limit via a common path (added automatically as a filter) and applies optional ASCII normalization once.
  • Refactors Discogs and MusicBrainz plugins to use the new abstraction; factors their criteria building into get_search_filters.
  • Centralizes error handling and logging; Deezer & Spotify now return empty tuples on failures or transparently re-authenticate (Spotify).

Summary by Sourcery

Centralize and standardize autotagger search functionality by introducing a shared SearchApiMetadataSourcePlugin and refactoring existing metadata source plugins to use it

New Features:

  • Introduce SearchApiMetadataSourcePlugin with SearchParams and hooks for unified album/track searches

Enhancements:

  • Refactor Deezer, Spotify, MusicBrainz, and Discogs plugins to use the shared search abstraction
  • Centralize ASCII normalization, search limit injection, error handling, and logging in the unified search API
  • Add Spotify transparent re-authentication on 401 errors and ensure Deezer returns empty results on failures

Tests:

  • Update MusicBrainz plugin tests to adapt to the new search response handling and ID mapping

@snejus snejus requested review from JOJ0 and semohr September 1, 2025 11:18
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Sep 1, 2025

Reviewer's Guide

Adds a shared SearchApiMetadataSourcePlugin abstraction to centralize query building, normalization, limit injection, error handling, and logging, then refactors all existing metadata source plugins and related tests to adopt the new hooks.

Sequence diagram for unified search flow in metadata source plugins

sequenceDiagram
    participant Plugin as SearchApiMetadataSourcePlugin
    participant UserCode as Importer/Autotagger
    participant API as External API (e.g. Spotify, Deezer)
    UserCode->>Plugin: candidates(items, artist, album, va_likely)
    Plugin->>Plugin: get_search_filters(query_type, items, artist, name, va_likely)
    Plugin->>Plugin: _search_api(query_type, query, filters)
    Plugin->>Plugin: get_search_response(SearchParams)
    Plugin->>API: Perform API request
    API-->>Plugin: Return results
    Plugin-->>UserCode: Return AlbumInfo/TrackInfo objects
Loading

Class diagram for the new SearchApiMetadataSourcePlugin abstraction and plugin refactoring

classDiagram
    class SearchApiMetadataSourcePlugin {
        +config
        +_log
        +get_search_filters(query_type, items, artist, name, va_likely)
        +get_search_response(params)
        +_search_api(query_type, query, filters)
        +_get_candidates(query_type, ...)
        +candidates(items, artist, album, va_likely)
        +item_candidates(item, artist, title)
    }
    class SearchParams {
        +query_type: QueryType
        +query: str
        +filters: dict[str, str]
    }
    class IDResponse {
        +id: str
    }
    SearchApiMetadataSourcePlugin <|-- SpotifyPlugin
    SearchApiMetadataSourcePlugin <|-- DeezerPlugin
    SearchApiMetadataSourcePlugin <|-- MusicBrainzPlugin
    SearchApiMetadataSourcePlugin <|-- DiscogsPlugin
    SearchParams <.. SearchApiMetadataSourcePlugin
    IDResponse <.. SearchApiMetadataSourcePlugin
    class SpotifyPlugin {
        +get_search_response(params)
    }
    class DeezerPlugin {
        +get_search_response(params)
    }
    class MusicBrainzPlugin {
        +get_search_filters(...)
        +get_search_response(params)
    }
    class DiscogsPlugin {
        +get_search_filters(...)
        +get_search_response(params)
    }
Loading

Class diagram for SearchParams and its usage

classDiagram
    class SearchParams {
        +query_type: QueryType
        +query: str
        +filters: dict[str, str]
    }
    SearchParams <.. SearchApiMetadataSourcePlugin: uses
Loading

File-Level Changes

Change Details Files
Centralize search logic in metadata_plugins.py by introducing SearchApiMetadataSourcePlugin
  • Define SearchParams NamedTuple and QueryType alias, replacing the old SearchFilter TypedDict
  • Add abstract get_search_filters and get_search_response hooks
  • Implement shared _search_api with ASCII normalization, limit injection, centralized error handling and logging
  • Introduce _get_candidates helper and update type annotations, removing legacy query builders
beets/metadata_plugins.py
Migrate plugins to use the new search abstraction
  • Remove custom _search_api implementations and query construction in Spotify, Deezer, MusicBrainz, and Discogs
  • Implement get_search_filters and get_search_response per plugin to build filters and parse API responses
  • Add Spotify reauthentication on HTTP 401 failures
  • Simplify candidate and album/track lookup methods to delegate to base class logic
beetsplug/spotify.py
beetsplug/deezer.py
beetsplug/musicbrainz.py
beetsplug/discogs.py
Update MusicBrainz plugin tests for new search behavior
  • Adjust RECORDING fixture to use the new response ID format
  • Monkeypatch get_recording_by_id to align test expectations with the new hook
test/plugins/test_musicbrainz.py

Possibly linked issues

  • mbsync: Tolerate MusicBrainz recording ID changes #1234: The PR centralizes search functionality, making unidecode optional via a config setting, which directly resolves the issue of incorrect non-Latin search results in the Deezer plugin.
  • Playlists #123: The PR centralizes search_limit injection for Spotify, addressing the issue's request to improve search_limit handling.

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

@snejus snejus changed the base branch from master to add-configurable-search-limit September 1, 2025 11:21
@snejus
Copy link
Member Author

snejus commented Sep 1, 2025

@sourcery-ai review

And centralise common search functionality inside the parent class
@snejus snejus force-pushed the centralise-common-autotagger-search branch from 7234d74 to 1a6fb85 Compare September 1, 2025 11:23
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 - here's some feedback:

  • Consider marking get_search_filters as abstract so that each plugin must explicitly implement its own query-building logic instead of relying on the current no-op default.
  • Align error handling and retry logic by moving the Spotify 401 re-authentication and other exception management into the base _search_api (or a shared hook) to ensure consistent behavior across all plugins.
  • The QueryType alias is currently only defined under TYPE_CHECKING and may not be available at runtime—move its definition out of the TYPE_CHECKING guard to avoid missing references in annotations.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Consider marking get_search_filters as abstract so that each plugin must explicitly implement its own query-building logic instead of relying on the current no-op default.
- Align error handling and retry logic by moving the Spotify 401 re-authentication and other exception management into the base _search_api (or a shared hook) to ensure consistent behavior across all plugins.
- The QueryType alias is currently only defined under TYPE_CHECKING and may not be available at runtime—move its definition out of the TYPE_CHECKING guard to avoid missing references in annotations.

## Individual Comments

### Comment 1
<location> `beetsplug/discogs.py:246` </location>
<code_context>
-        return filter(None, map(self.get_album_info, releases))
+        return f"{artist} - {name}", {"type": "release"}
+
+    def get_search_response(self, params: SearchParams) -> Sequence[IDResponse]:
+        """Returns a list of AlbumInfo objects for a discogs search query."""
+        limit = params.filters.pop("limit")
+        results = self.discogs_client.search(params.query, **params.filters)
+        results.per_page = limit
+        return [r.data for r in results.page(1)]

     @cache
</code_context>

<issue_to_address>
Potential issue with 'limit' key removal from filters.

Using pop without a default will raise KeyError if 'limit' is missing. Use params.filters.pop('limit', None) to prevent exceptions.
</issue_to_address>

### Comment 2
<location> `beetsplug/discogs.py:241` </location>
<code_context>
+        name: str,
+        va_likely: bool,
+    ) -> tuple[str, dict[str, str]]:
+        if va_likely:
+            artist = items[0].artist

-        try:
</code_context>

<issue_to_address>
Possible unintended override of artist for VA releases.

Using items[0].artist for VA releases could be inaccurate if items include multiple artists. Please verify that this logic aligns with the expected handling of VA releases.
</issue_to_address>

### Comment 3
<location> `beetsplug/spotify.py:437` </location>
<code_context>
-        )
-        return response_data
+            response.raise_for_status()
+        except requests.exceptions.HTTPError:
+            if response.status_code == 401:
+                self._authenticate()
+                return self.get_search_response(params)
+
+            raise
+
+        return response.json().get(f"{params.query_type}s", {}).get("items", [])
</code_context>

<issue_to_address>
Recursive call to get_search_response on authentication failure could lead to infinite recursion.

Add a retry limit or alternative handling to prevent stack overflow from repeated authentication failures.
</issue_to_address>

### Comment 4
<location> `test/plugins/test_musicbrainz.py:1043` </location>
<code_context>
+            lambda *_, **__: {"recording": self.RECORDING},
+        )

         candidates = list(mb.item_candidates(Item(), "hello", "there"))

</code_context>

<issue_to_address>
Test only covers successful candidate retrieval; missing error/edge case coverage.

Please add tests for scenarios like empty results, exceptions, or malformed data from the search API to verify error handling and logging.

Suggested implementation:

```python
    mbid = "d2a6f856-b553-40a0-ac54-a321e8e2da99"
    RECORDING = {"title": "foo", "id": mbid, "length": 42}

    @pytest.fixture
    def plugin_config(self):
            "musicbrainzngs.search_recordings",
            lambda *_, **__: {"recording-list": [self.RECORDING]},
        )
        monkeypatch.setattr(

    def test_item_candidates_empty_results(monkeypatch):
        # Simulate empty results from the search API
        monkeypatch.setattr(
            "musicbrainzngs.search_recordings",
            lambda *_, **__: {"recording-list": []},
        )
        mb = MyMusicBrainzPlugin()
        candidates = list(mb.item_candidates(Item(), "hello", "there"))
        assert candidates == [], "Expected no candidates for empty results"

    def test_item_candidates_exception(monkeypatch, caplog):
        # Simulate an exception from the search API
        def raise_exception(*_, **__):
            raise RuntimeError("API failure")
        monkeypatch.setattr(
            "musicbrainzngs.search_recordings",
            raise_exception,
        )
        mb = MyMusicBrainzPlugin()
        with caplog.at_level("ERROR"):
            candidates = list(mb.item_candidates(Item(), "hello", "there"))
            assert candidates == [], "Expected no candidates when exception occurs"
            assert any("API failure" in m for m in caplog.text.splitlines()), "Expected error to be logged"

    def test_item_candidates_malformed_data(monkeypatch, caplog):
        # Simulate malformed data from the search API
        monkeypatch.setattr(
            "musicbrainzngs.search_recordings",
            lambda *_, **__: {"unexpected-key": "unexpected-value"},
        )
        mb = MyMusicBrainzPlugin()
        with caplog.at_level("ERROR"):
            candidates = list(mb.item_candidates(Item(), "hello", "there"))
            assert candidates == [], "Expected no candidates for malformed data"
            assert any("malformed" in m.lower() or "unexpected" in m.lower() for m in caplog.text.splitlines()), "Expected malformed data error to be logged"

```

- You may need to adjust the error handling and logging in your plugin code (`item_candidates`) to ensure that exceptions and malformed data are properly caught and logged as errors.
- The test assumes the existence of a `MyMusicBrainzPlugin` class and an `Item` class, and that logging is performed using Python's standard logging module.
- If your plugin uses a different logging mechanism, update the tests accordingly.
</issue_to_address>

### Comment 5
<location> `test/plugins/test_musicbrainz.py:995` </location>
<code_context>
-    RECORDING = {"title": "foo", "id": "bar", "length": 42}
+    RECORDING = {"title": "foo", "id": mbid, "length": 42}

     @pytest.fixture
     def plugin_config(self):
</code_context>

<issue_to_address>
No tests for album candidate search with new abstraction.

Please add or update tests for album candidate search with the new abstraction, covering both VA and non-VA albums.

Suggested implementation:

```python
    mbid = "d2a6f856-b553-40a0-ac54-a321e8e2da99"
    RECORDING = {"title": "foo", "id": mbid, "length": 42}

    ALBUM_MBID = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
    VA_ALBUM_MBID = "va1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
    ALBUM = {
        "title": "Test Album",
        "id": ALBUM_MBID,
        "artist-credit": [{"artist": {"name": "Test Artist"}}],
        "release-group": {"type": "Album"},
        "release-list": [],
    }
    VA_ALBUM = {
        "title": "VA Compilation",
        "id": VA_ALBUM_MBID,
        "artist-credit": [{"artist": {"name": "Various Artists"}}],
        "release-group": {"type": "Album"},
        "release-list": [],
    }

```

```python
    @pytest.fixture
    def plugin_config(self):
        # Existing setup for recordings
        monkeypatch.setattr(
            "musicbrainzngs.search_recordings",
            lambda *_, **__: {"recording-list": [self.RECORDING]},
        )
        monkeypatch.setattr(
            "musicbrainzngs.get_recording_by_id",
            lambda *_, **__: {"recording": self.RECORDING},
        )
        # Setup for album candidate search
        monkeypatch.setattr(
            "musicbrainzngs.search_releases",
            lambda *_, **__: {"release-list": [self.ALBUM, self.VA_ALBUM]},
        )
        monkeypatch.setattr(
            "musicbrainzngs.get_release_by_id",
            lambda id, *_, **__: {"release": self.ALBUM if id == self.ALBUM_MBID else self.VA_ALBUM},
        )

        candidates = list(mb.item_candidates(Item(), "hello", "there"))

    def test_album_candidate_search_non_va(self, plugin_config):
        # Test non-VA album candidate search
        candidates = list(mb.album_candidates("Test Album", "Test Artist"))
        assert any(c["id"] == self.ALBUM_MBID for c in candidates)
        assert all("Various Artists" not in c["artist-credit"][0]["artist"]["name"] for c in candidates)

    def test_album_candidate_search_va(self, plugin_config):
        # Test VA album candidate search
        candidates = list(mb.album_candidates("VA Compilation", "Various Artists"))
        assert any(c["id"] == self.VA_ALBUM_MBID for c in candidates)
        assert any("Various Artists" in c["artist-credit"][0]["artist"]["name"] for c in candidates)

```

- Ensure that the `mb.album_candidates` abstraction exists and is implemented in your codebase. If not, you will need to implement it to match the new abstraction for album candidate search.
- If your test class uses `self`, make sure the test methods are part of a class and that the setup assigns the album/VA album attributes to `self`.
- Adjust the monkeypatching and test logic to fit your actual plugin and test structure.
</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.

Comment on lines +246 to +251
def get_search_response(self, params: SearchParams) -> Sequence[IDResponse]:
"""Returns a list of AlbumInfo objects for a discogs search query."""
limit = params.filters.pop("limit")
results = self.discogs_client.search(params.query, **params.filters)
results.per_page = limit
return [r.data for r in results.page(1)]
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Potential issue with 'limit' key removal from filters.

Using pop without a default will raise KeyError if 'limit' is missing. Use params.filters.pop('limit', None) to prevent exceptions.

Comment on lines +241 to +242
if va_likely:
artist = items[0].artist
Copy link
Contributor

Choose a reason for hiding this comment

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

question (bug_risk): Possible unintended override of artist for VA releases.

Using items[0].artist for VA releases could be inaccurate if items include multiple artists. Please verify that this logic aligns with the expected handling of VA releases.

Comment on lines +437 to +442
except requests.exceptions.HTTPError:
if response.status_code == 401:
self._authenticate()
return self.get_search_response(params)

raise
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Recursive call to get_search_response on authentication failure could lead to infinite recursion.

Add a retry limit or alternative handling to prevent stack overflow from repeated authentication failures.

lambda *_, **__: {"recording": self.RECORDING},
)

candidates = list(mb.item_candidates(Item(), "hello", "there"))
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (testing): Test only covers successful candidate retrieval; missing error/edge case coverage.

Please add tests for scenarios like empty results, exceptions, or malformed data from the search API to verify error handling and logging.

Suggested implementation:

    mbid = "d2a6f856-b553-40a0-ac54-a321e8e2da99"
    RECORDING = {"title": "foo", "id": mbid, "length": 42}

    @pytest.fixture
    def plugin_config(self):
            "musicbrainzngs.search_recordings",
            lambda *_, **__: {"recording-list": [self.RECORDING]},
        )
        monkeypatch.setattr(

    def test_item_candidates_empty_results(monkeypatch):
        # Simulate empty results from the search API
        monkeypatch.setattr(
            "musicbrainzngs.search_recordings",
            lambda *_, **__: {"recording-list": []},
        )
        mb = MyMusicBrainzPlugin()
        candidates = list(mb.item_candidates(Item(), "hello", "there"))
        assert candidates == [], "Expected no candidates for empty results"

    def test_item_candidates_exception(monkeypatch, caplog):
        # Simulate an exception from the search API
        def raise_exception(*_, **__):
            raise RuntimeError("API failure")
        monkeypatch.setattr(
            "musicbrainzngs.search_recordings",
            raise_exception,
        )
        mb = MyMusicBrainzPlugin()
        with caplog.at_level("ERROR"):
            candidates = list(mb.item_candidates(Item(), "hello", "there"))
            assert candidates == [], "Expected no candidates when exception occurs"
            assert any("API failure" in m for m in caplog.text.splitlines()), "Expected error to be logged"

    def test_item_candidates_malformed_data(monkeypatch, caplog):
        # Simulate malformed data from the search API
        monkeypatch.setattr(
            "musicbrainzngs.search_recordings",
            lambda *_, **__: {"unexpected-key": "unexpected-value"},
        )
        mb = MyMusicBrainzPlugin()
        with caplog.at_level("ERROR"):
            candidates = list(mb.item_candidates(Item(), "hello", "there"))
            assert candidates == [], "Expected no candidates for malformed data"
            assert any("malformed" in m.lower() or "unexpected" in m.lower() for m in caplog.text.splitlines()), "Expected malformed data error to be logged"
  • You may need to adjust the error handling and logging in your plugin code (item_candidates) to ensure that exceptions and malformed data are properly caught and logged as errors.
  • The test assumes the existence of a MyMusicBrainzPlugin class and an Item class, and that logging is performed using Python's standard logging module.
  • If your plugin uses a different logging mechanism, update the tests accordingly.

RECORDING = {"title": "foo", "id": "bar", "length": 42}
RECORDING = {"title": "foo", "id": mbid, "length": 42}

@pytest.fixture
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (testing): No tests for album candidate search with new abstraction.

Please add or update tests for album candidate search with the new abstraction, covering both VA and non-VA albums.

Suggested implementation:

    mbid = "d2a6f856-b553-40a0-ac54-a321e8e2da99"
    RECORDING = {"title": "foo", "id": mbid, "length": 42}

    ALBUM_MBID = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
    VA_ALBUM_MBID = "va1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
    ALBUM = {
        "title": "Test Album",
        "id": ALBUM_MBID,
        "artist-credit": [{"artist": {"name": "Test Artist"}}],
        "release-group": {"type": "Album"},
        "release-list": [],
    }
    VA_ALBUM = {
        "title": "VA Compilation",
        "id": VA_ALBUM_MBID,
        "artist-credit": [{"artist": {"name": "Various Artists"}}],
        "release-group": {"type": "Album"},
        "release-list": [],
    }
    @pytest.fixture
    def plugin_config(self):
        # Existing setup for recordings
        monkeypatch.setattr(
            "musicbrainzngs.search_recordings",
            lambda *_, **__: {"recording-list": [self.RECORDING]},
        )
        monkeypatch.setattr(
            "musicbrainzngs.get_recording_by_id",
            lambda *_, **__: {"recording": self.RECORDING},
        )
        # Setup for album candidate search
        monkeypatch.setattr(
            "musicbrainzngs.search_releases",
            lambda *_, **__: {"release-list": [self.ALBUM, self.VA_ALBUM]},
        )
        monkeypatch.setattr(
            "musicbrainzngs.get_release_by_id",
            lambda id, *_, **__: {"release": self.ALBUM if id == self.ALBUM_MBID else self.VA_ALBUM},
        )

        candidates = list(mb.item_candidates(Item(), "hello", "there"))

    def test_album_candidate_search_non_va(self, plugin_config):
        # Test non-VA album candidate search
        candidates = list(mb.album_candidates("Test Album", "Test Artist"))
        assert any(c["id"] == self.ALBUM_MBID for c in candidates)
        assert all("Various Artists" not in c["artist-credit"][0]["artist"]["name"] for c in candidates)

    def test_album_candidate_search_va(self, plugin_config):
        # Test VA album candidate search
        candidates = list(mb.album_candidates("VA Compilation", "Various Artists"))
        assert any(c["id"] == self.VA_ALBUM_MBID for c in candidates)
        assert any("Various Artists" in c["artist-credit"][0]["artist"]["name"] for c in candidates)
  • Ensure that the mb.album_candidates abstraction exists and is implemented in your codebase. If not, you will need to implement it to match the new abstraction for album candidate search.
  • If your test class uses self, make sure the test methods are part of a class and that the setup assigns the album/VA album attributes to self.
  • Adjust the monkeypatching and test logic to fit your actual plugin and test structure.

@codecov
Copy link

codecov bot commented Sep 1, 2025

Codecov Report

❌ Patch coverage is 70.42254% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.59%. Comparing base (17bc110) to head (1a6fb85).
⚠️ Report is 72 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
beetsplug/discogs.py 33.33% 8 Missing ⚠️
beets/metadata_plugins.py 78.12% 7 Missing ⚠️
beetsplug/spotify.py 54.54% 5 Missing ⚠️
beetsplug/deezer.py 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #5982      +/-   ##
==========================================
+ Coverage   66.49%   66.59%   +0.10%     
==========================================
  Files         117      117              
  Lines       18119    18102      -17     
  Branches     3070     3071       +1     
==========================================
+ Hits        12048    12055       +7     
+ Misses       5415     5390      -25     
- Partials      656      657       +1     
Files with missing lines Coverage Δ
beetsplug/musicbrainz.py 72.97% <100.00%> (+0.82%) ⬆️
beetsplug/deezer.py 17.96% <75.00%> (+1.30%) ⬆️
beetsplug/spotify.py 44.98% <54.54%> (-1.07%) ⬇️
beets/metadata_plugins.py 83.21% <78.12%> (+5.27%) ⬆️
beetsplug/discogs.py 66.86% <33.33%> (+0.99%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Base automatically changed from add-configurable-search-limit to master September 4, 2025 11:42
@semohr semohr added the core Pull requests that modify the beets core `beets` label Sep 16, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Pull requests that modify the beets core `beets`

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants