Migration to last.fm, removed old spotify api client. FOSRestBundle API serving data driven front end (JWT).

This commit is contained in:
boris committed 2026-02-27 16:52:57 +00:00
1 parent 391ecf1732
commit 56dea097d6
51 files changed
+1749 -844

No files matched your search

+183 -141
View File
@@ -7,110 +7,90 @@ use App\Dto\AlbumSearchResult;
use App\Entity\Album;
use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository;
use App\Service\SpotifyGenreResolver;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* AlbumSearchService pulls albums from Spotify and the local catalog,
* AlbumSearchService pulls albums from Last.fm and the local catalog,
* adds review stats, and hands the UI one uniform structure to render.
*/
class AlbumSearchService
{
public function __construct(
private readonly SpotifyClient $spotifyClient,
private readonly LastFmClient $lastFmClient,
private readonly AlbumRepository $albumRepository,
private readonly ReviewRepository $reviewRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly SpotifyGenreResolver $genreResolver,
private readonly LastFmTagResolver $tagResolver,
) {
}
/**
* Runs the end-to-end search flow: figures out which sources to query, merges the payloads,
* and returns review stats plus saved IDs so templates get everything in one package.
* Runs the end-to-end search flow and returns review stats plus saved IDs.
*/
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
{
$spotifyQuery = $this->buildSpotifySearchQuery($criteria);
$hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery);
// Spotify only gets pinged when callers explicitly enable it and we actually have
// something to ask for (bare "all" requests would otherwise waste API calls).
$shouldQuerySpotify = $criteria->shouldUseSpotify()
&& ($spotifyQuery !== '' || $criteria->genre !== '' || $criteria->source === 'spotify');
$externalQuery = $this->buildExternalSearchQuery($criteria);
$hasUserFilters = $this->hasUserFilters($criteria, $externalQuery);
$shouldQueryExternal = $criteria->shouldUseExternal()
&& ($externalQuery !== '' || $criteria->genre !== '' || $criteria->source === 'external');
$stats = [];
$savedIds = [];
$spotifyPayloads = [];
$externalPayloads = [];
$userPayloads = [];
if ($shouldQuerySpotify) {
// Try to reuse cached Spotify albums and only hit the API if we still need more.
$spotifyData = $this->resolveSpotifyAlbums($criteria, $spotifyQuery);
$spotifyPayloads = $spotifyData['payloads'];
$stats = $this->mergeStats($stats, $spotifyData['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
if ($shouldQueryExternal) {
$externalData = $this->resolveExternalAlbums($criteria, $externalQuery);
$externalPayloads = $externalData['payloads'];
$stats = $this->mergeStats($stats, $externalData['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $externalData['savedIds']);
}
if ($criteria->shouldUseUserCatalog() && $hasUserFilters) {
// Skip the user query unless at least one meaningful filter is present.
$userData = $this->resolveUserAlbums($criteria);
$userPayloads = $userData['payloads'];
$stats = $this->mergeStats($stats, $userData['stats']);
}
$albums = $this->composeAlbumList($criteria->source, $userPayloads, $spotifyPayloads, $criteria->limit);
$albums = $this->composeAlbumList($criteria->source, $userPayloads, $externalPayloads, $criteria->limit);
return new AlbumSearchResult($criteria, $albums, $stats, $savedIds);
}
/**
* Indicates whether Spotify credentials are configured.
*/
public function isSpotifyConfigured(): bool
{
return $this->spotifyClient->isConfigured();
}
/**
* Turns structured filters into Spotify's free-form query syntax.
* Indicates whether Last.fm credentials are configured.
*/
private function buildSpotifySearchQuery(AlbumSearchCriteria $criteria): string
public function isExternalConfigured(): bool
{
return $this->lastFmClient->isConfigured();
}
private function buildExternalSearchQuery(AlbumSearchCriteria $criteria): string
{
$parts = [];
if ($criteria->albumName !== '') {
$parts[] = 'album:' . $criteria->albumName;
}
if ($criteria->artist !== '') {
$parts[] = 'artist:' . $criteria->artist;
}
if ($criteria->yearFrom !== null || $criteria->yearTo !== null) {
if ($criteria->yearFrom !== null && $criteria->yearTo !== null && $criteria->yearTo >= $criteria->yearFrom) {
$parts[] = 'year:' . $criteria->yearFrom . '-' . $criteria->yearTo;
} else {
$year = $criteria->yearFrom ?? $criteria->yearTo;
if ($year !== null) {
$parts[] = 'year:' . $year;
}
}
}
if ($criteria->query !== '') {
$parts[] = $criteria->query;
}
return implode(' ', $parts);
if ($criteria->albumName !== '') {
$parts[] = $criteria->albumName;
}
if ($criteria->artist !== '') {
$parts[] = $criteria->artist;
}
return trim(implode(' ', $parts));
}
/**
* Quick gate to tell if it's worth running the user catalog query.
*/
private function hasUserFilters(AlbumSearchCriteria $criteria, string $spotifyQuery): bool
private function hasUserFilters(AlbumSearchCriteria $criteria, string $externalQuery): bool
{
if ($criteria->source === 'user') {
return true;
}
return $spotifyQuery !== ''
return $externalQuery !== ''
|| $criteria->albumName !== ''
|| $criteria->artist !== ''
|| $criteria->genre !== ''
@@ -119,13 +99,11 @@ class AlbumSearchService
}
/**
* Looks up cached Spotify albums and, if needed, tops them off with fresh API data.
*
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array
private function resolveExternalAlbums(AlbumSearchCriteria $criteria, string $externalQuery): array
{
$storedSpotifyAlbums = $this->albumRepository->searchSpotifyAlbums(
$storedExternalAlbums = $this->albumRepository->searchExternalAlbums(
$criteria->query,
$criteria->albumName,
$criteria->artist,
@@ -134,100 +112,92 @@ class AlbumSearchService
$criteria->yearTo ?? 0,
$criteria->limit
);
$storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $storedSpotifyAlbums);
$storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $storedExternalAlbums);
$storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre);
$storedIds = $this->collectSpotifyIds($storedSpotifyAlbums);
$storedPayloads = $this->filterPayloadsByYear($storedPayloads, $criteria->yearFrom, $criteria->yearTo);
$storedPayloads = $this->hydrateExternalImages($storedPayloads);
$storedIds = $this->collectExternalIds($storedExternalAlbums);
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
$savedIds = $storedIds;
$shouldFetchFromSpotify = $spotifyQuery !== '' && count($storedSpotifyAlbums) < $criteria->limit;
if (!$shouldFetchFromSpotify) {
$shouldFetchFromExternal = $externalQuery !== '' && count($storedPayloads) < $criteria->limit;
if (!$shouldFetchFromExternal) {
return [
'payloads' => array_slice($storedPayloads, 0, $criteria->limit),
'stats' => $stats,
'savedIds' => $savedIds,
'savedIds' => $storedIds,
];
}
// Mix cached payloads with just enough fresh API data to satisfy the limit.
// This has the consequence of preferring cached data, but reduces API calls.
$apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads);
$apiPayloads = $this->fetchExternalPayloads($criteria, $externalQuery);
$filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->genre);
$payloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit);
$stats = $this->mergeStats($stats, $apiPayloads['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
$filteredApiPayloads = $this->filterPayloadsByYear($filteredApiPayloads, $criteria->yearFrom, $criteria->yearTo);
return ['payloads' => $payloads, 'stats' => $stats, 'savedIds' => $savedIds];
return [
'payloads' => $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit),
'stats' => $this->mergeStats($stats, $apiPayloads['stats']),
'savedIds' => $this->mergeSavedIds($storedIds, $apiPayloads['savedIds']),
];
}
/**
* Calls Spotify search/albums endpoints, syncs what we find, and returns normalized payloads, stats, and IDs.
*
* @param array<int,array<mixed>> $storedPayloads
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
private function fetchSpotifyPayloads(AlbumSearchCriteria $criteria, string $spotifyQuery, array $storedPayloads): array
private function fetchExternalPayloads(AlbumSearchCriteria $criteria, string $externalQuery): array
{
$result = $this->spotifyClient->searchAlbums($spotifyQuery, $criteria->limit);
$searchItems = $result['albums']['items'] ?? [];
$result = $this->lastFmClient->searchAlbums($externalQuery, $criteria->limit);
$searchItems = (array) ($result['albums']['items'] ?? []);
$this->logger->info('Album search results received', [
'query' => $spotifyQuery,
'items' => is_countable($searchItems) ? count($searchItems) : 0,
'query' => $externalQuery,
'items' => count($searchItems),
]);
if (!$searchItems) {
if ($searchItems === []) {
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
}
$ids = $this->extractSpotifyIds($searchItems);
$ids = $this->extractExternalIds($searchItems);
if ($ids === []) {
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
}
$full = $this->spotifyClient->getAlbums($ids);
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
if ($albumsPayload === [] && $searchItems !== []) {
$full = $this->lastFmClient->getAlbums($ids);
$albumsPayload = is_array($full) ? ((array) ($full['albums'] ?? [])) : [];
if ($albumsPayload === []) {
$albumsPayload = $searchItems;
$this->logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]);
}
// Resolve genres up-front (either from album payloads or artist fallbacks) so we store richer data.
$genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albumsPayload);
$genresByAlbum = $this->tagResolver->resolveGenresForAlbums($albumsPayload);
$upserted = 0;
foreach ($albumsPayload as $payload) {
// Persist each album so the next search can reuse it without another API call.
$albumId = (string) ($payload['id'] ?? '');
$this->albumRepository->upsertFromSpotifyAlbum(
$this->albumRepository->upsertFromExternalAlbum(
(array) $payload,
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
);
$upserted++;
}
$this->entityManager->flush();
$this->logger->info('Albums upserted to DB', ['upserted' => $upserted]);
$existing = $this->albumRepository->findBySpotifyIdsKeyed($ids);
$this->entityManager->flush();
$existing = $this->albumRepository->findByExternalIdsKeyed($ids);
$payloads = [];
foreach ($ids as $sid) {
if (isset($existing[$sid])) {
$payloads[] = $existing[$sid]->toTemplateArray();
foreach ($ids as $externalId) {
if (isset($existing[$externalId])) {
$payloads[] = $existing[$externalId]->toTemplateArray();
}
}
$stats = $this->reviewRepository->getAggregatesForAlbumIds($ids);
$payloads = $this->hydrateExternalImages($payloads);
return [
'payloads' => $payloads,
'stats' => $stats,
'stats' => $this->reviewRepository->getAggregatesForAlbumIds($ids),
'savedIds' => array_keys($existing),
];
}
/**
* Searches user-created albums and attaches review aggregates keyed to each payload.
*
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>}
*/
private function resolveUserAlbums(AlbumSearchCriteria $criteria): array
@@ -241,23 +211,21 @@ class AlbumSearchService
$criteria->yearTo ?? 0,
$criteria->limit
);
if ($userAlbums === []) {
return ['payloads' => [], 'stats' => []];
}
$entityIds = array_values(array_map(static fn(Album $album) => (int) $album->getId(), $userAlbums));
$userStats = $this->reviewRepository->getAggregatesForAlbumEntityIds($entityIds);
$payloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums);
return [
'payloads' => $payloads,
'payloads' => array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums),
'stats' => $this->mapUserStatsToLocalIds($userAlbums, $userStats),
];
}
/**
* Re-keys entity-based stats to the local IDs that templates actually use.
*
* @param list<Album> $userAlbums
* @param array<int,array{count:int,avg:float}> $userStats
* @return array<string,array{count:int,avg:float}>
@@ -269,56 +237,52 @@ class AlbumSearchService
$entityId = (int) $album->getId();
$localId = (string) $album->getLocalId();
if ($localId !== '' && isset($userStats[$entityId])) {
// Templates never see entity IDs, so stats must be re-keyed to the user-facing local IDs.
$mapped[$localId] = $userStats[$entityId];
}
}
return $mapped;
}
/**
* Chooses which payload list to return based on the requested source preference.
*
* @param array<int,array<mixed>> $userPayloads
* @param array<int,array<mixed>> $spotifyPayloads
* @param array<int,array<mixed>> $externalPayloads
* @return array<int,array<mixed>>
*/
private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array
private function composeAlbumList(string $source, array $userPayloads, array $externalPayloads, int $limit): array
{
if ($source === 'user') {
return array_slice($userPayloads, 0, $limit);
}
if ($source === 'spotify') {
return array_slice($spotifyPayloads, 0, $limit);
if ($source === 'external') {
return array_slice($externalPayloads, 0, $limit);
}
return array_slice(array_merge($userPayloads, $spotifyPayloads), 0, $limit);
return array_slice(array_merge($userPayloads, $externalPayloads), 0, $limit);
}
/**
* Collects Spotify IDs from hydrated album entities while skipping blanks.
*
* @param list<Album> $albums
* @return list<string>
*/
private function collectSpotifyIds(array $albums): array
private function collectExternalIds(array $albums): array
{
$ids = [];
foreach ($albums as $album) {
$sid = (string) $album->getSpotifyId();
if ($sid !== '') {
$ids[] = $sid;
$externalId = (string) $album->getExternalId();
if ($externalId !== '') {
$ids[] = $externalId;
}
}
return array_values(array_unique($ids));
}
/**
* Pulls distinct Spotify IDs out of the raw search payload to reduce follow-up calls.
*
* @param array<int,mixed> $searchItems
* @return list<string>
*/
private function extractSpotifyIds(array $searchItems): array
private function extractExternalIds(array $searchItems): array
{
$ids = [];
foreach ($searchItems as $item) {
@@ -327,12 +291,11 @@ class AlbumSearchService
$ids[] = $id;
}
}
return array_values(array_unique($ids));
}
/**
* Overwrites stats with the newest aggregates from the update set.
*
* @param array<string,array{count:int,avg:float}> $current
* @param array<string,array{count:int,avg:float}> $updates
* @return array<string,array{count:int,avg:float}>
@@ -342,12 +305,11 @@ class AlbumSearchService
foreach ($updates as $key => $value) {
$current[$key] = $value;
}
return $current;
}
/**
* Adds newly saved Spotify IDs while tossing blanks and duplicates.
*
* @param list<string> $current
* @param list<string> $updates
* @return list<string>
@@ -359,8 +321,6 @@ class AlbumSearchService
}
/**
* Combines payload lists without duplicates, preferring primary items and honoring the limit.
*
* @param array<int,array<mixed>> $primary
* @param array<int,array<mixed>> $secondary
* @return array<int,array<mixed>>
@@ -369,29 +329,32 @@ class AlbumSearchService
{
$seen = [];
$merged = [];
foreach ($primary as $payload) {
$merged[] = $payload;
if (isset($payload['id'])) {
$seen[$payload['id']] = true;
$id = (string) ($payload['id'] ?? '');
if ($id !== '') {
$seen[$id] = true;
}
$merged[] = $payload;
if (count($merged) >= $limit) {
return array_slice($merged, 0, $limit);
}
}
foreach ($secondary as $payload) {
$id = $payload['id'] ?? null;
if ($id !== null && isset($seen[$id])) {
$id = (string) ($payload['id'] ?? '');
if ($id !== '' && isset($seen[$id])) {
continue;
}
// Secondary payloads often duplicate the primary list; skip anything we've already emitted.
$merged[] = $payload;
if ($id !== null) {
if ($id !== '') {
$seen[$id] = true;
}
if (count($merged) >= $limit) {
break;
}
}
return array_slice($merged, 0, $limit);
}
@@ -401,12 +364,12 @@ class AlbumSearchService
*/
private function filterPayloadsByGenre(array $payloads, string $genreNeedle): array
{
$genreNeedle = trim($genreNeedle);
if ($genreNeedle === '') {
$needle = trim(mb_strtolower($genreNeedle));
if ($needle === '') {
return $payloads;
}
$needle = mb_strtolower($genreNeedle);
return array_values(array_filter($payloads, static function(array $payload) use ($needle): bool {
return array_values(array_filter($payloads, static function (array $payload) use ($needle): bool {
$genres = $payload['genres'] ?? [];
if (!is_array($genres)) {
return false;
@@ -419,5 +382,84 @@ class AlbumSearchService
return false;
}));
}
}
/**
* @param array<int,array<mixed>> $payloads
* @return array<int,array<mixed>>
*/
private function filterPayloadsByYear(array $payloads, ?int $yearFrom, ?int $yearTo): array
{
if ($yearFrom === null && $yearTo === null) {
return $payloads;
}
return array_values(array_filter($payloads, static function (array $payload) use ($yearFrom, $yearTo): bool {
$releaseDate = trim((string) ($payload['release_date'] ?? ''));
if (!preg_match('/^\d{4}/', $releaseDate, $matches)) {
return false;
}
$year = (int) $matches[0];
if ($yearFrom !== null && $year < $yearFrom) {
return false;
}
if ($yearTo !== null && $year > $yearTo) {
return false;
}
return true;
}));
}
/**
* @param array<int,array<mixed>> $payloads
* @return array<int,array<mixed>>
*/
private function hydrateExternalImages(array $payloads): array
{
$externalIds = [];
foreach ($payloads as $payload) {
if ((string) ($payload['source'] ?? '') !== 'external') {
continue;
}
$id = trim((string) ($payload['id'] ?? ''));
if ($id !== '') {
$externalIds[] = $id;
}
}
if ($externalIds === []) {
return $payloads;
}
$full = $this->lastFmClient->getAlbums(array_values(array_unique($externalIds)));
$albums = is_array($full) ? ((array) ($full['albums'] ?? [])) : [];
if ($albums === []) {
return $payloads;
}
$imagesByAlbum = [];
foreach ($albums as $album) {
if (!is_array($album)) {
continue;
}
$id = trim((string) ($album['id'] ?? ''));
$images = $album['images'] ?? null;
if ($id === '' || !is_array($images) || $images === []) {
continue;
}
$imagesByAlbum[$id] = $images;
}
if ($imagesByAlbum === []) {
return $payloads;
}
foreach ($payloads as $index => $payload) {
$id = trim((string) ($payload['id'] ?? ''));
if ($id !== '' && isset($imagesByAlbum[$id])) {
$payloads[$index]['images'] = $imagesByAlbum[$id];
}
}
return $payloads;
}
}