I lowkey forgot to commit
This commit is contained in:
220
src/Service/SpotifyClient.php
Normal file
220
src/Service/SpotifyClient.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use App\Repository\SettingRepository;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class SpotifyClient
|
||||
{
|
||||
private HttpClientInterface $httpClient;
|
||||
private CacheInterface $cache;
|
||||
private ?string $clientId;
|
||||
private ?string $clientSecret;
|
||||
private SettingRepository $settings;
|
||||
private int $rateWindowSeconds;
|
||||
private int $rateMaxRequests;
|
||||
private int $rateMaxRequestsSensitive;
|
||||
|
||||
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;
|
||||
// Allow tuning via env vars; fallback to conservative defaults
|
||||
$this->rateWindowSeconds = (int) (getenv('SPOTIFY_RATE_WINDOW_SECONDS') ?: 30);
|
||||
$this->rateMaxRequests = (int) (getenv('SPOTIFY_RATE_MAX_REQUESTS') ?: 50);
|
||||
$this->rateMaxRequestsSensitive = (int) (getenv('SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE') ?: 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, false);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, false);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized request with basic throttling, caching and 429 handling.
|
||||
*
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0, bool $sensitive = false): array
|
||||
{
|
||||
$cacheKey = null;
|
||||
if ($cacheTtlSeconds > 0 && strtoupper($method) === 'GET') {
|
||||
$cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? []));
|
||||
$cached = $this->cache->get($cacheKey, function($item) use ($cacheTtlSeconds) {
|
||||
// placeholder; we'll set item value explicitly below on miss
|
||||
$item->expiresAfter(1);
|
||||
return null;
|
||||
});
|
||||
if (is_array($cached) && !empty($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$this->throttle($sensitive);
|
||||
|
||||
$attempts = 0;
|
||||
while (true) {
|
||||
++$attempts;
|
||||
$response = $this->httpClient->request($method, $url, $options);
|
||||
$status = $response->getStatusCode();
|
||||
|
||||
if ($status === 429) {
|
||||
$retryAfter = (int) ($response->getHeaders()['retry-after'][0] ?? 1);
|
||||
$retryAfter = max(1, min(30, $retryAfter));
|
||||
sleep($retryAfter);
|
||||
if ($attempts < 3) { continue; }
|
||||
}
|
||||
|
||||
$data = $response->toArray(false);
|
||||
if ($cacheKey && $cacheTtlSeconds > 0 && is_array($data)) {
|
||||
$this->cache->get($cacheKey, function($item) use ($data, $cacheTtlSeconds) {
|
||||
$item->expiresAfter($cacheTtlSeconds);
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
private function throttle(bool $sensitive): void
|
||||
{
|
||||
$windowKey = $sensitive ? 'spotify_rate_sensitive' : 'spotify_rate';
|
||||
$max = $sensitive ? $this->rateMaxRequestsSensitive : $this->rateMaxRequests;
|
||||
$now = time();
|
||||
$entry = $this->cache->get($windowKey, function($item) use ($now) {
|
||||
$item->expiresAfter($this->rateWindowSeconds);
|
||||
return ['start' => $now, 'count' => 0];
|
||||
});
|
||||
if (!is_array($entry) || !isset($entry['start'], $entry['count'])) {
|
||||
$entry = ['start' => $now, 'count' => 0];
|
||||
}
|
||||
$start = (int) $entry['start'];
|
||||
$count = (int) $entry['count'];
|
||||
$elapsed = $now - $start;
|
||||
if ($elapsed >= $this->rateWindowSeconds) {
|
||||
$start = $now; $count = 0;
|
||||
}
|
||||
if ($count >= $max) {
|
||||
$sleep = max(1, $this->rateWindowSeconds - $elapsed);
|
||||
sleep($sleep);
|
||||
$start = time(); $count = 0;
|
||||
}
|
||||
$count++;
|
||||
$newEntry = ['start' => $start, 'count' => $count];
|
||||
$this->cache->get($windowKey, function($item) use ($newEntry) {
|
||||
$item->expiresAfter($this->rateWindowSeconds);
|
||||
return $newEntry;
|
||||
});
|
||||
}
|
||||
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
return $this->cache->get('spotify_client_credentials_token', function ($item) {
|
||||
// Default to 1 hour, will adjust based on response
|
||||
$item->expiresAfter(3500);
|
||||
|
||||
$clientId = $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? '');
|
||||
$clientSecret = $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? '');
|
||||
if ($clientId === '' || $clientSecret === '') {
|
||||
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'])) {
|
||||
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'];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user