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
# 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
@ -23,6 +24,7 @@ import base64
import collections
import json
import re
import threading
import time
import webbrowser
from typing import TYPE_CHECKING, Any, Literal, Sequence, Union
@ -50,13 +52,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
@ -77,6 +80,12 @@ class APIError(Exception):
pass
class AudioFeaturesUnavailableError(Exception):
"""Raised when audio features API returns 403 (deprecated)."""
pass
class SpotifyPlugin(
SearchApiMetadataSourcePlugin[
Union[SearchResponseAlbums, SearchResponseTracks]
@ -140,6 +149,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.setup()
def setup(self):
@ -158,9 +173,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()
@ -201,9 +214,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
:param dict params: (optional) list of tuples or bytes to send
in the query string for the :class:`Request`.
:type params: dict
"""
if retry_count > max_retries:
@ -246,6 +259,17 @@ 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 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:
seconds = e.response.headers.get(
"Retry-After", DEFAULT_WAITING_TIME
@ -268,7 +292,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:
@ -279,10 +304,11 @@ class SpotifyPlugin(
"""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
@ -356,7 +382,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"])
@ -385,6 +413,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)):
@ -425,10 +454,11 @@ class SpotifyPlugin(
"""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
@ -523,13 +553,16 @@ 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.
: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 = []
failures = []
@ -640,12 +673,14 @@ 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)
: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:
spotify_ids = [track_data["id"] for track_data in results]
@ -691,6 +726,8 @@ class SpotifyPlugin(
item["isrc"] = isrc
item["ean"] = ean
item["upc"] = upc
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)
@ -698,6 +735,9 @@ class SpotifyPlugin(
for feature, value in audio_features.items():
if feature in self.spotify_audio_features:
item[self.spotify_audio_features[feature]] = value
else:
self._log.debug("Audio features API unavailable, skipping")
item["spotify_updated"] = time.time()
item.store()
if write:
@ -721,11 +761,34 @@ 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:
# 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)
return None

View file

@ -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: