I lowkey forgot to commit

This commit is contained in:
2025-11-01 00:28:29 +00:00
parent f9e747633f
commit c0528310c1
54 changed files with 2154 additions and 7 deletions

View 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'];
});
}
}