Files
tonehaus/src/Service/SpotifyClient.php
boris d52eb6bd81
All checks were successful
CI (Gitea) / php-tests (push) Successful in 10m8s
CI (Gitea) / docker-image (push) Successful in 2m18s
documentation and env changes
2025-11-28 08:14:13 +00:00

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 !== '';
}
}