Skip to content

Commit 584329e

Browse files
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.)
2 parents 9608ec0 + 7724c66 commit 584329e

File tree

2 files changed

+103
-34
lines changed

2 files changed

+103
-34
lines changed

beetsplug/spotify.py

Lines changed: 97 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
# The above copyright notice and this permission notice shall be
1414
# included in all copies or substantial portions of the Software.
1515

16-
"""Adds Spotify release and track search support to the autotagger, along with
17-
Spotify playlist construction.
16+
"""Adds Spotify release and track search support to the autotagger.
17+
18+
Also includes Spotify playlist construction.
1819
"""
1920

2021
from __future__ import annotations
@@ -23,6 +24,7 @@
2324
import collections
2425
import json
2526
import re
27+
import threading
2628
import time
2729
import webbrowser
2830
from typing import TYPE_CHECKING, Any, Literal, Sequence, Union
@@ -50,13 +52,14 @@
5052
class SearchResponseAlbums(IDResponse):
5153
"""A response returned by the Spotify API.
5254
53-
We only use items and disregard the pagination information.
54-
i.e. res["albums"]["items"][0].
55+
We only use items and disregard the pagination information. i.e.
56+
res["albums"]["items"][0].
5557
56-
There are more fields in the response, but we only type
57-
the ones we currently use.
58+
There are more fields in the response, but we only type the ones we
59+
currently use.
5860
5961
see https://developer.spotify.com/documentation/web-api/reference/search
62+
6063
"""
6164

6265
album_type: str
@@ -77,6 +80,12 @@ class APIError(Exception):
7780
pass
7881

7982

83+
class AudioFeaturesUnavailableError(Exception):
84+
"""Raised when audio features API returns 403 (deprecated)."""
85+
86+
pass
87+
88+
8089
class SpotifyPlugin(
8190
SearchApiMetadataSourcePlugin[
8291
Union[SearchResponseAlbums, SearchResponseTracks]
@@ -140,6 +149,12 @@ def __init__(self):
140149
self.config["client_id"].redact = True
141150
self.config["client_secret"].redact = True
142151

152+
self.audio_features_available = (
153+
True # Track if audio features API is available
154+
)
155+
self._audio_features_lock = (
156+
threading.Lock()
157+
) # Protects audio_features_available
143158
self.setup()
144159

145160
def setup(self):
@@ -158,9 +173,7 @@ def _tokenfile(self) -> str:
158173
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
159174

160175
def _authenticate(self) -> None:
161-
"""Request an access token via the Client Credentials Flow:
162-
https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow
163-
"""
176+
"""Request an access token via the Client Credentials Flow: https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow"""
164177
c_id: str = self.config["client_id"].as_str()
165178
c_secret: str = self.config["client_secret"].as_str()
166179

@@ -201,9 +214,9 @@ def _handle_response(
201214
202215
:param method: HTTP method to use for the request.
203216
:param url: URL for the new :class:`Request` object.
204-
:param params: (optional) list of tuples or bytes to send
217+
:param dict params: (optional) list of tuples or bytes to send
205218
in the query string for the :class:`Request`.
206-
:type params: dict
219+
207220
"""
208221

