mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 08:39:17 +01:00
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:
commit
584329e7f0
2 changed files with 103 additions and 34 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue