httpClient = $httpClient; $this->cache = $cache; $this->clientId = $clientId; $this->clientSecret = $clientSecret; $this->settings = $settings; } /** * Search Spotify albums by query string. * * @param string $query * @param int $limit * @return array */ public function searchAlbums(string $query, int $limit = 12): array { $accessToken = $this->getAccessToken(); if ($accessToken === null) { return ['albums' => ['items' => []]]; } $url = 'https://api.spotify.com/v1/search'; $options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ], 'query' => [ 'q' => $query, 'type' => 'album', 'limit' => $limit ], ]; return $this->sendRequest('GET', $url, $options, 600); } /** * Fetch a single album by Spotify ID. * * @return array|null */ public function getAlbum(string $albumId): ?array { $accessToken = $this->getAccessToken(); if ($accessToken === null) { return null; } $url = 'https://api.spotify.com/v1/albums/' . urlencode($albumId); $options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ] ]; try { return $this->sendRequest('GET', $url, $options, 3600); } catch (\Throwable) { return null; } } /** * Fetches album metadata plus the full tracklist. * * @return array|null */ public function getAlbumWithTracks(string $albumId): ?array { $album = $this->getAlbum($albumId); if ($album === null) { return null; } $tracks = $this->getAlbumTracks($albumId); if ($tracks !== []) { $album['tracks'] = $album['tracks'] ?? []; $album['tracks']['items'] = $tracks; $album['tracks']['total'] = count($tracks); $album['tracks']['limit'] = count($tracks); $album['tracks']['offset'] = 0; $album['tracks']['next'] = null; $album['tracks']['previous'] = null; } return $album; } /** * Retrieves the complete tracklist for an album. * * @return list> */ public function getAlbumTracks(string $albumId): array { $accessToken = $this->getAccessToken(); if ($accessToken === null) { return []; } $items = []; $limit = 50; $offset = 0; do { // Spotify returns tracks in pages of 50, so iterate until there are no further pages. $page = $this->requestAlbumTracksPage($albumId, $accessToken, $limit, $offset); if ($page === null) { break; } $batch = (array) ($page['items'] ?? []); $items = array_merge($items, $batch); $offset += $limit; $total = isset($page['total']) ? (int) $page['total'] : null; $hasNext = isset($page['next']) && $page['next'] !== null; // Guard against Spotify omitting total by relying on the "next" cursor. // Ensures album requests stop when Spotify has no more pages. } while ($hasNext && ($total === null || $offset < $total)); return $items; } /** * Fetch multiple albums with one call. * * @param list $albumIds * @return array|null */ public function getAlbums(array $albumIds): ?array { if ($albumIds === []) { return []; } $accessToken = $this->getAccessToken(); if ($accessToken === null) { return null; } $url = 'https://api.spotify.com/v1/albums'; $options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ], 'query' => [ 'ids' => implode(',', $albumIds) ], ]; try { return $this->sendRequest('GET', $url, $options, 3600); } catch (\Throwable) { return null; } } /** * Fetch multiple artists to gather genre information. * * @param list $artistIds * @return array|null */ public function getArtists(array $artistIds): ?array { if ($artistIds === []) { return []; } $accessToken = $this->getAccessToken(); if ($accessToken === null) { return null; } $url = 'https://api.spotify.com/v1/artists'; $options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ], 'query' => [ 'ids' => implode(',', $artistIds) ], ]; try { return $this->sendRequest('GET', $url, $options, 1800); } catch (\Throwable) { return null; } } /** * Centralized request helper with lightweight caching. * * @param array $options * @return array */ private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0): array { $request = function () use ($method, $url, $options): array { $response = $this->httpClient->request($method, $url, $options); return $response->toArray(false); }; $shouldCache = $cacheTtlSeconds > 0 && strtoupper($method) === 'GET'; if ($shouldCache) { // Cache fingerprint mixes URL and query only; headers are static (Bearer token). $cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? [])); return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) { $item->expiresAfter($cacheTtlSeconds); return $request(); }); } return $request(); } /** * Requests one paginated track list page for an album using the provided OAuth token. * * @return array|null */ private function requestAlbumTracksPage(string $albumId, string $accessToken, int $limit, int $offset): ?array { $url = sprintf('https://api.spotify.com/v1/albums/%s/tracks', urlencode($albumId)); $options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ], 'query' => [ 'limit' => $limit, 'offset' => $offset ], ]; try { return $this->sendRequest('GET', $url, $options, 1200); } catch (\Throwable) { return null; } } /** * Retrieves a cached access token or refreshes credentials when missing. */ private function getAccessToken(): ?string { $cacheKey = 'spotify_client_credentials_token'; $token = $this->cache->get($cacheKey, function (ItemInterface $item) { // Default to ~1 hour, adjusted after Spotify response $item->expiresAfter(3500); $clientId = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? '')); $clientSecret = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? '')); if ($clientId === '' || $clientSecret === '') { // surface the miss quickly so the cache can be recomputed on the next request $item->expiresAfter(60); return null; } $response = $this->httpClient->request('POST', 'https://accounts.spotify.com/api/token', [ 'headers' => [ 'Authorization' => 'Basic ' . base64_encode($clientId . ':' . $clientSecret), 'Content-Type' => 'application/x-www-form-urlencoded', ], 'body' => 'grant_type=client_credentials', ]); $data = $response->toArray(false); if (!isset($data['access_token'])) { $item->expiresAfter(60); return null; } if (isset($data['expires_in']) && is_int($data['expires_in'])) { $ttl = max(60, $data['expires_in'] - 60); $item->expiresAfter($ttl); } return $data['access_token']; }); if ($token === null) { // Nuke cached nulls so the next request retries instead of reusing the failure sentinel. $this->cache->delete($cacheKey); } return $token; } /** * Returns true when credentials are available from DB or environment. */ public function isConfigured(): bool { $clientId = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? '')); $clientSecret = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? '')); return $clientId !== '' && $clientSecret !== ''; } }