import threading import time import spotipy from requests.exceptions import RequestException from spotipy.oauth2 import SpotifyOAuth from app.logger import log from app.secrets_manager import SecretsManagerClient from app.config import SPOTIFY PLAYLIST_EXISTS_MAX_ATTEMPTS = 3 DEFAULT_RETRY_AFTER_SECONDS = 1 class SpotifyApiClient: def __init__(self): log.info("Initializing Spotify API client") secrets_client = SecretsManagerClient() self._spotify_secrets = secrets_client.get_secret_json()[SPOTIFY] self.auth_manager = SpotifyOAuth( client_id=self._spotify_secrets["client_id"], client_secret=self._spotify_secrets["client_secret"], redirect_uri="http://localhost:8080/callback/spotify", scope="playlist-modify-public playlist-modify-private playlist-read-private", cache_path=None # Disable .cache file ) self._token_lock = threading.Lock() self._refresh_token() def _refresh_token(self): # Guards against every thread in a concurrent playlist_exists() batch # refreshing at once when the shared token expires mid-run. with self._token_lock: token_info = self.auth_manager.refresh_access_token(self._spotify_secrets["refresh_token"]) self.sp = spotipy.Spotify(auth=token_info["access_token"]) def playlist_exists(self, playlist_id): """Check whether a playlist still exists on Spotify. Returns True/False, or None if existence could not be determined after retries (e.g. access forbidden, persistent transient error) so callers don't treat an inconclusive check as a confirmed miss. """ for attempt in range(1, PLAYLIST_EXISTS_MAX_ATTEMPTS + 1): is_last_attempt = attempt == PLAYLIST_EXISTS_MAX_ATTEMPTS try: self.sp.playlist(playlist_id, fields="id") return True except spotipy.SpotifyException as e: if e.http_status == 404: return False if e.http_status == 401 and not is_last_attempt: log.warning(f"Spotify token expired, refreshing and retrying playlist {playlist_id}") self._refresh_token() continue if e.http_status == 429 and not is_last_attempt: retry_after = int(e.headers.get("Retry-After", DEFAULT_RETRY_AFTER_SECONDS)) log.warning(f"Rate-limited checking playlist {playlist_id} on Spotify, retrying in {retry_after}s") time.sleep(retry_after) continue log.warning(f"Could not verify playlist {playlist_id} on Spotify: HTTP {e.http_status} - {e.msg}") return None except RequestException as e: if not is_last_attempt: log.warning(f"Network error checking playlist {playlist_id} on Spotify, retrying: {e}") continue log.warning(f"Could not verify playlist {playlist_id} on Spotify: {e}") return None return None def get_all_tracks(self, playlist_id): all_tracks = [] results = self.sp.playlist_items(playlist_id, limit=100) all_tracks.extend(results['items']) while results['next']: results = self.sp.next(results) all_tracks.extend(results['items']) return [{ 'id': t['track']['id'], 'name': t['track']['name'], 'isrc': t['track']['external_ids']['isrc'] # 'artists': [a['name'] for a in t['track']['artists']], # 'album': t['track']['album']['name'], # 'url': t['track']['external_urls']['spotify'] } for t in all_tracks if t['track']] def get_spotify_playlist_isrc(self, playlist_id): spotify_playlist_tracks = self.get_all_tracks(playlist_id) spotify_playlist_isrcs = [ track['isrc'] for track in spotify_playlist_tracks ] log.debug(f'spotify_playlist_isrcs = {spotify_playlist_isrcs}') return spotify_playlist_isrcs def log_tracks_order(self, playlist_id): log.info("========= TRACKS ORDER =========") tracks = self.get_all_tracks(playlist_id) for track in tracks: log.info(track) log.info("========= TRACKS ORDER END=========") def get_track_position_by_isrc(self, playlist_id, isrc): isrcs = self.get_spotify_playlist_isrc(playlist_id) try: position = isrcs.index(isrc) + 1 log.info(f"SPOTIFY ISRC position: {isrc} is {position}") return position except ValueError: return -1 def reorder_playlist_items(self, playlist_id, range_start, insert_before, range_length=1): """ Reorder one or more items in a playlist, with logging. :param playlist_id: The Spotify ID of the playlist. :param range_start: Position of the first item to be reordered. :param insert_before: Position where the items should be inserted. :param range_length: Optional. Number of items to be reordered (default = 1). """ self.log_tracks_order(playlist_id) # Fetch all tracks with metadata tracks = self.get_all_tracks(playlist_id) # Get the slice of tracks being moved moved_tracks = tracks[range_start:range_start + range_length] moved_titles = [t['name'] for t in moved_tracks] log.info(f"Moving track(s) {moved_titles} from position {range_start} " f"to position {insert_before}") self.sp.playlist_reorder_items( playlist_id=playlist_id, range_start=range_start, insert_before=insert_before, range_length=range_length ) self.log_tracks_order(playlist_id)