209222
if retry_count > max_retries:
@@ -246,6 +259,17 @@ def _handle_response(
246259
f"API Error: {e.response.status_code}\n"
247260
f"URL: {url}\nparams: {params}"
248261
)
262+
elif e.response.status_code == 403:
263+
# Check if this is the audio features endpoint
264+
if url.startswith(self.audio_features_url):
265+
raise AudioFeaturesUnavailableError(
266+
"Audio features API returned 403 "
267+
"(deprecated or unavailable)"
268+
)
269+
raise APIError(
270+
f"API Error: {e.response.status_code}\n"
271+
f"URL: {url}\nparams: {params}"
272+
)
249273
elif e.response.status_code == 429:
250274
seconds = e.response.headers.get(
251275
"Retry-After", DEFAULT_WAITING_TIME
@@ -268,7 +292,8 @@ def _handle_response(
268292
raise APIError("Bad Gateway.")
269293
elif e.response is not None:
270294
raise APIError(
271-
f"{self.data_source} API error:\n{e.response.text}\n"
295+
f"{self.data_source} API error:\n"
296+
f"{e.response.text}\n"
272297
f"URL:\n{url}\nparams:\n{params}"
273298
)
274299
else:
@@ -279,10 +304,11 @@ def album_for_id(self, album_id: str) -> AlbumInfo | None:
279304
"""Fetch an album by its Spotify ID or URL and return an
280305
AlbumInfo object or None if the album is not found.
281306
282-
:param album_id: Spotify ID or URL for the album
283-
:type album_id: str
284-
:return: AlbumInfo object for album
307+
:param str album_id: Spotify ID or URL for the album
308+
309+
:returns: AlbumInfo object for album
285310
:rtype: beets.autotag.hooks.AlbumInfo or None
311+
286312
"""
287313
if not (spotify_id := self._extract_id(album_id)):
288314
return None
@@ -356,7 +382,9 @@ def _get_track(self, track_data: JSONDict) -> TrackInfo:
356382
357383
:param track_data: Simplified track object
358384
(https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
359-
:return: TrackInfo object for track
385+
386+
:returns: TrackInfo object for track
387+
360388
"""
361389
artist, artist_id = self.get_artist(track_data["artists"])
362390

@@ -385,6 +413,7 @@ def track_for_id(self, track_id: str) -> None | TrackInfo:
385413
"""Fetch a track by its Spotify ID or URL.
386414
387415
Returns a TrackInfo object or None if the track is not found.
416+
388417
"""
389418

390419
if not (spotify_id := self._extract_id(track_id)):
@@ -425,10 +454,11 @@ def _search_api(
425454
"""Query the Spotify Search API for the specified ``query_string``,
426455
applying the provided ``filters``.
427456
428-
:param query_type: Item type to search across. Valid types are:
429-
'album', 'artist', 'playlist', and 'track'.
457+
:param query_type: Item type to search across. Valid types are: 'album',
458+
'artist', 'playlist', and 'track'.
430459
:param filters: Field filters to apply.
431460
:param query_string: Additional query to include in the search.
461+
432462
"""
433463
query = self._construct_search_query(
434464
filters=filters, query_string=query_string
@@ -523,13 +553,16 @@ def _parse_opts(self, opts):
523553
return True
524554

525555
def _match_library_tracks(self, library: Library, keywords: str):
526-
"""Get a list of simplified track object dicts for library tracks
527-
matching the specified ``keywords``.
556+
"""Get simplified track object dicts for library tracks.
557+
558+
Matches tracks based on the specified ``keywords``.
528559
529560
:param library: beets library object to query.
530561
:param keywords: Query to match library items against.
531-
:return: List of simplified track object dicts for library items
532-
matching the specified query.
562+
563+
:returns: List of simplified track object dicts for library
564+
items matching the specified query.
565+
533566
"""
534567
results = []
535568
failures = []
@@ -640,12 +673,14 @@ def _match_library_tracks(self, library: Library, keywords: str):
640673
return results
641674

642675
def _output_match_results(self, results):
643-
"""Open a playlist or print Spotify URLs for the provided track
644-
object dicts.
676+
"""Open a playlist or print Spotify URLs.
677+
678+
Uses the provided track object dicts.
679+
680+
:param list[dict] results: List of simplified track object dicts
681+
(https://developer.spotify.com/documentation/web-api/
682+
reference/object-model/#track-object-simplified)
645683
646-
:param results: List of simplified track object dicts
647-
(https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
648-
:type results: list[dict]
649684
"""
650685
if results:
651686
spotify_ids = [track_data["id"] for track_data in results]
@@ -691,13 +726,18 @@ def _fetch_info(self, items, write, force):
691726
item["isrc"] = isrc
692727
item["ean"] = ean
693728
item["upc"] = upc
694-
audio_features = self.track_audio_features(spotify_track_id)
695-
if audio_features is None:
696-
self._log.info("No audio features found for: {}", item)
729+
730+
if self.audio_features_available:
731+
audio_features = self.track_audio_features(spotify_track_id)
732+
if audio_features is None:
733+
self._log.info("No audio features found for: {}", item)
734+
else:
735+
for feature, value in audio_features.items():
736+
if feature in self.spotify_audio_features:
737+
item[self.spotify_audio_features[feature]] = value
697738
else:
698-
for feature, value in audio_features.items():
699-
if feature in self.spotify_audio_features:
700-
item[self.spotify_audio_features[feature]] = value
739+
self._log.debug("Audio features API unavailable, skipping")
740+
701741
item["spotify_updated"] = time.time()
702742
item.store()
703743
if write:
@@ -721,11 +761,34 @@ def track_info(self, track_id: str):
721761
)
722762

723763
def track_audio_features(self, track_id: str):
724-
"""Fetch track audio features by its Spotify ID."""
764+
"""Fetch track audio features by its Spotify ID.
765+
766+
Thread-safe: avoids redundant API calls and logs the 403 warning only
767+
once.
768+
769+
"""
770+
# Fast path: if we've already detected unavailability, skip the call.
771+
with self._audio_features_lock:
772+
if not self.audio_features_available:
773+
return None
774+
725775
try:
726776
return self._handle_response(
727777
"get", f"{self.audio_features_url}{track_id}"
728778
)
779+
except AudioFeaturesUnavailableError:
780+
# Disable globally in a thread-safe manner and warn once.
781+
should_log = False
782+
with self._audio_features_lock:
783+
if self.audio_features_available:
784+
self.audio_features_available = False
785+
should_log = True
786+
if should_log:
787+
self._log.warning(
788+
"Audio features API is unavailable (403 error). "
789+
"Skipping audio features for remaining tracks."
790+
)
791+
return None
729792
except APIError as e:
730793
self._log.debug("Spotify API error: {}", e)
731794
return None

docs/changelog.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ New features:
2222

2323
Bug fixes:
2424

25+
- :doc:`/plugins/spotify`: The plugin now gracefully handles audio-features API
26+
deprecation (HTTP 403 errors). When a 403 error is encountered from the
27+
audio-features endpoint, the plugin logs a warning once and skips audio
28+
features for all remaining tracks in the session, avoiding unnecessary API
29+
calls and rate limit exhaustion.
30+
2531
For packagers:
2632

2733
Other changes:

0 commit comments

Comments
 (0)