293 lines
9.5 KiB
PHP
293 lines
9.5 KiB
PHP
<?php
|
|
|
|
namespace App\Service;
|
|
|
|
use App\Repository\SettingRepository;
|
|
use Symfony\Contracts\Cache\CacheInterface;
|
|
use Symfony\Contracts\Cache\ItemInterface;
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
|
|
/**
|
|
* SpotifyClient wraps API calls with caching and error handling.
|
|
*/
|
|
class SpotifyClient
|
|
{
|
|
private HttpClientInterface $httpClient;
|
|
private CacheInterface $cache;
|
|
private ?string $clientId;
|
|
private ?string $clientSecret;
|
|
private SettingRepository $settings;
|
|
|
|
/**
|
|
* Builds the client with HTTP, cache, and configuration dependencies.
|
|
*/
|
|
public function __construct(
|
|
HttpClientInterface $httpClient,
|
|
CacheInterface $cache,
|
|
?string $clientId,
|
|
?string $clientSecret,
|
|
SettingRepository $settings
|
|
) {
|
|
$this->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<mixed>
|
|
*/
|
|
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<mixed>|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<mixed>|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<array<string,mixed>>
|
|
*/
|
|
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<string> $albumIds
|
|
* @return array<mixed>|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<string> $artistIds
|
|
* @return array<mixed>|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<string,mixed> $options
|
|
* @return array<mixed>
|
|
*/
|
|
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<mixed>|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 !== '';
|
|
}
|
|
}
|
|
|
|
|