"""Spotify Web API client. Replaces Sony.Filtr.SpotifyWebAPI (AuthenticatedSpotifyWebApi) and the Apollo IApolloWebApi dependency for source playlist fetching. Authentication model -------------------- - Source playlist fetching: uses the app-level client credentials (SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET from settings) via the Client Credentials flow. No user authorization is needed to read a public playlist. - Target playlist modification: uses per-service-account OAuth tokens stored in tblServiceAccount. The client ID/secret may be overridden per account via tblSpotifyApiKeys (mirrors GetServiceAccountSpotifyClientIdAndSecretAsync in .NET). """ import logging from collections.abc import Iterator from typing import Optional import requests import spotipy from spotipy.oauth2 import SpotifyClientCredentials, SpotifyOAuth from playlist_sync.config import settings logger = logging.getLogger("playlist_sync.spotify_client") _SPOTIFY_API_BASE = "https://api.spotify.com/v1" _CHUNK_SIZE = 100 # Spotify API limit for batch track operations def _chunk(lst: list, size: int) -> Iterator[list]: """Split a list into chunks of at most `size`.""" for i in range(0, len(lst), size): yield lst[i : i + size] def _extract_playlist_id(spotify_uri_or_id: str) -> str: """Convert 'spotify:playlist:ABC' or 'https://…/ABC' or bare 'ABC' to a plain ID.""" if ":" in spotify_uri_or_id: return spotify_uri_or_id.split(":")[-1] if "/" in spotify_uri_or_id: return spotify_uri_or_id.rstrip("/").split("/")[-1] return spotify_uri_or_id def refresh_access_token( refresh_token: str, client_id: Optional[str] = None, client_secret: Optional[str] = None, ) -> Optional[dict]: """Call Spotify's token endpoint to exchange a refresh token for a new access token. Returns a dict with 'access_token' and 'expires_in' on success, None on failure. Mirrors: the OAuth token refresh logic in Sony.Filtr.SpotifyWebAPI. """ import base64 effective_id = client_id or settings.SPOTIFY_CLIENT_ID effective_secret = client_secret or settings.SPOTIFY_CLIENT_SECRET auth_header = base64.b64encode( f"{effective_id}:{effective_secret}".encode() ).decode() try: resp = requests.post( "https://accounts.spotify.com/api/token", headers={"Authorization": f"Basic {auth_header}"}, data={"grant_type": "refresh_token", "refresh_token": refresh_token}, timeout=10, ) resp.raise_for_status() return resp.json() # type: ignore[no-any-return] except Exception as exc: logger.warning("Spotify token refresh failed: %s", exc) return None def get_app_spotify_client() -> spotipy.Spotify: """Return a Spotipy client using the app-level Client Credentials flow.""" from spotipy.cache_handler import MemoryCacheHandler auth = SpotifyClientCredentials( client_id=settings.SPOTIFY_CLIENT_ID, client_secret=settings.SPOTIFY_CLIENT_SECRET, cache_handler=MemoryCacheHandler(), ) return spotipy.Spotify(auth_manager=auth) def get_playlist_with_all_tracks( spotify_uri: str, sp: Optional[spotipy.Spotify] = None, ) -> Optional[dict]: """Fetch a Spotify playlist and all its tracks. Returns the full playlist dict with 'tracks.items' populated (handles Spotify pagination automatically via spotipy). Returns None if the playlist cannot be found. If `sp` is provided (an already-authenticated Spotipy client), it is used directly; otherwise an app-level client credentials client is created. Mirrors: IApolloWebApi.GetPlaylistByIdWithAllTracksAsync() """ playlist_id = _extract_playlist_id(spotify_uri) client = sp or get_app_spotify_client() try: playlist = client.playlist(playlist_id) except spotipy.SpotifyException as exc: logger.warning("Could not fetch playlist %s: %s", playlist_id, exc) return None if playlist is None: return None # Paginate through all tracks all_items = [] tracks_page = playlist.get("tracks", {}) all_items.extend(tracks_page.get("items", [])) while tracks_page.get("next"): tracks_page = client.next(tracks_page) all_items.extend(tracks_page.get("items", [])) playlist["tracks"]["items"] = all_items return playlist # type: ignore[no-any-return] def get_all_playlist_track_items( playlist_id: str, sp: Optional[spotipy.Spotify] = None, ) -> list[dict]: """Fetch all track items for a playlist (for re-fetch after modifications). If `sp` is provided (an already-authenticated Spotipy client), it is used directly; otherwise an app-level client credentials client is created. Mirrors: IApolloWebApi.GetAllPlaylistTracksByIdAsync() """ client = sp or get_app_spotify_client() all_items = [] page = client.playlist_items(playlist_id, limit=100) all_items.extend(page.get("items", [])) while page.get("next"): page = client.next(page) all_items.extend(page.get("items", [])) return all_items # type: ignore[no-any-return] # --------------------------------------------------------------------------- # Target playlist modification (per-service-account OAuth tokens) # --------------------------------------------------------------------------- def get_authenticated_client( access_token: str, client_id: Optional[str] = None, client_secret: Optional[str] = None, refresh_token: Optional[str] = None, ) -> spotipy.Spotify: """Return a Spotipy client authenticated with a service account's OAuth token. If client_id/client_secret are provided (from tblSpotifyApiKeys), they override the global app credentials — mirrors the per-account key lookup in .NET. """ effective_client_id = client_id or settings.SPOTIFY_CLIENT_ID effective_client_secret = client_secret or settings.SPOTIFY_CLIENT_SECRET if refresh_token: from spotipy.cache_handler import MemoryCacheHandler token_info = { "access_token": access_token, "refresh_token": refresh_token, "token_type": "Bearer", "expires_in": 3600, "expires_at": 0, # Force refresh on next use "scope": "", } cache_handler = MemoryCacheHandler(token_info=token_info) auth = SpotifyOAuth( client_id=effective_client_id, client_secret=effective_client_secret, redirect_uri=settings.SPOTIFY_REDIRECT_URI, cache_handler=cache_handler, ) return spotipy.Spotify(auth_manager=auth) return spotipy.Spotify(auth=access_token) def add_all_tracks( sp: spotipy.Spotify, user_id: str, playlist_id: str, uris: list[str], ) -> list[dict]: """Add tracks to a playlist in batches of 100. Returns list of snapshot responses. Mirrors: AuthenticatedSpotifyWebApi.AddAllTrackAsync() """ responses = [] for batch in _chunk(uris, _CHUNK_SIZE): resp = sp.playlist_add_items(playlist_id, batch) responses.append(resp) return responses def add_tracks_at_position( sp: spotipy.Spotify, user_id: str, playlist_id: str, uris: list[str], position: int, ) -> list[dict]: """Insert tracks at a specific position in a playlist, batched. Spotify's playlist_add_items accepts a `position` argument that places the inserted items before the given 0-based index. When batching, each subsequent batch is inserted after the previous batch (position + offset). """ responses = [] inserted = 0 for batch in _chunk(uris, _CHUNK_SIZE): resp = sp.playlist_add_items(playlist_id, batch, position=position + inserted) responses.append(resp) inserted += len(batch) return responses def delete_multiple_playlist_tracks( sp: spotipy.Spotify, user_id: str, playlist_id: str, uris: list[str], snapshot_id: Optional[str] = None, ) -> Optional[str]: """Remove tracks from a playlist by URI (in batches of 100). Returns the last snapshot_id. Mirrors: AuthenticatedSpotifyWebApi.DeleteMultiplePlaylistTracksAsync() """ last_snapshot = snapshot_id for batch in _chunk(uris, _CHUNK_SIZE): result = sp.playlist_remove_all_occurrences_of_items(playlist_id, batch) last_snapshot = result.get("snapshot_id", last_snapshot) return last_snapshot def delete_playlist_tracks_by_position( sp: spotipy.Spotify, user_id: str, playlist_id: str, positions: list[int], items: list[dict], snapshot_id: Optional[str] = None, ) -> Optional[str]: """Remove tracks from a playlist by position index (in batches of 100). Mirrors: AuthenticatedSpotifyWebApi.DeleteAllPlaylistTracksInPositionAsync() """ from collections import defaultdict uri_positions: dict[str, list[int]] = defaultdict(list) for pos in positions: uri = items[pos]["track"]["uri"] uri_positions[uri].append(pos) tracks = [ {"uri": uri, "positions": pos_list} for uri, pos_list in uri_positions.items() ] last_snapshot = snapshot_id for batch in _chunk(tracks, _CHUNK_SIZE): result = sp.playlist_remove_specific_occurrences_of_items( playlist_id, batch, last_snapshot ) last_snapshot = result.get("snapshot_id", last_snapshot) return last_snapshot def order_playlist_tracks( sp: spotipy.Spotify, user_id: str, playlist_id: str, range_start: int, range_length: int, insert_before: int, snapshot_id: Optional[str] = None, ) -> dict: """Reorder tracks within a playlist. Mirrors: AuthenticatedSpotifyWebApi.OrderPlaylistTracksAsync() """ return sp.playlist_reorder_items( # type: ignore[no-any-return] playlist_id, range_start=range_start, insert_before=insert_before, range_length=range_length, snapshot_id=snapshot_id, ) def set_playlist_details( sp: spotipy.Spotify, user_id: str, playlist_id: str, name: str, public: Optional[bool] = None, description: Optional[str] = None, ) -> None: """Update playlist name, visibility, and/or description. Mirrors: AuthenticatedSpotifyWebApi.SetPlaylistDetails() """ sp.playlist_change_details( playlist_id, name=name, public=public, description=description )