From 0d11e19ecf0cb487d6dd12b7f9e871c162a54dbb Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 30 Oct 2025 10:13:54 -0400 Subject: [PATCH 1/6] Spotify: gracefully handle 403 from deprecated audio-features API Add a dedicated AudioFeaturesUnavailableError and track audio-features availability with an audio_features_available flag. If the audio-features endpoint returns HTTP 403, raise the new error, log a warning once, and disable further audio-features requests for the session. The plugin now skips attempting audio-features lookups when disabled (avoiding repeated failed calls and potential rate-limit issues). Also update changelog to document the behavior. --- beetsplug/spotify.py | 40 ++++++++++++++++++++++++++++++++++------ docs/changelog.rst | 6 ++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 7cb9e330d..dadb0ea4d 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -77,6 +77,11 @@ class APIError(Exception): pass +class AudioFeaturesUnavailableError(Exception): + """Raised when the audio features API returns 403 (deprecated/unavailable).""" + pass + + class SpotifyPlugin( SearchApiMetadataSourcePlugin[ Union[SearchResponseAlbums, SearchResponseTracks] @@ -140,6 +145,7 @@ class SpotifyPlugin( self.config["client_id"].redact = True self.config["client_secret"].redact = True + self.audio_features_available = True # Track if audio features API is available self.setup() def setup(self): @@ -246,6 +252,16 @@ class SpotifyPlugin( f"API Error: {e.response.status_code}\n" f"URL: {url}\nparams: {params}" ) + elif e.response.status_code == 403: + # Check if this is the audio features endpoint + if self.audio_features_url in url: + raise AudioFeaturesUnavailableError( + "Audio features API returned 403 (deprecated or unavailable)" + ) + raise APIError( + f"API Error: {e.response.status_code}\n" + f"URL: {url}\nparams: {params}" + ) elif e.response.status_code == 429: seconds = e.response.headers.get( "Retry-After", DEFAULT_WAITING_TIME @@ -691,13 +707,18 @@ class SpotifyPlugin( item["isrc"] = isrc item["ean"] = ean item["upc"] = upc - audio_features = self.track_audio_features(spotify_track_id) - if audio_features is None: - self._log.info("No audio features found for: {}", item) + + if self.audio_features_available: + audio_features = self.track_audio_features(spotify_track_id) + if audio_features is None: + self._log.info("No audio features found for: {}", item) + else: + for feature, value in audio_features.items(): + if feature in self.spotify_audio_features: + item[self.spotify_audio_features[feature]] = value else: - for feature, value in audio_features.items(): - if feature in self.spotify_audio_features: - item[self.spotify_audio_features[feature]] = value + self._log.debug("Audio features API unavailable, skipping") + item["spotify_updated"] = time.time() item.store() if write: @@ -726,6 +747,13 @@ class SpotifyPlugin( return self._handle_response( "get", f"{self.audio_features_url}{track_id}" ) + except AudioFeaturesUnavailableError as e: + self._log.warning( + "Audio features API is unavailable (403 error). " + "Skipping audio features for remaining tracks." + ) + self.audio_features_available = False + return None except APIError as e: self._log.debug("Spotify API error: {}", e) return None diff --git a/docs/changelog.rst b/docs/changelog.rst index a78787273..e192259b1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,12 @@ New features: Bug fixes: +- :doc:`/plugins/spotify`: The plugin now gracefully handles audio-features API + deprecation (HTTP 403 errors). When a 403 error is encountered from the + audio-features endpoint, the plugin logs a warning once and skips audio + features for all remaining tracks in the session, avoiding unnecessary API + calls and rate limit exhaustion. + For packagers: Other changes: From e6c70f06c1223d5931e0a2e8ae004851d3e198ed Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 30 Oct 2025 10:20:53 -0400 Subject: [PATCH 2/6] lint --- beetsplug/spotify.py | 60 ++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index dadb0ea4d..acd60d989 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -13,9 +13,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Adds Spotify release and track search support to the autotagger, along with -Spotify playlist construction. -""" +"""Adds Spotify release and track search support to the autotagger, along with Spotify playlist construction.""" from __future__ import annotations @@ -50,13 +48,14 @@ DEFAULT_WAITING_TIME = 5 class SearchResponseAlbums(IDResponse): """A response returned by the Spotify API. - We only use items and disregard the pagination information. - i.e. res["albums"]["items"][0]. + We only use items and disregard the pagination information. i.e. + res["albums"]["items"][0]. - There are more fields in the response, but we only type - the ones we currently use. + There are more fields in the response, but we only type the ones we + currently use. see https://developer.spotify.com/documentation/web-api/reference/search + """ album_type: str @@ -164,9 +163,7 @@ class SpotifyPlugin( return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True)) def _authenticate(self) -> None: - """Request an access token via the Client Credentials Flow: - https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow - """ + """Request an access token via the Client Credentials Flow: https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow""" c_id: str = self.config["client_id"].as_str() c_secret: str = self.config["client_secret"].as_str() @@ -207,9 +204,9 @@ class SpotifyPlugin( :param method: HTTP method to use for the request. :param url: URL for the new :class:`Request` object. - :param params: (optional) list of tuples or bytes to send - in the query string for the :class:`Request`. - :type params: dict + :param dict params: (optional) list of tuples or bytes to send in the + query string for the :class:`Request`. + """ if retry_count > max_retries: @@ -292,13 +289,13 @@ class SpotifyPlugin( raise APIError("Request failed.") 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 and return an AlbumInfo object or None if the album is not found. - :param album_id: Spotify ID or URL for the album - :type album_id: str - :return: AlbumInfo object for album + :param str album_id: Spotify ID or URL for the album + + :returns: AlbumInfo object for album :rtype: beets.autotag.hooks.AlbumInfo or None + """ if not (spotify_id := self._extract_id(album_id)): return None @@ -372,7 +369,9 @@ class SpotifyPlugin( :param track_data: Simplified track object (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) - :return: TrackInfo object for track + + :returns: TrackInfo object for track + """ artist, artist_id = self.get_artist(track_data["artists"]) @@ -401,6 +400,7 @@ class SpotifyPlugin( """Fetch a track by its Spotify ID or URL. Returns a TrackInfo object or None if the track is not found. + """ if not (spotify_id := self._extract_id(track_id)): @@ -438,13 +438,13 @@ class SpotifyPlugin( filters: SearchFilter, query_string: str = "", ) -> Sequence[SearchResponseAlbums | SearchResponseTracks]: - """Query the Spotify Search API for the specified ``query_string``, - applying the provided ``filters``. + """Query the Spotify Search API for the specified ``query_string``, applying the provided ``filters``. - :param query_type: Item type to search across. Valid types are: - 'album', 'artist', 'playlist', and 'track'. + :param query_type: Item type to search across. Valid types are: 'album', + 'artist', 'playlist', and 'track'. :param filters: Field filters to apply. :param query_string: Additional query to include in the search. + """ query = self._construct_search_query( filters=filters, query_string=query_string @@ -539,13 +539,14 @@ class SpotifyPlugin( return True def _match_library_tracks(self, library: Library, keywords: str): - """Get a list of simplified track object dicts for library tracks - matching the specified ``keywords``. + """Get a list of simplified track object dicts for library tracks matching the specified ``keywords``. :param library: beets library object to query. :param keywords: Query to match library items against. - :return: List of simplified track object dicts for library items + + :returns: List of simplified track object dicts for library items matching the specified query. + """ results = [] failures = [] @@ -656,12 +657,11 @@ class SpotifyPlugin( return results def _output_match_results(self, results): - """Open a playlist or print Spotify URLs for the provided track - object dicts. + """Open a playlist or print Spotify URLs for the provided track object dicts. - :param results: List of simplified track object dicts + :param list[dict] results: List of simplified track object dicts (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) - :type results: list[dict] + """ if results: spotify_ids = [track_data["id"] for track_data in results] From 4302ca97eb4c0b907c4931d147e0a83d0932e651 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 30 Oct 2025 10:29:07 -0400 Subject: [PATCH 3/6] resolve sorucery issue....make it thread safe --- beetsplug/spotify.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index acd60d989..c937ed893 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -24,6 +24,7 @@ import re import time import webbrowser from typing import TYPE_CHECKING, Any, Literal, Sequence, Union +import threading import confuse import requests @@ -145,6 +146,7 @@ class SpotifyPlugin( self.config["client_secret"].redact = True self.audio_features_available = True # Track if audio features API is available + self._audio_features_lock = threading.Lock() # Protects audio_features_available self.setup() def setup(self): @@ -251,7 +253,7 @@ class SpotifyPlugin( ) elif e.response.status_code == 403: # Check if this is the audio features endpoint - if self.audio_features_url in url: + if url.startswith(self.audio_features_url): raise AudioFeaturesUnavailableError( "Audio features API returned 403 (deprecated or unavailable)" ) @@ -742,17 +744,33 @@ class SpotifyPlugin( ) def track_audio_features(self, track_id: str): - """Fetch track audio features by its Spotify ID.""" + """Fetch track audio features by its Spotify ID. + + Thread-safe: avoids redundant API calls and logs the 403 warning only + 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 + try: return self._handle_response( "get", f"{self.audio_features_url}{track_id}" ) - except AudioFeaturesUnavailableError as e: - self._log.warning( - "Audio features API is unavailable (403 error). " - "Skipping audio features for remaining tracks." - ) - self.audio_features_available = False + 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." + ) return None except APIError as e: self._log.debug("Spotify API error: {}", e) From 8305821488e717a22c5893a225306b660574ea19 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 30 Oct 2025 10:34:30 -0400 Subject: [PATCH 4/6] more lint --- beetsplug/spotify.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index c937ed893..8225b45be 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -79,6 +79,7 @@ class APIError(Exception): class AudioFeaturesUnavailableError(Exception): """Raised when the audio features API returns 403 (deprecated/unavailable).""" + pass @@ -145,8 +146,12 @@ class SpotifyPlugin( self.config["client_id"].redact = True self.config["client_secret"].redact = True - self.audio_features_available = True # Track if audio features API is available - self._audio_features_lock = threading.Lock() # Protects audio_features_available + self.audio_features_available = ( + True # Track if audio features API is available + ) + self._audio_features_lock = ( + threading.Lock() + ) # Protects audio_features_available self.setup() def setup(self): From 447511b4c866b27c58ead4c1d9b3727ab84c87d5 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 30 Oct 2025 10:47:07 -0400 Subject: [PATCH 5/6] ruff formating --- beetsplug/spotify.py | 51 +++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 8225b45be..d86ddb9e4 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -13,7 +13,10 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Adds Spotify release and track search support to the autotagger, along with Spotify playlist construction.""" +"""Adds Spotify release and track search support to the autotagger. + +Also includes Spotify playlist construction. +""" from __future__ import annotations @@ -21,10 +24,10 @@ import base64 import collections import json import re +import threading import time import webbrowser from typing import TYPE_CHECKING, Any, Literal, Sequence, Union -import threading import confuse import requests @@ -78,7 +81,7 @@ class APIError(Exception): class AudioFeaturesUnavailableError(Exception): - """Raised when the audio features API returns 403 (deprecated/unavailable).""" + """Raised when audio features API returns 403 (deprecated).""" pass @@ -190,7 +193,8 @@ class SpotifyPlugin( response.raise_for_status() except requests.exceptions.HTTPError as e: raise ui.UserError( - f"Spotify authorization failed: {e}\n{response.text}" + f"Spotify authorization failed: {e}\n" + f"{response.text}" ) self.access_token = response.json()["access_token"] @@ -211,8 +215,8 @@ class SpotifyPlugin( :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`. """ @@ -260,7 +264,8 @@ class SpotifyPlugin( # Check if this is the audio features endpoint if url.startswith(self.audio_features_url): raise AudioFeaturesUnavailableError( - "Audio features API returned 403 (deprecated or unavailable)" + "Audio features API returned 403 " + "(deprecated or unavailable)" ) raise APIError( f"API Error: {e.response.status_code}\n" @@ -288,7 +293,8 @@ class SpotifyPlugin( raise APIError("Bad Gateway.") elif e.response is not None: raise APIError( - f"{self.data_source} API error:\n{e.response.text}\n" + f"{self.data_source} API error:\n" + f"{e.response.text}\n" f"URL:\n{url}\nparams:\n{params}" ) else: @@ -296,7 +302,8 @@ class SpotifyPlugin( raise APIError("Request failed.") 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 and return an + AlbumInfo object or None if the album is not found. :param str album_id: Spotify ID or URL for the album @@ -444,8 +451,11 @@ class SpotifyPlugin( query_type: Literal["album", "track"], filters: SearchFilter, query_string: str = "", - ) -> Sequence[SearchResponseAlbums | SearchResponseTracks]: - """Query the Spotify Search API for the specified ``query_string``, applying the provided ``filters``. + ) -> Sequence[ + SearchResponseAlbums | SearchResponseTracks + ]: + """Query the Spotify Search API for the specified ``query_string``, + applying the provided ``filters``. :param query_type: Item type to search across. Valid types are: 'album', 'artist', 'playlist', and 'track'. @@ -457,7 +467,9 @@ class SpotifyPlugin( filters=filters, query_string=query_string ) - self._log.debug("Searching {.data_source} for '{}'", self, query) + self._log.debug( + "Searching {.data_source} for '{}'", self, query + ) try: response = self._handle_response( "get", @@ -546,13 +558,15 @@ class SpotifyPlugin( return True def _match_library_tracks(self, library: Library, keywords: str): - """Get a list of simplified track object dicts for library tracks matching the specified ``keywords``. + """Get simplified track object dicts for library tracks. + + Matches tracks based on the specified ``keywords``. :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 = [] @@ -664,10 +678,13 @@ class SpotifyPlugin( return results def _output_match_results(self, results): - """Open a playlist or print Spotify URLs for the provided track object dicts. + """Open a playlist or print Spotify URLs. + + Uses the provided track object dicts. :param list[dict] results: List of simplified track object dicts - (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) + (https://developer.spotify.com/documentation/web-api/ + reference/object-model/#track-object-simplified) """ if results: From 7724c661a4ecf951c4c0573a476d3d64ca57b25a Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Thu, 30 Oct 2025 10:49:51 -0400 Subject: [PATCH 6/6] hopefully...this works --- beetsplug/spotify.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index d86ddb9e4..a8126b852 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -193,8 +193,7 @@ class SpotifyPlugin( response.raise_for_status() except requests.exceptions.HTTPError as e: raise ui.UserError( - f"Spotify authorization failed: {e}\n" - f"{response.text}" + f"Spotify authorization failed: {e}\n{response.text}" ) self.access_token = response.json()["access_token"] @@ -451,9 +450,7 @@ class SpotifyPlugin( query_type: Literal["album", "track"], filters: SearchFilter, query_string: str = "", - ) -> Sequence[ - SearchResponseAlbums | SearchResponseTracks - ]: + ) -> Sequence[SearchResponseAlbums | SearchResponseTracks]: """Query the Spotify Search API for the specified ``query_string``, applying the provided ``filters``. @@ -467,9 +464,7 @@ class SpotifyPlugin( filters=filters, query_string=query_string ) - self._log.debug( - "Searching {.data_source} for '{}'", self, query - ) + self._log.debug("Searching {.data_source} for '{}'", self, query) try: response = self._handle_response( "get",