its 7am i havent slept i have no idea
This commit is contained in:
1 parent
336dcc4d3a
commit
f77f3a9e40
34 files changed
+1142
-183
No files matched your search
@@ -7,11 +7,13 @@ 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 composes Spotify and user albums into reusable payloads.
|
||||
* AlbumSearchService pulls albums from Spotify and the local catalog,
|
||||
* adds review stats, and hands the UI one uniform structure to render.
|
||||
*/
|
||||
class AlbumSearchService
|
||||
{
|
||||
@@ -21,20 +23,30 @@ class AlbumSearchService
|
||||
private readonly ReviewRepository $reviewRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly SpotifyGenreResolver $genreResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
|
||||
{
|
||||
$spotifyQuery = $this->buildSpotifyQuery($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->useSpotify()
|
||||
&& ($spotifyQuery !== '' || $criteria->getGenre() !== '' || $criteria->source === 'spotify');
|
||||
|
||||
$stats = [];
|
||||
$savedIds = [];
|
||||
$spotifyPayloads = [];
|
||||
$userPayloads = [];
|
||||
|
||||
if ($criteria->useSpotify() && $spotifyQuery !== '') {
|
||||
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']);
|
||||
@@ -42,6 +54,7 @@ class AlbumSearchService
|
||||
}
|
||||
|
||||
if ($criteria->useUser() && $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']);
|
||||
@@ -52,6 +65,9 @@ class AlbumSearchService
|
||||
return new AlbumSearchResult($criteria, $albums, $stats, $savedIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns structured filters into Spotify's free-form query syntax.
|
||||
*/
|
||||
private function buildSpotifyQuery(AlbumSearchCriteria $criteria): string
|
||||
{
|
||||
$parts = [];
|
||||
@@ -77,34 +93,48 @@ class AlbumSearchService
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick gate to tell if it's worth running the user catalog query.
|
||||
*/
|
||||
private function hasUserFilters(AlbumSearchCriteria $criteria, string $spotifyQuery): bool
|
||||
{
|
||||
if ($criteria->source === 'user') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $spotifyQuery !== ''
|
||||
|| $criteria->albumName !== ''
|
||||
|| $criteria->artist !== ''
|
||||
|| $criteria->getGenre() !== ''
|
||||
|| $criteria->yearFrom !== null
|
||||
|| $criteria->yearTo !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$stored = $this->albumRepository->searchSpotifyAlbums(
|
||||
$spotifyQuery,
|
||||
$criteria->query,
|
||||
$criteria->albumName,
|
||||
$criteria->artist,
|
||||
$criteria->getGenre(),
|
||||
$criteria->yearFrom ?? 0,
|
||||
$criteria->yearTo ?? 0,
|
||||
$criteria->limit
|
||||
);
|
||||
$storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $stored);
|
||||
$storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->getGenre());
|
||||
$storedIds = $this->collectSpotifyIds($stored);
|
||||
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
|
||||
$savedIds = $storedIds;
|
||||
|
||||
if (count($stored) >= $criteria->limit) {
|
||||
$shouldFetchFromSpotify = $spotifyQuery !== '' && count($stored) < $criteria->limit;
|
||||
|
||||
if (!$shouldFetchFromSpotify) {
|
||||
return [
|
||||
'payloads' => array_slice($storedPayloads, 0, $criteria->limit),
|
||||
'stats' => $stats,
|
||||
@@ -112,8 +142,10 @@ class AlbumSearchService
|
||||
];
|
||||
}
|
||||
|
||||
// Mix cached payloads with just enough fresh API data to satisfy the limit.
|
||||
$apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads);
|
||||
$payloads = $this->mergePayloadLists($apiPayloads['payloads'], $storedPayloads, $criteria->limit);
|
||||
$filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->getGenre());
|
||||
$payloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit);
|
||||
$stats = $this->mergeStats($stats, $apiPayloads['stats']);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
|
||||
|
||||
@@ -121,6 +153,8 @@ class AlbumSearchService
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>}
|
||||
*/
|
||||
@@ -149,9 +183,17 @@ class AlbumSearchService
|
||||
$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);
|
||||
|
||||
$upserted = 0;
|
||||
foreach ($albumsPayload as $payload) {
|
||||
$this->albumRepository->upsertFromSpotifyAlbum((array) $payload);
|
||||
// Persist each album so the next search can reuse it without another API call.
|
||||
$albumId = (string) ($payload['id'] ?? '');
|
||||
$this->albumRepository->upsertFromSpotifyAlbum(
|
||||
(array) $payload,
|
||||
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
|
||||
);
|
||||
$upserted++;
|
||||
}
|
||||
$this->em->flush();
|
||||
@@ -175,6 +217,8 @@ class AlbumSearchService
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -183,6 +227,7 @@ class AlbumSearchService
|
||||
$criteria->query,
|
||||
$criteria->albumName,
|
||||
$criteria->artist,
|
||||
$criteria->getGenre(),
|
||||
$criteria->yearFrom ?? 0,
|
||||
$criteria->yearTo ?? 0,
|
||||
$criteria->limit
|
||||
@@ -202,6 +247,8 @@ class AlbumSearchService
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}>
|
||||
@@ -219,6 +266,13 @@ class AlbumSearchService
|
||||
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
|
||||
* @return array<int,array<mixed>>
|
||||
*/
|
||||
private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array
|
||||
{
|
||||
if ($source === 'user') {
|
||||
@@ -231,6 +285,8 @@ class AlbumSearchService
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects Spotify IDs from hydrated album entities while skipping blanks.
|
||||
*
|
||||
* @param list<Album> $albums
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -247,6 +303,8 @@ class AlbumSearchService
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls distinct Spotify IDs out of the raw search payload to reduce follow-up calls.
|
||||
*
|
||||
* @param array<int,mixed> $searchItems
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -262,6 +320,13 @@ class AlbumSearchService
|
||||
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}>
|
||||
*/
|
||||
private function mergeStats(array $current, array $updates): array
|
||||
{
|
||||
foreach ($updates as $key => $value) {
|
||||
@@ -270,6 +335,13 @@ class AlbumSearchService
|
||||
return $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds newly saved Spotify IDs while tossing blanks and duplicates.
|
||||
*
|
||||
* @param list<string> $current
|
||||
* @param list<string> $updates
|
||||
* @return list<string>
|
||||
*/
|
||||
private function mergeSavedIds(array $current, array $updates): array
|
||||
{
|
||||
$merged = array_merge($current, array_filter($updates, static fn($id) => $id !== ''));
|
||||
@@ -277,6 +349,8 @@ 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>>
|
||||
@@ -299,6 +373,7 @@ class AlbumSearchService
|
||||
if ($id !== null && isset($seen[$id])) {
|
||||
continue;
|
||||
}
|
||||
// Fill the remainder of the list with secondary payloads that have not already been emitted.
|
||||
$merged[] = $payload;
|
||||
if ($id !== null) {
|
||||
$seen[$id] = true;
|
||||
@@ -309,5 +384,30 @@ class AlbumSearchService
|
||||
}
|
||||
return array_slice($merged, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $payloads
|
||||
* @return array<int,array<mixed>>
|
||||
*/
|
||||
private function filterPayloadsByGenre(array $payloads, string $genreNeedle): array
|
||||
{
|
||||
$genreNeedle = trim($genreNeedle);
|
||||
if ($genreNeedle === '') {
|
||||
return $payloads;
|
||||
}
|
||||
$needle = mb_strtolower($genreNeedle);
|
||||
return array_values(array_filter($payloads, static function(array $payload) use ($needle): bool {
|
||||
$genres = $payload['genres'] ?? [];
|
||||
if (!is_array($genres)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($genres as $genre) {
|
||||
if (str_contains(mb_strtolower((string) $genre), $needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user