Spotify: gracefully handle deprecated audio-features API (#6138)

Spotify has deprecated many of its APIs that we are still using, wasting
calls and time on these API calls; also results in frequent rate limits.

This PR introduces a dedicated `AudioFeaturesUnavailableError` and
tracks audio feature availability with an `audio_features_available`
flag. If the audio-features endpoint returns an HTTP 403 error, raise a
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 rate-limit issues).

Also, update the changelog to document the behavior.


## To Do

- [x] Changelog. (Add an entry to `docs/changelog.rst` to the bottom of
one of the lists near the top of the document.)
This commit is contained in:
henry 2025-10-31 18:54:49 -07:00 committed by GitHub
commit 584329e7f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 103 additions and 34 deletions

View file

@ -13,8 +13,9 @@
# The above copyright notice and this permission notice shall be # The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software. # included in all copies or substantial portions of the Software.
"""Adds Spotify release and track search support to the autotagger, along with """Adds Spotify release and track search support to the autotagger.
Spotify playlist construction.
Also includes Spotify playlist construction.
""" """
from __future__ import annotations from __future__ import annotations
@ -23,6 +24,7 @@ import base64
import collections import collections
import json import json
import re import re
import threading
import time import time
import webbrowser import webbrowser
from typing import TYPE_CHECKING, Any, Literal, Sequence, Union from typing import TYPE_CHECKING, Any, Literal, Sequence, Union
@ -50,13 +52,14 @@ DEFAULT_WAITING_TIME = 5
class SearchResponseAlbums(IDResponse): class SearchResponseAlbums(IDResponse):
"""A response returned by the Spotify API. """A response returned by the Spotify API.
We only use items and disregard the pagination information. We only use items and disregard the pagination information. i.e.
i.e. res["albums"]["items"][0]. res["albums"]["items"][0].
There are more fields in the response, but we only type There are more fields in the response, but we only type the ones we
the ones we currently use. currently use.
see https://developer.spotify.com/documentation/web-api/reference/search see https://developer.spotify.com/documentation/web-api/reference/search
""" """
album_type: str album_type: str
@ -77,6 +80,12 @@ class APIError(Exception):
pass pass
class AudioFeaturesUnavailableError(Exception):
"""Raised when audio features API returns 403 (deprecated)."""
pass
class SpotifyPlugin( class SpotifyPlugin(
SearchApiMetadataSourcePlugin[ SearchApiMetadataSourcePlugin[
Union[SearchResponseAlbums, SearchResponseTracks] Union[SearchResponseAlbums, SearchResponseTracks]
@ -140,6 +149,12 @@ class SpotifyPlugin(
self.config["client_id"].redact = True self.config["client_id"].redact = True
self.config["client_secret"].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.setup() self.setup()
def setup(self): def setup(self):
@ -158,9 +173,7 @@ class SpotifyPlugin(
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True)) return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
def _authenticate(self) -> None: def _authenticate(self) -> None:
"""Request an access token via the Client Credentials Flow: """Request an access token via the Client Credentials Flow: https://developer.spotify.com/documentation/general/guides/authorization-guide/#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_id: str = self.config["client_id"].as_str()
c_secret: str = self.config["client_secret"].as_str() c_secret: str = self.config["client_secret"].as_str()
@ -201,9 +214,9 @@ class SpotifyPlugin(
:param method: HTTP method to use for the request. :param method: HTTP method to use for the request.
:param url: URL for the new :class:`Request` object. :param url: URL for the new :class:`Request` object.
:param params: (optional) list of tuples or bytes to send :param dict params: (optional) list of tuples or bytes to send
in the query string for the :class:`Request`. in the query string for the :class:`Request`.
:type params: dict
""" """
if retry_count > max_retries: if retry_count > max_retries:
@ -246,6 +259,17 @@ class SpotifyPlugin(
f"API Error: {e.response.status_code}\n" f"API Error: {e.response.status_code}\n"
f"URL: {url}\nparams: {params}" f"URL: {url}\nparams: {params}"
) )
elif e.response.status_code == 403:
# 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)"
)
raise APIError(
f"API Error: {e.response.status_code}\n"
f"URL: {url}\nparams: {params}"
)
elif e.response.status_code == 429: elif e.response.status_code == 429:
seconds = e.response.headers.get( seconds = e.response.headers.get(
"Retry-After", DEFAULT_WAITING_TIME "Retry-After", DEFAULT_WAITING_TIME
@ -268,7 +292,8 @@ class SpotifyPlugin(
raise APIError("Bad Gateway.") raise APIError("Bad Gateway.")
elif e.response is not None: elif e.response is not None:
raise APIError( 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}" f"URL:\n{url}\nparams:\n{params}"
) )
else: else:
@ -279,10 +304,11 @@ class SpotifyPlugin(
"""Fetch an album by its Spotify ID or URL and return an """Fetch an album by its Spotify ID or URL and return an
AlbumInfo object or None if the album is not found. AlbumInfo object or None if the album is not found.
:param album_id: Spotify ID or URL for the album :param str album_id: Spotify ID or URL for the album
:type album_id: str
:return: AlbumInfo object for album :returns: AlbumInfo object for album
:rtype: beets.autotag.hooks.AlbumInfo or None :rtype: beets.autotag.hooks.AlbumInfo or None
""" """
if not (spotify_id := self._extract_id(album_id)): if not (spotify_id := self._extract_id(album_id)):
return None return None
@ -356,7 +382,9 @@ class SpotifyPlugin(
:param track_data: Simplified track object :param track_data: Simplified track object
(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)
:return: TrackInfo object for track
:returns: TrackInfo object for track
""" """
artist, artist_id = self.get_artist(track_data["artists"]) artist, artist_id = self.get_artist(track_data["artists"])
@ -385,6 +413,7 @@ class SpotifyPlugin(
"""Fetch a track by its Spotify ID or URL. """Fetch a track by its Spotify ID or URL.
Returns a TrackInfo object or None if the track is not found. Returns a TrackInfo object or None if the track is not found.
""" """
if not (spotify_id := self._extract_id(track_id)): if not (spotify_id := self._extract_id(track_id)):
@ -425,10 +454,11 @@ class SpotifyPlugin(
"""Query the Spotify Search API for the specified ``query_string``, """Query the Spotify Search API for the specified ``query_string``,
applying the provided ``filters``. applying the provided ``filters``.
:param query_type: Item type to search across. Valid types are: :param query_type: Item type to search across. Valid types are: 'album',
'album', 'artist', 'playlist', and 'track'. 'artist', 'playlist', and 'track'.
:param filters: Field filters to apply. :param filters: Field filters to apply.
:param query_string: Additional query to include in the search. :param query_string: Additional query to include in the search.
""" """
query = self._construct_search_query( query = self._construct_search_query(
filters=filters, query_string=query_string filters=filters, query_string=query_string
@ -523,13 +553,16 @@ class SpotifyPlugin(
return True return True
def _match_library_tracks(self, library: Library, keywords: str): def _match_library_tracks(self, library: Library, keywords: str):
"""Get a list of simplified track object dicts for library tracks """Get simplified track object dicts for library tracks.
matching the specified ``keywords``.
Matches tracks based on the specified ``keywords``.
:param library: beets library object to query. :param library: beets library object to query.
:param keywords: Query to match library items against. :param keywords: Query to match library items against.
:return: 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 = [] results = []
failures = [] failures = []
@ -640,12 +673,14 @@ class SpotifyPlugin(
return results return results
def _output_match_results(self, results): def _output_match_results(self, results):
"""Open a playlist or print Spotify URLs for the provided track """Open a playlist or print Spotify URLs.
object dicts.
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)
:param 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: if results:
spotify_ids = [track_data["id"] for track_data in results] spotify_ids = [track_data["id"] for track_data in results]
@ -691,13 +726,18 @@ class SpotifyPlugin(
item["isrc"] = isrc item["isrc"] = isrc
item["ean"] = ean item["ean"] = ean
item["upc"] = upc item["upc"] = upc
audio_features = self.track_audio_features(spotify_track_id)
if audio_features is None: if self.audio_features_available:
self._log.info("No audio features found for: {}", item) 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: else:
for feature, value in audio_features.items(): self._log.debug("Audio features API unavailable, skipping")
if feature in self.spotify_audio_features:
item[self.spotify_audio_features[feature]] = value
item["spotify_updated"] = time.time() item["spotify_updated"] = time.time()
item.store() item.store()
if write: if write:
@ -721,11 +761,34 @@ class SpotifyPlugin(
) )
def track_audio_features(self, track_id: str): 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: try:
return self._handle_response( return self._handle_response(
"get", f"{self.audio_features_url}{track_id}" "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."
)
return None
except APIError as e: except APIError as e:
self._log.debug("Spotify API error: {}", e) self._log.debug("Spotify API error: {}", e)
return None return None

View file

@ -22,6 +22,12 @@ New features:
Bug fixes: 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: For packagers:
Other changes: Other changes: