From d4d265e7e00b9d58badb14deb8839d1e3fab7de8 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Tue, 31 Mar 2026 19:33:56 -0400 Subject: [PATCH 01/14] batch spotify requests --- beetsplug/spotify.py | 124 ++++++++++++++++--- docs/changelog.rst | 3 + test/plugins/test_spotify.py | 224 +++++++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+), 14 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 2415fcc1cf..900ce5df5e 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -112,7 +112,9 @@ class SpotifyPlugin( open_track_url = "https://open.spotify.com/track/" search_url = "https://api.spotify.com/v1/search" album_url = "https://api.spotify.com/v1/albums/" + tracks_url = "https://api.spotify.com/v1/tracks" track_url = "https://api.spotify.com/v1/tracks/" + audio_features_batch_url = "https://api.spotify.com/v1/audio-features" audio_features_url = "https://api.spotify.com/v1/audio-features/" spotify_audio_features: ClassVar[dict[str, str]] = { @@ -259,7 +261,7 @@ def _handle_response( ) elif e.response.status_code == 403: # Check if this is the audio features endpoint - if url.startswith(self.audio_features_url): + if url.startswith(self.audio_features_batch_url): raise AudioFeaturesUnavailableError( "Audio features API returned 403 " "(deprecated or unavailable)" @@ -722,11 +724,99 @@ def _output_match_results(self, results): "No {.data_source} tracks found from beets query", self ) + @staticmethod + def _chunked(ids: Sequence[str], chunk_size: int) -> list[list[str]]: + """Split IDs into deterministic chunks for Spotify batch endpoints.""" + return [ids[i : i + chunk_size] for i in range(0, len(ids), chunk_size)] + + def _disable_audio_features(self) -> None: + """Disable audio features globally and warn only once.""" + should_log = False + with self._audio_features_lock: + if self.audio_features_available: + self.audio_features_available = False + should_log = True + if should_log: + self._log.warning( + "Audio features API is unavailable (403 error). " + "Skipping audio features for remaining tracks." + ) + + def track_info_batch( + self, track_ids: Sequence[str] + ) -> dict[str, tuple[Any, str | None, str | None, str | None]]: + """Fetch popularity and external IDs in batches of 50 tracks.""" + if not track_ids: + return {} + + info_by_id: dict[str, tuple[Any, str | None, str | None, str | None]] = {} + for chunk in self._chunked(track_ids, 50): + track_data = self._handle_response( + "get", + self.tracks_url, + params={"ids": ",".join(chunk)}, + ) + + for idx, track in enumerate(track_data.get("tracks", [])): + if track is None: + continue + + external_ids = track.get("external_ids", {}) + track_id = track.get("id") or chunk[idx] + info_by_id[track_id] = ( + track.get("popularity"), + external_ids.get("isrc"), + external_ids.get("ean"), + external_ids.get("upc"), + ) + + for track_id in chunk: + info_by_id.setdefault(track_id, (None, None, None, None)) + + return info_by_id + + def track_audio_features_batch( + self, track_ids: Sequence[str] + ) -> dict[str, JSONDict]: + """Fetch track audio features in batches of 100 tracks.""" + if not track_ids: + return {} + + with self._audio_features_lock: + if not self.audio_features_available: + return {} + + features_by_id: dict[str, JSONDict] = {} + try: + for chunk in self._chunked(track_ids, 100): + features_data = self._handle_response( + "get", + self.audio_features_batch_url, + params={"ids": ",".join(chunk)}, + ) + + for idx, feature_data in enumerate( + features_data.get("audio_features", []) + ): + if feature_data is None: + continue + track_id = feature_data.get("id") or chunk[idx] + features_by_id[track_id] = feature_data + return features_by_id + except AudioFeaturesUnavailableError: + self._disable_audio_features() + return {} + except APIError as e: + self._log.debug("Spotify API error: {}", e) + return {} + def _fetch_info(self, items, write, force): """Obtain track information from Spotify.""" self._log.debug("Total {} tracks", len(items)) + items_to_update: list[tuple[Any, str]] = [] + for index, item in enumerate(items, start=1): self._log.info( "Processing {}/{} tracks - {} ", index, len(items), item @@ -743,14 +833,30 @@ def _fetch_info(self, items, write, force): self._log.debug("No track_id present for: {}", item) continue - popularity, isrc, ean, upc = self.track_info(spotify_track_id) + items_to_update.append((item, spotify_track_id)) + + if not items_to_update: + return + + track_ids = [track_id for _, track_id in items_to_update] + unique_track_ids = list(dict.fromkeys(track_ids)) + track_info_by_id = self.track_info_batch(unique_track_ids) + audio_features_by_id = self.track_audio_features_batch( + unique_track_ids + ) + + for item, spotify_track_id in items_to_update: + popularity, isrc, ean, upc = track_info_by_id.get( + spotify_track_id, (None, None, None, None) + ) + item["spotify_track_popularity"] = popularity item["isrc"] = isrc item["ean"] = ean item["upc"] = upc if self.audio_features_available: - audio_features = self.track_audio_features(spotify_track_id) + audio_features = audio_features_by_id.get(spotify_track_id) if audio_features is None: self._log.info("No audio features found for: {}", item) else: @@ -799,17 +905,7 @@ def track_audio_features(self, track_id: str): "get", f"{self.audio_features_url}{track_id}" ) except AudioFeaturesUnavailableError: - # Disable globally in a thread-safe manner and warn once. - should_log = False - with self._audio_features_lock: - if self.audio_features_available: - self.audio_features_available = False - should_log = True - if should_log: - self._log.warning( - "Audio features API is unavailable (403 error). " - "Skipping audio features for remaining tracks." - ) + self._disable_audio_features() return None except APIError as e: self._log.debug("Spotify API error: {}", e) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2221780633..8f43cc5e7d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,9 @@ Bug fixes Other changes ~~~~~~~~~~~~~ +- :doc:`plugins/spotify`: Batch ``spotifysync`` track and audio-features API + requests and deduplicate repeated Spotify track IDs within a run. + 2.8.0 (March 28, 2026) ---------------------- diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index 6e322ca0bf..4515c7351e 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -1,5 +1,6 @@ """Tests for the 'spotify' plugin""" +import json import os from urllib.parse import parse_qs, urlparse @@ -307,3 +308,226 @@ def test_multiartist_album_and_track(self): assert track_info.artists == ["Foo", "Bar"] assert track_info.artist_id == "12345" assert track_info.artists_ids == ["12345", "67890"] + + @responses.activate + def test_track_info_batch_chunks_requests(self): + ids_per_request = [] + + def callback(request): + ids = _params(request.url)["ids"][0].split(",") + ids_per_request.append(len(ids)) + return ( + 200, + {"Content-Type": "application/json"}, + json.dumps( + { + "tracks": [ + { + "id": track_id, + "popularity": 50, + "external_ids": {}, + } + for track_id in ids + ] + } + ), + ) + + responses.add_callback( + responses.GET, + spotify.SpotifyPlugin.tracks_url, + callback=callback, + content_type="application/json", + ) + + track_ids = [f"track-{idx}" for idx in range(51)] + track_info = self.spotify.track_info_batch(track_ids) + + assert len(track_info) == 51 + assert ids_per_request == [50, 1] + + @responses.activate + def test_fetch_info_uses_batch_endpoints(self): + responses.add( + responses.GET, + spotify.SpotifyPlugin.tracks_url, + status=200, + json={ + "tracks": [ + { + "id": "id-1", + "popularity": 10, + "external_ids": { + "isrc": "isrc-1", + "ean": "ean-1", + "upc": "upc-1", + }, + }, + { + "id": "id-2", + "popularity": 20, + "external_ids": { + "isrc": "isrc-2", + "ean": "ean-2", + "upc": "upc-2", + }, + }, + { + "id": "id-3", + "popularity": 30, + "external_ids": { + "isrc": "isrc-3", + "ean": "ean-3", + "upc": "upc-3", + }, + }, + ] + }, + content_type="application/json", + ) + responses.add( + responses.GET, + spotify.SpotifyPlugin.audio_features_batch_url, + status=200, + json={ + "audio_features": [ + {"id": "id-1", "tempo": 100.1, "energy": 0.4}, + {"id": "id-2", "tempo": 110.2, "energy": 0.5}, + {"id": "id-3", "tempo": 120.3, "energy": 0.6}, + ] + }, + content_type="application/json", + ) + + items = [] + for idx in range(1, 4): + item = Item(title=f"Track {idx}", artist="Artist", length=10) + item.add(self.lib) + item["spotify_track_id"] = f"id-{idx}" + items.append(item) + + self.spotify._fetch_info(items, write=False, force=True) + + get_calls = [ + call + for call in responses.calls + if call.request.method == "GET" + ] + batch_track_calls = [ + call + for call in get_calls + if urlparse(call.request.url).path == "/v1/tracks" + ] + single_track_calls = [ + call + for call in get_calls + if urlparse(call.request.url).path.startswith("/v1/tracks/") + ] + batch_audio_calls = [ + call + for call in get_calls + if urlparse(call.request.url).path == "/v1/audio-features" + ] + single_audio_calls = [ + call + for call in get_calls + if urlparse(call.request.url).path.startswith( + "/v1/audio-features/" + ) + ] + + assert len(batch_track_calls) == 1 + assert len(single_track_calls) == 0 + assert len(batch_audio_calls) == 1 + assert len(single_audio_calls) == 0 + + assert items[0]["spotify_track_popularity"] == 10 + assert items[1]["spotify_track_popularity"] == 20 + assert items[2]["spotify_track_popularity"] == 30 + + assert items[0]["spotify_tempo"] == 100.1 + assert items[1]["spotify_tempo"] == 110.2 + assert items[2]["spotify_tempo"] == 120.3 + + @responses.activate + def test_fetch_info_deduplicates_batch_ids(self): + seen_track_ids = [] + seen_audio_ids = [] + + def track_callback(request): + ids = _params(request.url)["ids"][0].split(",") + seen_track_ids.append(ids) + return ( + 200, + {"Content-Type": "application/json"}, + json.dumps( + { + "tracks": [ + { + "id": track_id, + "popularity": 50, + "external_ids": {}, + } + for track_id in ids + ] + } + ), + ) + + def audio_callback(request): + ids = _params(request.url)["ids"][0].split(",") + seen_audio_ids.append(ids) + return ( + 200, + {"Content-Type": "application/json"}, + json.dumps( + { + "audio_features": [ + {"id": track_id, "tempo": 100.0} + for track_id in ids + ] + } + ), + ) + + responses.add_callback( + responses.GET, + spotify.SpotifyPlugin.tracks_url, + callback=track_callback, + content_type="application/json", + ) + responses.add_callback( + responses.GET, + spotify.SpotifyPlugin.audio_features_batch_url, + callback=audio_callback, + content_type="application/json", + ) + + items = [] + for idx in range(2): + item = Item(title=f"Track {idx}", artist="Artist", length=10) + item.add(self.lib) + item["spotify_track_id"] = "shared-id" + items.append(item) + + self.spotify._fetch_info(items, write=False, force=True) + + assert seen_track_ids == [["shared-id"]] + assert seen_audio_ids == [["shared-id"]] + assert items[0]["spotify_track_popularity"] == 50 + assert items[1]["spotify_track_popularity"] == 50 + + @responses.activate + def test_track_audio_features_batch_disables_on_403(self): + responses.add( + responses.GET, + spotify.SpotifyPlugin.audio_features_batch_url, + status=403, + json={"error": {"status": 403}}, + content_type="application/json", + ) + + assert self.spotify.track_audio_features_batch(["id-1"]) == {} + assert self.spotify.audio_features_available is False + assert self.spotify.track_audio_features_batch(["id-2"]) == {} + assert len(responses.calls) == 1 From 2d6711e40417549140baaa6d4d08550b1bf912de Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Tue, 31 Mar 2026 19:41:51 -0400 Subject: [PATCH 02/14] lint --- beetsplug/spotify.py | 8 ++++---- test/plugins/test_spotify.py | 11 +++-------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 900ce5df5e..dc1d19c1f6 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -749,7 +749,9 @@ def track_info_batch( if not track_ids: return {} - info_by_id: dict[str, tuple[Any, str | None, str | None, str | None]] = {} + info_by_id: dict[ + str, tuple[Any, str | None, str | None, str | None] + ] = {} for chunk in self._chunked(track_ids, 50): track_data = self._handle_response( "get", @@ -841,9 +843,7 @@ def _fetch_info(self, items, write, force): track_ids = [track_id for _, track_id in items_to_update] unique_track_ids = list(dict.fromkeys(track_ids)) track_info_by_id = self.track_info_batch(unique_track_ids) - audio_features_by_id = self.track_audio_features_batch( - unique_track_ids - ) + audio_features_by_id = self.track_audio_features_batch(unique_track_ids) for item, spotify_track_id in items_to_update: popularity, isrc, ean, upc = track_info_by_id.get( diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index 4515c7351e..a194c3e69e 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -409,9 +409,7 @@ def test_fetch_info_uses_batch_endpoints(self): self.spotify._fetch_info(items, write=False, force=True) get_calls = [ - call - for call in responses.calls - if call.request.method == "GET" + call for call in responses.calls if call.request.method == "GET" ] batch_track_calls = [ call @@ -431,9 +429,7 @@ def test_fetch_info_uses_batch_endpoints(self): single_audio_calls = [ call for call in get_calls - if urlparse(call.request.url).path.startswith( - "/v1/audio-features/" - ) + if urlparse(call.request.url).path.startswith("/v1/audio-features/") ] assert len(batch_track_calls) == 1 @@ -483,8 +479,7 @@ def audio_callback(request): json.dumps( { "audio_features": [ - {"id": track_id, "tempo": 100.0} - for track_id in ids + {"id": track_id, "tempo": 100.0} for track_id in ids ] } ), From 913149ef8f44046744c06f4d884cc1becd204392 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Tue, 31 Mar 2026 19:45:29 -0400 Subject: [PATCH 03/14] Fix Spotify batch helper typing for mypy --- beetsplug/spotify.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index dc1d19c1f6..ac5e03604f 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -727,7 +727,10 @@ def _output_match_results(self, results): @staticmethod def _chunked(ids: Sequence[str], chunk_size: int) -> list[list[str]]: """Split IDs into deterministic chunks for Spotify batch endpoints.""" - return [ids[i : i + chunk_size] for i in range(0, len(ids), chunk_size)] + return [ + list(ids[i : i + chunk_size]) + for i in range(0, len(ids), chunk_size) + ] def _disable_audio_features(self) -> None: """Disable audio features globally and warn only once.""" From 75cfa0d751bf2834121ae2ba1ed49e42a6512393 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Tue, 31 Mar 2026 19:49:04 -0400 Subject: [PATCH 04/14] Address reviewer coments --- beetsplug/spotify.py | 33 ++++++++++++++--------------- docs/changelog.rst | 5 ++--- test/plugins/test_spotify.py | 40 ++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index ac5e03604f..a70a00f101 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -792,28 +792,29 @@ def track_audio_features_batch( return {} features_by_id: dict[str, JSONDict] = {} - try: - for chunk in self._chunked(track_ids, 100): + for chunk in self._chunked(track_ids, 100): + try: features_data = self._handle_response( "get", self.audio_features_batch_url, params={"ids": ",".join(chunk)}, ) + except AudioFeaturesUnavailableError: + self._disable_audio_features() + break + except APIError as e: + self._log.debug("Spotify API error: {}", e) + continue - for idx, feature_data in enumerate( - features_data.get("audio_features", []) - ): - if feature_data is None: - continue - track_id = feature_data.get("id") or chunk[idx] - features_by_id[track_id] = feature_data - return features_by_id - except AudioFeaturesUnavailableError: - self._disable_audio_features() - return {} - except APIError as e: - self._log.debug("Spotify API error: {}", e) - return {} + for idx, feature_data in enumerate( + features_data.get("audio_features", []) + ): + if feature_data is None: + continue + track_id = feature_data.get("id") or chunk[idx] + features_by_id[track_id] = feature_data + + return features_by_id def _fetch_info(self, items, write, force): """Obtain track information from Spotify.""" diff --git a/docs/changelog.rst b/docs/changelog.rst index 8f43cc5e7d..ffca3d082d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -23,9 +23,8 @@ Bug fixes For plugin developers ~~~~~~~~~~~~~~~~~~~~~ -.. - Other changes - ~~~~~~~~~~~~~ +Other changes +~~~~~~~~~~~~~ - :doc:`plugins/spotify`: Batch ``spotifysync`` track and audio-features API requests and deduplicate repeated Spotify track IDs within a run. diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index a194c3e69e..5cc111e6da 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -526,3 +526,43 @@ def test_track_audio_features_batch_disables_on_403(self): assert self.spotify.audio_features_available is False assert self.spotify.track_audio_features_batch(["id-2"]) == {} assert len(responses.calls) == 1 + + @responses.activate + def test_track_audio_features_batch_keeps_partial_results_on_api_error( + self, + ): + def callback(request): + ids = _params(request.url)["ids"][0].split(",") + if "track-100" in ids: + return ( + 502, + {"Content-Type": "application/json"}, + json.dumps({"error": {"status": 502}}), + ) + return ( + 200, + {"Content-Type": "application/json"}, + json.dumps( + { + "audio_features": [ + {"id": track_id, "tempo": 100.0} for track_id in ids + ] + } + ), + ) + + responses.add_callback( + responses.GET, + spotify.SpotifyPlugin.audio_features_batch_url, + callback=callback, + content_type="application/json", + ) + + track_ids = [f"track-{idx}" for idx in range(201)] + features = self.spotify.track_audio_features_batch(track_ids) + + assert "track-0" in features + assert "track-99" in features + assert "track-100" not in features + assert "track-199" not in features + assert "track-200" in features From cd06e620ce8f3a5ae377b407cee33270f2af946c Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Mon, 13 Apr 2026 20:35:16 -0400 Subject: [PATCH 05/14] Resolve merge conflict in changelog.rst --- docs/changelog.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e82482ab70..bce261bf0c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -139,15 +139,12 @@ For plugin developers respective multi-valued fields instead (``arrangers``, ``composers``, ``lyricists``, ``remixers``). -<<<<<<< HEAD Other changes ~~~~~~~~~~~~~ - :doc:`plugins/spotify`: Batch ``spotifysync`` track and audio-features API requests and deduplicate repeated Spotify track IDs within a run. -======= ->>>>>>> upstream/master 2.8.0 (March 28, 2026) ---------------------- From 826aaeb4b38b6a2bed03a89feb2b7c2473423a19 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sat, 18 Apr 2026 21:07:48 -0400 Subject: [PATCH 06/14] Address reviewer comments --- beets/library/migrations.py | 14 +---- beets/util/__init__.py | 6 ++ beetsplug/spotify.py | 103 +++++++++++++++++++++-------------- docs/changelog.rst | 15 ++--- test/plugins/test_spotify.py | 18 +++--- 5 files changed, 82 insertions(+), 74 deletions(-) diff --git a/beets/library/migrations.py b/beets/library/migrations.py index 3b196d1419..05bc2b3dea 100644 --- a/beets/library/migrations.py +++ b/beets/library/migrations.py @@ -3,7 +3,7 @@ import os from contextlib import suppress from functools import cached_property -from typing import TYPE_CHECKING, ClassVar, NamedTuple, TypeVar +from typing import TYPE_CHECKING, ClassVar, NamedTuple from confuse.exceptions import ConfigError @@ -12,23 +12,13 @@ from beets.dbcore.db import Migration from beets.dbcore.pathutils import normalize_path_for_db from beets.dbcore.types import MULTI_VALUE_DELIMITER -from beets.util import unique_list +from beets.util import chunks, unique_list from beets.util.lyrics import Lyrics if TYPE_CHECKING: - from collections.abc import Iterator - from beets.dbcore.db import Model from beets.library import Library -T = TypeVar("T") - - -def chunks(lst: list[T], n: int) -> Iterator[list[T]]: - """Yield successive n-sized chunks from lst.""" - for i in range(0, len(lst), n): - yield lst[i : i + n] - class MultiValueFieldMigration(Migration): """Backfill multi-valued field from legacy single-string values.""" diff --git a/beets/util/__init__.py b/beets/util/__init__.py index d4ce8ab122..25715eb97f 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -1204,3 +1204,9 @@ def get_temp_filename( def unique_list(elements: Iterable[T]) -> list[T]: """Return a list with unique elements in the original order.""" return list(dict.fromkeys(elements)) + + +def chunks(lst: list[T], n: int) -> Iterator[list[T]]: + """Yield successive n-sized chunks from lst.""" + for i in range(0, len(lst), n): + yield lst[i : i + n] diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index a70a00f101..cca9e1692b 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -28,13 +28,14 @@ import time import webbrowser from http import HTTPStatus -from typing import TYPE_CHECKING, Any, ClassVar, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal, NamedTuple, TypedDict import confuse import requests from beets import ui from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.util import chunks from beets.dbcore import types from beets.library import Library from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin @@ -49,6 +50,33 @@ DEFAULT_WAITING_TIME = 5 +class SpotifyTrackInfo(NamedTuple): + """Popularity and external IDs returned by the /v1/tracks batch endpoint.""" + + popularity: int | None + isrc: str | None + ean: str | None + upc: str | None + + +class SpotifyAudioFeatureData(TypedDict, total=False): + """Audio feature fields returned by the /v1/audio-features endpoint.""" + + id: str + acousticness: float + danceability: float + energy: float + instrumentalness: float + key: int + liveness: float + loudness: float + mode: int + speechiness: float + tempo: float + time_signature: int + valence: float + + class SearchResponseAlbums(IDResponse): """A response returned by the Spotify API. @@ -112,10 +140,8 @@ class SpotifyPlugin( open_track_url = "https://open.spotify.com/track/" search_url = "https://api.spotify.com/v1/search" album_url = "https://api.spotify.com/v1/albums/" - tracks_url = "https://api.spotify.com/v1/tracks" - track_url = "https://api.spotify.com/v1/tracks/" - audio_features_batch_url = "https://api.spotify.com/v1/audio-features" - audio_features_url = "https://api.spotify.com/v1/audio-features/" + track_url = "https://api.spotify.com/v1/tracks" + audio_features_url = "https://api.spotify.com/v1/audio-features" spotify_audio_features: ClassVar[dict[str, str]] = { "acousticness": "spotify_acousticness", @@ -261,7 +287,7 @@ def _handle_response( ) elif e.response.status_code == 403: # Check if this is the audio features endpoint - if url.startswith(self.audio_features_batch_url): + if url.startswith(self.audio_features_url): raise AudioFeaturesUnavailableError( "Audio features API returned 403 " "(deprecated or unavailable)" @@ -446,7 +472,7 @@ def track_for_id(self, track_id: str) -> None | TrackInfo: if not ( track_data := self._handle_response( - "get", f"{self.track_url}{spotify_id}" + "get", f"{self.track_url}/{spotify_id}" ) ): self._log.debug("Track not found: {}", track_id) @@ -724,14 +750,6 @@ def _output_match_results(self, results): "No {.data_source} tracks found from beets query", self ) - @staticmethod - def _chunked(ids: Sequence[str], chunk_size: int) -> list[list[str]]: - """Split IDs into deterministic chunks for Spotify batch endpoints.""" - return [ - list(ids[i : i + chunk_size]) - for i in range(0, len(ids), chunk_size) - ] - def _disable_audio_features(self) -> None: """Disable audio features globally and warn only once.""" should_log = False @@ -747,18 +765,16 @@ def _disable_audio_features(self) -> None: def track_info_batch( self, track_ids: Sequence[str] - ) -> dict[str, tuple[Any, str | None, str | None, str | None]]: + ) -> dict[str, SpotifyTrackInfo]: """Fetch popularity and external IDs in batches of 50 tracks.""" if not track_ids: return {} - info_by_id: dict[ - str, tuple[Any, str | None, str | None, str | None] - ] = {} - for chunk in self._chunked(track_ids, 50): + info_by_id: dict[str, SpotifyTrackInfo] = {} + for chunk in chunks(list(track_ids), 50): track_data = self._handle_response( "get", - self.tracks_url, + self.track_url, params={"ids": ",".join(chunk)}, ) @@ -768,21 +784,23 @@ def track_info_batch( external_ids = track.get("external_ids", {}) track_id = track.get("id") or chunk[idx] - info_by_id[track_id] = ( - track.get("popularity"), - external_ids.get("isrc"), - external_ids.get("ean"), - external_ids.get("upc"), + info_by_id[track_id] = SpotifyTrackInfo( + popularity=track.get("popularity"), + isrc=external_ids.get("isrc"), + ean=external_ids.get("ean"), + upc=external_ids.get("upc"), ) for track_id in chunk: - info_by_id.setdefault(track_id, (None, None, None, None)) + info_by_id.setdefault( + track_id, SpotifyTrackInfo(None, None, None, None) + ) return info_by_id def track_audio_features_batch( self, track_ids: Sequence[str] - ) -> dict[str, JSONDict]: + ) -> dict[str, SpotifyAudioFeatureData]: """Fetch track audio features in batches of 100 tracks.""" if not track_ids: return {} @@ -791,12 +809,12 @@ def track_audio_features_batch( if not self.audio_features_available: return {} - features_by_id: dict[str, JSONDict] = {} - for chunk in self._chunked(track_ids, 100): + features_by_id: dict[str, SpotifyAudioFeatureData] = {} + for chunk in chunks(list(track_ids), 100): try: features_data = self._handle_response( "get", - self.audio_features_batch_url, + self.audio_features_url, params={"ids": ",".join(chunk)}, ) except AudioFeaturesUnavailableError: @@ -821,7 +839,7 @@ def _fetch_info(self, items, write, force): self._log.debug("Total {} tracks", len(items)) - items_to_update: list[tuple[Any, str]] = [] + items_to_update: list[tuple[Item, str]] = [] for index, item in enumerate(items, start=1): self._log.info( @@ -844,20 +862,21 @@ def _fetch_info(self, items, write, force): if not items_to_update: return - track_ids = [track_id for _, track_id in items_to_update] - unique_track_ids = list(dict.fromkeys(track_ids)) + unique_track_ids = list( + dict.fromkeys(track_id for _, track_id in items_to_update) + ) track_info_by_id = self.track_info_batch(unique_track_ids) audio_features_by_id = self.track_audio_features_batch(unique_track_ids) for item, spotify_track_id in items_to_update: - popularity, isrc, ean, upc = track_info_by_id.get( - spotify_track_id, (None, None, None, None) + track_info = track_info_by_id.get( + spotify_track_id, SpotifyTrackInfo(None, None, None, None) ) - item["spotify_track_popularity"] = popularity - item["isrc"] = isrc - item["ean"] = ean - item["upc"] = upc + item["spotify_track_popularity"] = track_info.popularity + item["isrc"] = track_info.isrc + item["ean"] = track_info.ean + item["upc"] = track_info.upc if self.audio_features_available: audio_features = audio_features_by_id.get(spotify_track_id) @@ -877,7 +896,7 @@ def _fetch_info(self, items, write, force): def track_info(self, track_id: str): """Fetch a track's popularity and external IDs using its Spotify ID.""" - track_data = self._handle_response("get", f"{self.track_url}{track_id}") + track_data = self._handle_response("get", f"{self.track_url}/{track_id}") external_ids = track_data.get("external_ids", {}) popularity = track_data.get("popularity") self._log.debug( @@ -906,7 +925,7 @@ def track_audio_features(self, track_id: str): try: return self._handle_response( - "get", f"{self.audio_features_url}{track_id}" + "get", f"{self.audio_features_url}/{track_id}" ) except AudioFeaturesUnavailableError: self._disable_audio_features() diff --git a/docs/changelog.rst b/docs/changelog.rst index bce261bf0c..b67c42aa42 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -43,15 +43,14 @@ For plugin developers ``TypedDict`` models for releases, recordings, works, and relations. Update direct access to raw MusicBrainz response keys if needed. -.. - Other changes - ~~~~~~~~~~~~~ +Other changes +~~~~~~~~~~~~~ - :doc:`plugins/spotify`: Batch ``spotifysync`` track and audio-features API requests and deduplicate repeated Spotify track IDs within a run. -2.8.0 (March 28, 2026) ----------------------- +2.9.0 (April 11, 2026) +----------------------- Beets now officially supports Python 3.14. @@ -139,12 +138,6 @@ For plugin developers respective multi-valued fields instead (``arrangers``, ``composers``, ``lyricists``, ``remixers``). -Other changes -~~~~~~~~~~~~~ - -- :doc:`plugins/spotify`: Batch ``spotifysync`` track and audio-features API - requests and deduplicate repeated Spotify track IDs within a run. - 2.8.0 (March 28, 2026) ---------------------- diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index 5cc111e6da..5f472f2167 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -133,7 +133,7 @@ def test_track_for_id(self): responses.add( responses.GET, - f"{spotify.SpotifyPlugin.track_url}6NPVjNh8Jhru9xOmyQigds", + f"{spotify.SpotifyPlugin.track_url}/6NPVjNh8Jhru9xOmyQigds", body=response_body, status=200, content_type="application/json", @@ -280,7 +280,7 @@ def test_multiartist_album_and_track(self): responses.add( responses.GET, - f"{spotify.SpotifyPlugin.track_url}6sjZfVJworBX6TqyjkxIJ1", + f"{spotify.SpotifyPlugin.track_url}/6sjZfVJworBX6TqyjkxIJ1", body=track_response_body, status=200, content_type="application/json", @@ -335,7 +335,7 @@ def callback(request): responses.add_callback( responses.GET, - spotify.SpotifyPlugin.tracks_url, + spotify.SpotifyPlugin.track_url, callback=callback, content_type="application/json", ) @@ -350,7 +350,7 @@ def callback(request): def test_fetch_info_uses_batch_endpoints(self): responses.add( responses.GET, - spotify.SpotifyPlugin.tracks_url, + spotify.SpotifyPlugin.track_url, status=200, json={ "tracks": [ @@ -387,7 +387,7 @@ def test_fetch_info_uses_batch_endpoints(self): ) responses.add( responses.GET, - spotify.SpotifyPlugin.audio_features_batch_url, + spotify.SpotifyPlugin.audio_features_url, status=200, json={ "audio_features": [ @@ -487,13 +487,13 @@ def audio_callback(request): responses.add_callback( responses.GET, - spotify.SpotifyPlugin.tracks_url, + spotify.SpotifyPlugin.track_url, callback=track_callback, content_type="application/json", ) responses.add_callback( responses.GET, - spotify.SpotifyPlugin.audio_features_batch_url, + spotify.SpotifyPlugin.audio_features_url, callback=audio_callback, content_type="application/json", ) @@ -516,7 +516,7 @@ def audio_callback(request): def test_track_audio_features_batch_disables_on_403(self): responses.add( responses.GET, - spotify.SpotifyPlugin.audio_features_batch_url, + spotify.SpotifyPlugin.audio_features_url, status=403, json={"error": {"status": 403}}, content_type="application/json", @@ -553,7 +553,7 @@ def callback(request): responses.add_callback( responses.GET, - spotify.SpotifyPlugin.audio_features_batch_url, + spotify.SpotifyPlugin.audio_features_url, callback=callback, content_type="application/json", ) From 99b81c550fe6a7fbe9ced5ddc6ad84a4df103a96 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sat, 18 Apr 2026 21:16:39 -0400 Subject: [PATCH 07/14] lint --- beetsplug/spotify.py | 26 ++++++++++++++------------ docs/changelog.rst | 2 +- test/plugins/test_spotify.py | 4 ++-- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index cca9e1692b..d6c4677de5 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -16,6 +16,7 @@ """Adds Spotify release and track search support to the autotagger. Also includes Spotify playlist construction. + """ from __future__ import annotations @@ -35,10 +36,10 @@ from beets import ui from beets.autotag.hooks import AlbumInfo, TrackInfo -from beets.util import chunks from beets.dbcore import types from beets.library import Library from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin +from beets.util import chunks if TYPE_CHECKING: from collections.abc import Sequence @@ -240,8 +241,8 @@ def _handle_response( :param method: HTTP method to use for the request. :param url: URL for the new :class:`Request` object. - :param dict params: (optional) list of tuples or bytes to send - in the query string for the :class:`Request`. + :param dict params: (optional) list of tuples or bytes to send in the + query string for the :class:`Request`. """ @@ -329,10 +330,7 @@ def _handle_response( def _multi_artist_credit( self, artists: list[dict[str | int, str]] ) -> tuple[list[str], list[str]]: - """Given a list of artist dictionaries, accumulate data into a pair - of lists: the first being the artist names, and the second being the - artist IDs. - """ + """Accumulate data from artist dicts into name and ID lists.""" artist_names = [] artist_ids = [] for artist in artists: @@ -341,8 +339,9 @@ def _multi_artist_credit( return artist_names, artist_ids def album_for_id(self, album_id: str) -> AlbumInfo | None: - """Fetch an album by its Spotify ID or URL and return an - AlbumInfo object or None if the album is not found. + """Fetch an album by its Spotify ID or URL. + + Returns an AlbumInfo object, or None if the album is not found. :param str album_id: Spotify ID or URL for the album @@ -516,6 +515,7 @@ def get_search_response( Unauthorized responses trigger one token refresh attempt before the method gives up and falls back to an empty result set. + """ for _ in range(2): response = requests.get( @@ -618,8 +618,8 @@ def _match_library_tracks(self, library: Library, keywords: str): :param library: beets library object to query. :param keywords: Query to match library items against. - :returns: List of simplified track object dicts for library - items matching the specified query. + :returns: List of simplified track object dicts for library items + matching the specified query. """ results = [] @@ -896,7 +896,9 @@ def _fetch_info(self, items, write, force): def track_info(self, track_id: str): """Fetch a track's popularity and external IDs using its Spotify ID.""" - track_data = self._handle_response("get", f"{self.track_url}/{track_id}") + track_data = self._handle_response( + "get", f"{self.track_url}/{track_id}" + ) external_ids = track_data.get("external_ids", {}) popularity = track_data.get("popularity") self._log.debug( diff --git a/docs/changelog.rst b/docs/changelog.rst index d2482f2773..c34fcc7352 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -69,7 +69,7 @@ Other changes requests and deduplicate repeated Spotify track IDs within a run. 2.9.0 (April 11, 2026) ------------------------ +---------------------- Beets now officially supports Python 3.14. diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index 5f472f2167..746ebb8664 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -253,8 +253,8 @@ def test_japanese_track(self): @responses.activate def test_multiartist_album_and_track(self): - """Tests if plugin is able to map multiple artists in an album and - track info correctly""" + """Tests if plugin is able to map multiple artists in an album and track + info correctly.""" # Mock the Spotify 'Get Album' call json_file = os.path.join( From b8e029669c60a71eb722ebf12fe7c15ca29169c3 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sat, 18 Apr 2026 21:24:37 -0400 Subject: [PATCH 08/14] simplify --- beets/util/__init__.py | 2 +- beetsplug/spotify.py | 29 +++++++++++------------------ 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 25715eb97f..6e852a6dea 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -1206,7 +1206,7 @@ def unique_list(elements: Iterable[T]) -> list[T]: return list(dict.fromkeys(elements)) -def chunks(lst: list[T], n: int) -> Iterator[list[T]]: +def chunks(lst: Sequence[T], n: int) -> Iterator[list[T]]: """Yield successive n-sized chunks from lst.""" for i in range(0, len(lst), n): yield lst[i : i + n] diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index d6c4677de5..5737a881ca 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -39,7 +39,7 @@ from beets.dbcore import types from beets.library import Library from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin -from beets.util import chunks +from beets.util import chunks, unique_list if TYPE_CHECKING: from collections.abc import Sequence @@ -54,10 +54,10 @@ class SpotifyTrackInfo(NamedTuple): """Popularity and external IDs returned by the /v1/tracks batch endpoint.""" - popularity: int | None - isrc: str | None - ean: str | None - upc: str | None + popularity: int | None = None + isrc: str | None = None + ean: str | None = None + upc: str | None = None class SpotifyAudioFeatureData(TypedDict, total=False): @@ -771,7 +771,7 @@ def track_info_batch( return {} info_by_id: dict[str, SpotifyTrackInfo] = {} - for chunk in chunks(list(track_ids), 50): + for chunk in chunks(track_ids, 50): track_data = self._handle_response( "get", self.track_url, @@ -792,9 +792,7 @@ def track_info_batch( ) for track_id in chunk: - info_by_id.setdefault( - track_id, SpotifyTrackInfo(None, None, None, None) - ) + info_by_id.setdefault(track_id, SpotifyTrackInfo()) return info_by_id @@ -810,7 +808,7 @@ def track_audio_features_batch( return {} features_by_id: dict[str, SpotifyAudioFeatureData] = {} - for chunk in chunks(list(track_ids), 100): + for chunk in chunks(track_ids, 100): try: features_data = self._handle_response( "get", @@ -845,8 +843,6 @@ def _fetch_info(self, items, write, force): self._log.info( "Processing {}/{} tracks - {} ", index, len(items), item ) - # If we're not forcing re-downloading for all tracks, check - # whether the popularity data is already present if not force: if "spotify_track_popularity" in item: self._log.debug("Popularity already present for: {}", item) @@ -862,16 +858,14 @@ def _fetch_info(self, items, write, force): if not items_to_update: return - unique_track_ids = list( - dict.fromkeys(track_id for _, track_id in items_to_update) + unique_track_ids = unique_list( + track_id for _, track_id in items_to_update ) track_info_by_id = self.track_info_batch(unique_track_ids) audio_features_by_id = self.track_audio_features_batch(unique_track_ids) for item, spotify_track_id in items_to_update: - track_info = track_info_by_id.get( - spotify_track_id, SpotifyTrackInfo(None, None, None, None) - ) + track_info = track_info_by_id.get(spotify_track_id, SpotifyTrackInfo()) item["spotify_track_popularity"] = track_info.popularity item["isrc"] = track_info.isrc @@ -920,7 +914,6 @@ def track_audio_features(self, track_id: str): once. """ - # Fast path: if we've already detected unavailability, skip the call. with self._audio_features_lock: if not self.audio_features_available: return None From ea72d3f117332a82722ef7ea7f344306cdc86fc3 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sat, 18 Apr 2026 21:28:42 -0400 Subject: [PATCH 09/14] more lint --- beets/util/__init__.py | 2 +- beetsplug/spotify.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 6e852a6dea..1733c53ba6 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -1209,4 +1209,4 @@ def unique_list(elements: Iterable[T]) -> list[T]: def chunks(lst: Sequence[T], n: int) -> Iterator[list[T]]: """Yield successive n-sized chunks from lst.""" for i in range(0, len(lst), n): - yield lst[i : i + n] + yield list(lst[i : i + n]) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 5737a881ca..76e2354057 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -865,7 +865,9 @@ def _fetch_info(self, items, write, force): audio_features_by_id = self.track_audio_features_batch(unique_track_ids) for item, spotify_track_id in items_to_update: - track_info = track_info_by_id.get(spotify_track_id, SpotifyTrackInfo()) + track_info = track_info_by_id.get( + spotify_track_id, SpotifyTrackInfo() + ) item["spotify_track_popularity"] = track_info.popularity item["isrc"] = track_info.isrc From 2c7274fff19240e5fe6487d360edfb8d40185d17 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sun, 19 Apr 2026 09:08:55 -0400 Subject: [PATCH 10/14] Address reviewer comments and update related tests --- beetsplug/spotify.py | 71 +++++++++++++++++------------------- test/plugins/test_spotify.py | 4 +- 2 files changed, 35 insertions(+), 40 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 76e2354057..209056e6fd 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -29,7 +29,7 @@ import time import webbrowser from http import HTTPStatus -from typing import TYPE_CHECKING, Any, ClassVar, Literal, NamedTuple, TypedDict +from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict import confuse import requests @@ -39,7 +39,7 @@ from beets.dbcore import types from beets.library import Library from beets.metadata_plugins import IDResponse, SearchApiMetadataSourcePlugin -from beets.util import chunks, unique_list +from beets.util import chunks if TYPE_CHECKING: from collections.abc import Sequence @@ -51,16 +51,16 @@ DEFAULT_WAITING_TIME = 5 -class SpotifyTrackInfo(NamedTuple): +class TrackDetails(TypedDict): """Popularity and external IDs returned by the /v1/tracks batch endpoint.""" - popularity: int | None = None - isrc: str | None = None - ean: str | None = None - upc: str | None = None + spotify_track_popularity: int | None + isrc: str | None + ean: str | None + upc: str | None -class SpotifyAudioFeatureData(TypedDict, total=False): +class AudioFeatures(TypedDict, total=False): """Audio feature fields returned by the /v1/audio-features endpoint.""" id: str @@ -330,7 +330,10 @@ def _handle_response( def _multi_artist_credit( self, artists: list[dict[str | int, str]] ) -> tuple[list[str], list[str]]: - """Accumulate data from artist dicts into name and ID lists.""" + """Given a list of artist dictionaries, accumulate data into a pair + of lists: the first being the artist names, and the second being the + artist IDs. + """ artist_names = [] artist_ids = [] for artist in artists: @@ -339,9 +342,8 @@ def _multi_artist_credit( return artist_names, artist_ids def album_for_id(self, album_id: str) -> AlbumInfo | None: - """Fetch an album by its Spotify ID or URL. - - Returns an AlbumInfo object, or None if the album is not found. + """Fetch an album by its Spotify ID or URL and return an + AlbumInfo object or None if the album is not found. :param str album_id: Spotify ID or URL for the album @@ -763,14 +765,14 @@ def _disable_audio_features(self) -> None: "Skipping audio features for remaining tracks." ) - def track_info_batch( + def get_track_details_by_id( self, track_ids: Sequence[str] - ) -> dict[str, SpotifyTrackInfo]: + ) -> dict[str, TrackDetails]: """Fetch popularity and external IDs in batches of 50 tracks.""" if not track_ids: return {} - info_by_id: dict[str, SpotifyTrackInfo] = {} + details_by_id: dict[str, TrackDetails] = {} for chunk in chunks(track_ids, 50): track_data = self._handle_response( "get", @@ -784,21 +786,18 @@ def track_info_batch( external_ids = track.get("external_ids", {}) track_id = track.get("id") or chunk[idx] - info_by_id[track_id] = SpotifyTrackInfo( - popularity=track.get("popularity"), + details_by_id[track_id] = TrackDetails( + spotify_track_popularity=track.get("popularity"), isrc=external_ids.get("isrc"), ean=external_ids.get("ean"), upc=external_ids.get("upc"), ) - for track_id in chunk: - info_by_id.setdefault(track_id, SpotifyTrackInfo()) - - return info_by_id + return details_by_id def track_audio_features_batch( self, track_ids: Sequence[str] - ) -> dict[str, SpotifyAudioFeatureData]: + ) -> dict[str, AudioFeatures]: """Fetch track audio features in batches of 100 tracks.""" if not track_ids: return {} @@ -807,7 +806,7 @@ def track_audio_features_batch( if not self.audio_features_available: return {} - features_by_id: dict[str, SpotifyAudioFeatureData] = {} + features_by_id: dict[str, AudioFeatures] = {} for chunk in chunks(track_ids, 100): try: features_data = self._handle_response( @@ -825,10 +824,9 @@ def track_audio_features_batch( for idx, feature_data in enumerate( features_data.get("audio_features", []) ): - if feature_data is None: - continue - track_id = feature_data.get("id") or chunk[idx] - features_by_id[track_id] = feature_data + if feature_data: + track_id = feature_data.get("id") or chunk[idx] + features_by_id[track_id] = feature_data return features_by_id @@ -843,6 +841,8 @@ def _fetch_info(self, items, write, force): self._log.info( "Processing {}/{} tracks - {} ", index, len(items), item ) + # If we're not forcing re-downloading for all tracks, check + # whether the popularity data is already present if not force: if "spotify_track_popularity" in item: self._log.debug("Popularity already present for: {}", item) @@ -858,21 +858,15 @@ def _fetch_info(self, items, write, force): if not items_to_update: return - unique_track_ids = unique_list( - track_id for _, track_id in items_to_update + unique_track_ids = list( + dict.fromkeys(track_id for _, track_id in items_to_update) ) - track_info_by_id = self.track_info_batch(unique_track_ids) + track_details_by_id = self.get_track_details_by_id(unique_track_ids) audio_features_by_id = self.track_audio_features_batch(unique_track_ids) for item, spotify_track_id in items_to_update: - track_info = track_info_by_id.get( - spotify_track_id, SpotifyTrackInfo() - ) - - item["spotify_track_popularity"] = track_info.popularity - item["isrc"] = track_info.isrc - item["ean"] = track_info.ean - item["upc"] = track_info.upc + if track_details := track_details_by_id.get(spotify_track_id): + item.update(track_details) if self.audio_features_available: audio_features = audio_features_by_id.get(spotify_track_id) @@ -916,6 +910,7 @@ def track_audio_features(self, track_id: str): once. """ + # Fast path: if we've already detected unavailability, skip the call. with self._audio_features_lock: if not self.audio_features_available: return None diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index 746ebb8664..ff585181ae 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -310,7 +310,7 @@ def test_multiartist_album_and_track(self): assert track_info.artists_ids == ["12345", "67890"] @responses.activate - def test_track_info_batch_chunks_requests(self): + def test_get_track_details_by_id_chunks_requests(self): ids_per_request = [] def callback(request): @@ -341,7 +341,7 @@ def callback(request): ) track_ids = [f"track-{idx}" for idx in range(51)] - track_info = self.spotify.track_info_batch(track_ids) + track_info = self.spotify.get_track_details_by_id(track_ids) assert len(track_info) == 51 assert ids_per_request == [50, 1] From 2c0dbb173a207cb17d7a76ac9422103d002c80e2 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Mon, 20 Apr 2026 18:24:48 -0400 Subject: [PATCH 11/14] fix CI failure --- docs/changelog.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b40bcce18c..012164aacb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -180,8 +180,6 @@ New features order before passing it to the player. - :doc:`plugins/lyrics`: Add ``auto_ignore`` configuration option to skip fetching lyrics for items matching a beets query during auto import. -- :doc:`plugins/musicbrainz`: Use title aliases for releases, release groups, - and recordings. Bug fixes ~~~~~~~~~ From 48d6661f83f28d7510aa815aad38acd05fb7f884 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Mon, 20 Apr 2026 19:04:19 -0400 Subject: [PATCH 12/14] fix CI issues --- docs/changelog.rst | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index c9c8ff12b3..05979c6343 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -46,9 +46,11 @@ New features For plugin developers ~~~~~~~~~~~~~~~~~~~~~ -.. - Other changes - ~~~~~~~~~~~~~ +Other changes +~~~~~~~~~~~~~ + +- :doc:`plugins/spotify`: Batch ``spotifysync`` track and audio-features API + requests and deduplicate repeated Spotify track IDs within a run. 2.10.0 (April 19, 2026) ----------------------- @@ -114,12 +116,6 @@ For plugin developers ``TypedDict`` models for releases, recordings, works, and relations. Update direct access to raw MusicBrainz response keys if needed. -Other changes -~~~~~~~~~~~~~ - -- :doc:`plugins/spotify`: Batch ``spotifysync`` track and audio-features API - requests and deduplicate repeated Spotify track IDs within a run. - 2.9.0 (April 11, 2026) ---------------------- From 91e3562e716416f6fe4b9bf9769a5fb13ea8ab33 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Tue, 28 Apr 2026 08:12:51 -0400 Subject: [PATCH 13/14] revert unwanted changes --- docs/changelog.rst | 211 ++------------------------------------------- 1 file changed, 7 insertions(+), 204 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index add0900c19..ffca3d082d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,61 +9,15 @@ below! Unreleased ---------- -New features -~~~~~~~~~~~~ - -- :doc:`plugins/smartplaylist`: The ``splupdate`` command output is - restructured. The per-playlist summary now includes a track count. Per-track - details are shown only when ``-v`` flag is provided (``beet -v splupdate``). - The ``--pretend`` flag produces the same output but reports *"N playlists - would be updated"* instead of *"N playlists updated"*. The ``--format`` option - allows customizing the track line format. The ``--pretend-paths`` option was - removed (use ``--format='$path'`` instead). :bug:`6183` -- :ref:`import-cmd`: When importing an archive (zip, tar, rar, or 7z) with - ``move: yes``, the source archive is now removed after a successful import. - Archives are preserved if any file in the archive was not imported (e.g. - skipped as a duplicate, or the import was aborted), and in non-move import - modes. -- :doc:`plugins/fromfilename`: Support ``track`` prefix when parsing the track - number from the filename (e.g., ``track01.m4a``). -- **Tidal plugin**: Introduces a new plugin for fetching metadata from Tidal. It - supports album and track lookups by ID, including batch operations via - ``albums_for_ids`` and ``tracks_for_ids``. It also enables search by query as - well as identifier-based retrieval, with support for ISRC codes (tracks) and - barcode/EANs (albums). -- Add support for adding or modifying a subtitle (ID3 tag ``TIT3``) field - - This is an initial, relatively minimal implementation, but already fully - usable for common metadata workflows. We welcome feedback, improvement ideas, - and community contributions to further extend its capabilities. - - See :doc:`plugins/tidal` for more information. +.. + New features + ~~~~~~~~~~~~ Bug fixes ~~~~~~~~~ -- :ref:`import-cmd`: Multi-disc album detection now recognizes ``cassette``, - ``digital media``, and ``vinyl`` as disc markers (e.g. ``vinyl 1``, ``12 vinyl - 2``), in addition to the existing ``disc``, ``disk``, and ``cd`` markers. -- :ref:`import-cmd`: Tags with a zero distance penalty are no longer shown as - differences in the match display. Previously, custom ``distance_weights`` - could cause fields with no actual mismatch to appear in the ``≠`` line. -- Library path migration now also handles manually edited database rows where - item or album-art paths were stored as SQLite ``TEXT`` values instead of - bytes, so upgrading to the portable-path storage format no longer fails for - those libraries. :bug:`6561` -- :ref:`import-cmd` Fix duplicate album art files (e.g. ``cover.2.jpg``) being - created when re-importing albums with the :doc:`plugins/fetchart` plugin - enabled. Old album art is now properly removed when replacing duplicate albums - during import. :bug:`1264` :bug:`6205` -- :doc:`plugins/discogs`: Prevent duplicate featured artists in track artist - fields when the same artist is credited both in ``artists`` (for example with - ``Feat.`` join text) and ``extraartists`` as ``Featuring``. :bug:`6166` -- :ref:`import-cmd` Metadata source plugin ID lookups now correctly call each - plugin's own lookup method when running in parallel. :bug:`6583` -- Improve ``DBAccessError`` messages to help users diagnose database permission - issues more easily. The error message now mentions directory missing and file - permissions as potential causes. :bug:`1676` +- Correctly handle semicolon-delimited genre values from externally-tagged + files. :bug:`6450` .. For plugin developers @@ -75,159 +29,6 @@ Other changes - :doc:`plugins/spotify`: Batch ``spotifysync`` track and audio-features API requests and deduplicate repeated Spotify track IDs within a run. -2.10.0 (April 19, 2026) ------------------------ - -New features -~~~~~~~~~~~~ - -- **Beets library is now made portable**: item and album-art paths are now - stored relative to the library root in the database while remaining absolute - in the rest of beets. Path queries continue matching both library-relative - paths and absolute paths under the currently configured music directory under - the new storage model. The existing paths in the database are migrated - automatically the first time you run any ``beet`` command after the update. - :bug:`133` - - .. warning:: - - make sure you run ``beet version`` (or any other command) at least once - after upgrading to trigger the migration. Only then you can safely move - the library to a new location. - -- :doc:`plugins/inline`: Add access to the ``album`` or ``item`` object as - ``db_obj`` in inline fields. -- :doc:`plugins/discogs`: Import Discogs remixer, lyricist, composer, and - arranger credits into the multi-value ``remixers``, ``lyricists``, - ``composers``, and ``arrangers`` fields. :bug:`6380` -- :doc:`plugins/lyrics`: Add ``keep_synced`` config option and ``--keep-synced`` - CLI flag to skip re-fetching lyrics for tracks that already have synced - lyrics, even when ``force`` is enabled. :bug:`5249` -- :doc:`plugins/musicbrainz`: Use aliases for artist credit. -- Metadata source plugin searches and lookups are now executed concurrently, - speeding up lookups when multiple plugins (e.g. MusicBrainz and Spotify) are - enabled. - -Bug fixes -~~~~~~~~~ - -- :ref:`import-cmd` Automatically remux WAV files containing MP3 streams - (``WAVE_FORMAT_MPEGLAYER3``) to proper MP3 files during import, instead of - silently importing them with incorrect metadata. :bug:`6455` -- :doc:`plugins/listenbrainz`: Retry listenbrainz requests for temporary - failures. -- :doc:`plugins/chroma`: Do not produce MusicBrainz-sourced autotagger - candidates when the :doc:`plugins/musicbrainz` plugin is not enabled. The - chroma plugin now looks up the musicbrainz plugin through the metadata-source - registry instead of unconditionally instantiating its own private instance, - which also restores compatibility with :doc:`plugins/mbpseudo` for - chroma-triggered lookups. :bug:`6212` :bug:`6441` -- :ref:`import-cmd` Remove clutter from imported album folders. :bug:`5016` -- :doc:`plugins/web`: Fix a stored XSS vulnerability where unescaped metadata - fields (artist, album, title, comments, lyrics) could execute arbitrary - JavaScript in the browser. Template tags now use ``<%-`` (escaped - interpolation) instead of ``<%=`` (raw interpolation). - -For plugin developers -~~~~~~~~~~~~~~~~~~~~~ - -- Consumers of :py:class:`beetsplug._utils.musicbrainz.MusicBrainzAPI` now - receive normalized MusicBrainz payloads with underscore-separated field names - (for example ``artist_credit`` and ``release_group``) and grouped relation - lists such as ``work_relations``, ``release_relations``, and - ``url_relations``. The API responses are also now fully typed with concrete - ``TypedDict`` models for releases, recordings, works, and relations. Update - direct access to raw MusicBrainz response keys if needed. - -2.9.0 (April 11, 2026) ----------------------- - -Beets now officially supports Python 3.14. - -New features -~~~~~~~~~~~~ - -- :ref:`import-cmd` Use ffprobe to recognize format of any import music file - that has no extension. If the file cannot be recognized as a music file, leave - it alone. :bug:`4881` -- Query: Add ``has_cover_art`` computed field to query items by embedded cover - art presence. Users can now search for tracks with or without embedded artwork - using ``beet list has_cover_art:true`` or ``beet list has_cover_art:false``. -- :doc:`plugins/autobpm`: Add ``force`` configuration and CLI option and - deprecate ``overwrite``. -- :doc:`plugins/autobpm`: The "BPM already exists for item" log message can now - be hidden with the ``--quiet`` flag. -- :doc:`plugins/smartplaylist`: The list of available playlists shown when an - unknown playlist name is passed as an argument is now sorted alphabetically - and printed space-delimited and POSIX shell-quoted when required. This makes - it easier to copy and paste multiple playlists for further use in the shell. -- :doc:`plugins/chroma`: Add new command ``chromasearch`` to search the local - library by chromaprint fingerprint. -- Store track remixers, lyricists, composers, and arrangers in the multi-valued - ``remixers``, ``lyricists``, ``composers``, and ``arrangers`` fields instead - of the legacy single-value ``remixer``, ``lyricist``, ``composer``, and - ``arranger`` fields. Existing libraries are migrated automatically, and - :doc:`plugins/musicbrainz` now preserves each MusicBrainz ``remixer``, - ``lyricist``, ``composer``, and ``arranger`` relation as a separate value. -- :doc:`plugins/musicbrainz`: Store MBIDs for remixers, lyricists, composers, - and arrangers in the new multi-valued fields ``remixers_mbid``, - ``lyricists_mbid``, ``composers_mbid``, and ``arrangers_mbid``. :bug:`5698` -- :doc:`plugins/replaygain`: Conflicting replay gain tags are now removed on - write. RG_* tags are removed when setting R128_* and vice versa. -- :doc:`plugins/fetchart`: Add support for WebP images. -- :doc:`plugins/lastgenre`: Add support for a user-configurable ignorelist to - exclude unwanted or incorrect Last.fm (or existing) genres, either per artist - or globally :bug:`6449` - -Bug fixes -~~~~~~~~~ - -- :doc:`plugins/deezer`: Fix Various Artists albums being tagged with a - localized string instead of the configured ``va_name``. Detection now uses - Deezer's artist ID rather than the artist name string. :bug:`4956` -- :doc:`plugins/listenbrainz`: Paginate through all ListenBrainz listens instead - of fetching only 25, aggregate individual listen events into correct play - counts, use ``recording_mbid`` from the ListenBrainz mapping when available, - and avoid per-listen MusicBrainz API lookups that caused imports to hang on - large listen histories. :bug:`6469` -- Correctly handle semicolon-delimited genre values from externally-tagged - files. :bug:`6450` -- :doc:`plugins/listenbrainz`: Fix ``lbimport`` crashing when ListenBrainz - tracks are processed through Last.fm-specific play-count import logic. - Play-count imports now use source-specific fields so - :doc:`plugins/listenbrainz`, :doc:`plugins/lastimport`, and - :doc:`plugins/mpdstats` do not clash. :bug:`6469` -- :ref:`import-cmd` Fix ``albumartists_sort`` (and related fields) incorrectly - prepending the full combined artist credit as the first element for - multi-artist releases. :bug:`6470` -- :doc:`plugins/discogs`: Store specific Discogs styles in beets ``genres`` and - broader Discogs genres in the ``style`` field. When - :conf:`plugins.discogs:append_style_genre` is enabled, the broader Discogs - genres are also appended to the ``genres`` list. :bug:`6390` -- :doc:`plugins/deezer`: Fix a regression in 2.8.0 where selecting a Deezer - match during import could crash with ``AttributeError: 'AlbumInfo' object has - no attribute 'raw_data'`` when Deezer returned numeric artist IDs. :bug:`6503` -- :ref:`modify-cmd` accepts legacy singular field names such as ``genre``, - ``composer``, ``lyricist``, ``remixer``, and ``arranger`` in assignments, - rewrites them to the corresponding multi-valued fields, and warns users to - switch to the plural field names. :ref:`list-cmd`, and query expressions, - accept the same legacy singular field names and warn users to switch to the - plural field names. :bug:`6483` -- :doc:`plugins/fetchart`: Error when a configured source does not exist or - sources configuration is empty. :bug:`6336` -- :doc:`plugins/rewrite` :doc:`plugins/advancedrewrite`: Fix rewriting - multi-valued fields such as ``genres`` by applying rules to each matching list - entry. Additionally, apply rewrite rules in config order, so that multiple - rules can be applied to the same field. :bug:`6515` - -For plugin developers -~~~~~~~~~~~~~~~~~~~~~ - -- If you maintain a metadata source plugin that populates any of ``arranger``, - ``composer``, ``lyricist``, ``remixer`` fields, update it to populate the - respective multi-valued fields instead (``arrangers``, ``composers``, - ``lyricists``, ``remixers``). - 2.8.0 (March 28, 2026) ---------------------- @@ -506,6 +307,8 @@ New features ``beet import``. - :doc:`plugins/random`: Added ``--field`` option to specify which field to use for equal-chance sampling (default: ``albumartist``). +- :doc:`plugins/musicbrainz`: Use title aliases for releases, release groups, + and recordings. Bug fixes ~~~~~~~~~ From e84ce4198acdd15d33938aaa8cec21dfff097f57 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Wed, 29 Apr 2026 08:33:31 -0400 Subject: [PATCH 14/14] Revert unrelated docstring reformatting --- beetsplug/spotify.py | 10 ++++------ test/plugins/test_spotify.py | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 209056e6fd..28c4be37c5 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -16,7 +16,6 @@ """Adds Spotify release and track search support to the autotagger. Also includes Spotify playlist construction. - """ from __future__ import annotations @@ -241,8 +240,8 @@ def _handle_response( :param method: HTTP method to use for the request. :param url: URL for the new :class:`Request` object. - :param dict params: (optional) list of tuples or bytes to send in the - query string for the :class:`Request`. + :param dict params: (optional) list of tuples or bytes to send + in the query string for the :class:`Request`. """ @@ -517,7 +516,6 @@ def get_search_response( Unauthorized responses trigger one token refresh attempt before the method gives up and falls back to an empty result set. - """ for _ in range(2): response = requests.get( @@ -620,8 +618,8 @@ def _match_library_tracks(self, library: Library, keywords: str): :param library: beets library object to query. :param keywords: Query to match library items against. - :returns: List of simplified track object dicts for library items - matching the specified query. + :returns: List of simplified track object dicts for library + items matching the specified query. """ results = [] diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index ff585181ae..127fb3a628 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -253,8 +253,8 @@ def test_japanese_track(self): @responses.activate def test_multiartist_album_and_track(self): - """Tests if plugin is able to map multiple artists in an album and track - info correctly.""" + """Tests if plugin is able to map multiple artists in an album and + track info correctly""" # Mock the Spotify 'Get Album' call json_file = os.path.join(