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
2021from __future__ import annotations
2324import collections
2425import json
2526import re
27+ import threading
2628import time
2729import webbrowser
2830from typing import TYPE_CHECKING , Any , Literal , Sequence , Union
5052class 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+
8089class 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 } \n params: { 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 } \n params: { 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 } \n params:\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
0 commit comments