what the fuck
This commit is contained in:
1 parent
c1b1c2f372
commit
72a99a5bcd
69 files changed
+7519
-558
No files matched your search
@@ -11,13 +11,21 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* AlbumSearchService pulls albums from Last.fm and the local catalog,
|
||||
* AlbumSearchService pulls albums from the configured external provider and local catalog,
|
||||
* adds review stats, and hands the UI one uniform structure to render.
|
||||
*/
|
||||
class AlbumSearchService
|
||||
{
|
||||
private const MAX_EXTERNAL_SEARCH_PAGES = 3;
|
||||
private const MAX_FAST_SEARCH_PAGES = 2;
|
||||
private const MAX_IMAGE_HYDRATION_ALBUMS = 20;
|
||||
private const DETAIL_VALIDATION_BATCH_SIZE = 24;
|
||||
private const VALIDATION_ID_HEADROOM = 8;
|
||||
/** @var array<string,mixed> */
|
||||
private array $lastSearchProfile = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly LastFmClient $lastFmClient,
|
||||
private readonly ExternalAlbumClientInterface $externalClient,
|
||||
private readonly AlbumRepository $albumRepository,
|
||||
private readonly ReviewRepository $reviewRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
@@ -29,13 +37,21 @@ class AlbumSearchService
|
||||
/**
|
||||
* Runs the end-to-end search flow and returns review stats plus saved IDs.
|
||||
*/
|
||||
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
|
||||
public function search(AlbumSearchCriteria $criteria, bool $preferFastExternal = false): AlbumSearchResult
|
||||
{
|
||||
$searchStartedAt = microtime(true);
|
||||
$externalQuery = $this->buildExternalSearchQuery($criteria);
|
||||
$hasUserFilters = $this->hasUserFilters($criteria, $externalQuery);
|
||||
$this->resetSearchProfile($criteria, $externalQuery, $preferFastExternal);
|
||||
|
||||
$shouldQueryExternal = $criteria->shouldUseExternal()
|
||||
&& ($externalQuery !== '' || $criteria->genre !== '' || $criteria->source === 'external');
|
||||
&& (
|
||||
$externalQuery !== ''
|
||||
|| $criteria->genre !== ''
|
||||
|| $criteria->yearFrom !== null
|
||||
|| $criteria->yearTo !== null
|
||||
|| $criteria->source === 'external'
|
||||
);
|
||||
|
||||
$stats = [];
|
||||
$savedIds = [];
|
||||
@@ -43,33 +59,56 @@ class AlbumSearchService
|
||||
$userPayloads = [];
|
||||
|
||||
if ($shouldQueryExternal) {
|
||||
$externalData = $this->resolveExternalAlbums($criteria, $externalQuery);
|
||||
$externalStartedAt = microtime(true);
|
||||
$externalData = $this->resolveExternalAlbums($criteria, $externalQuery, $preferFastExternal);
|
||||
$externalPayloads = $externalData['payloads'];
|
||||
$stats = $this->mergeStats($stats, $externalData['stats']);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, $externalData['savedIds']);
|
||||
$this->recordTimingMs('external', $externalStartedAt);
|
||||
}
|
||||
|
||||
if ($criteria->shouldUseUserCatalog() && $hasUserFilters) {
|
||||
$userStartedAt = microtime(true);
|
||||
$userData = $this->resolveUserAlbums($criteria);
|
||||
$userPayloads = $userData['payloads'];
|
||||
$stats = $this->mergeStats($stats, $userData['stats']);
|
||||
$this->recordTimingMs('user', $userStartedAt);
|
||||
}
|
||||
|
||||
$composeStartedAt = microtime(true);
|
||||
$albums = $this->composeAlbumList($criteria->source, $userPayloads, $externalPayloads, $criteria->limit);
|
||||
$this->recordTimingMs('compose', $composeStartedAt);
|
||||
$this->setCount('result_albums', count($albums));
|
||||
$this->recordTimingMs('total', $searchStartedAt);
|
||||
$this->logger->info('Album search profile', $this->lastSearchProfile);
|
||||
|
||||
return new AlbumSearchResult($criteria, $albums, $stats, $savedIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns metrics collected from the latest search() call in this request.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function getLastSearchProfile(): array
|
||||
{
|
||||
return $this->lastSearchProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether Last.fm credentials are configured.
|
||||
*/
|
||||
public function isExternalConfigured(): bool
|
||||
{
|
||||
return $this->lastFmClient->isConfigured();
|
||||
return $this->externalClient->isConfigured();
|
||||
}
|
||||
|
||||
private function buildExternalSearchQuery(AlbumSearchCriteria $criteria): string
|
||||
{
|
||||
if ($this->externalClient->getProviderName() === 'musicbrainz') {
|
||||
return $this->buildMusicBrainzSearchQuery($criteria);
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
if ($criteria->query !== '') {
|
||||
$parts[] = $criteria->query;
|
||||
@@ -81,9 +120,67 @@ class AlbumSearchService
|
||||
$parts[] = $criteria->artist;
|
||||
}
|
||||
|
||||
// Faceted-only searches (genre/date without text) still need a query token upstream.
|
||||
if ($parts === [] && $criteria->genre !== '') {
|
||||
$parts[] = $criteria->genre;
|
||||
}
|
||||
if ($parts === [] && $criteria->yearFrom !== null) {
|
||||
$parts[] = (string) $criteria->yearFrom;
|
||||
}
|
||||
if ($parts === [] && $criteria->yearTo !== null) {
|
||||
$parts[] = (string) $criteria->yearTo;
|
||||
}
|
||||
|
||||
return trim(implode(' ', $parts));
|
||||
}
|
||||
|
||||
private function buildMusicBrainzSearchQuery(AlbumSearchCriteria $criteria): string
|
||||
{
|
||||
$clauses = [];
|
||||
|
||||
$query = trim($criteria->query);
|
||||
if ($query !== '') {
|
||||
$queryPhrase = $this->musicBrainzPhrase($query);
|
||||
$clauses[] = sprintf('(release:%s OR artist:%s)', $queryPhrase, $queryPhrase);
|
||||
}
|
||||
|
||||
if ($criteria->albumName !== '') {
|
||||
$clauses[] = 'release:' . $this->musicBrainzPhrase($criteria->albumName);
|
||||
}
|
||||
|
||||
if ($criteria->artist !== '') {
|
||||
$clauses[] = 'artist:' . $this->musicBrainzPhrase($criteria->artist);
|
||||
}
|
||||
|
||||
if ($criteria->genre !== '') {
|
||||
// MusicBrainz indexes style/tags via the `tag` field.
|
||||
$clauses[] = 'tag:' . $this->musicBrainzPhrase($criteria->genre);
|
||||
}
|
||||
|
||||
if ($criteria->yearFrom !== null || $criteria->yearTo !== null) {
|
||||
$from = $criteria->yearFrom !== null ? (string) $criteria->yearFrom : '*';
|
||||
$to = $criteria->yearTo !== null ? (string) $criteria->yearTo : '*';
|
||||
$clauses[] = sprintf('date:[%s TO %s]', $from, $to);
|
||||
}
|
||||
|
||||
if ($clauses === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode(' AND ', $clauses);
|
||||
}
|
||||
|
||||
private function musicBrainzPhrase(string $raw): string
|
||||
{
|
||||
$value = trim($raw);
|
||||
if ($value === '') {
|
||||
return '""';
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
|
||||
return '"' . $escaped . '"';
|
||||
}
|
||||
|
||||
private function hasUserFilters(AlbumSearchCriteria $criteria, string $externalQuery): bool
|
||||
{
|
||||
if ($criteria->source === 'user') {
|
||||
@@ -101,61 +198,137 @@ class AlbumSearchService
|
||||
/**
|
||||
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
|
||||
*/
|
||||
private function resolveExternalAlbums(AlbumSearchCriteria $criteria, string $externalQuery): array
|
||||
private function resolveExternalAlbums(AlbumSearchCriteria $criteria, string $externalQuery, bool $preferFastExternal): array
|
||||
{
|
||||
$providerName = $this->externalClient->getProviderName();
|
||||
$skipGenrePostFilter = $providerName === 'musicbrainz' && $criteria->genre !== '';
|
||||
$storedGenreFilter = $skipGenrePostFilter ? '' : $criteria->genre;
|
||||
|
||||
$storedExternalAlbums = $this->albumRepository->searchExternalAlbums(
|
||||
$criteria->query,
|
||||
$criteria->albumName,
|
||||
$criteria->artist,
|
||||
$criteria->genre,
|
||||
$storedGenreFilter,
|
||||
$criteria->yearFrom ?? 0,
|
||||
$criteria->yearTo ?? 0,
|
||||
$criteria->limit
|
||||
);
|
||||
$this->setCount('stored_external', count($storedExternalAlbums));
|
||||
|
||||
$storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $storedExternalAlbums);
|
||||
$storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre);
|
||||
$storedPayloads = $this->filterPayloadsByTrackCount($storedPayloads);
|
||||
$storedPayloads = $this->filterPayloadsByArtist($storedPayloads, $criteria->artist);
|
||||
if (!$skipGenrePostFilter) {
|
||||
$storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre);
|
||||
}
|
||||
$storedPayloads = $this->filterPayloadsByYear($storedPayloads, $criteria->yearFrom, $criteria->yearTo);
|
||||
$storedPayloads = $this->hydrateExternalImages($storedPayloads);
|
||||
|
||||
$storedIds = $this->collectExternalIds($storedExternalAlbums);
|
||||
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
|
||||
|
||||
// Cache-first policy: only hit Last.fm when local storage cannot satisfy this search.
|
||||
$shouldFetchFromExternal = $externalQuery !== '' && count($storedPayloads) < $criteria->limit;
|
||||
if (!$shouldFetchFromExternal) {
|
||||
$limitedStoredPayloads = array_slice($storedPayloads, 0, $criteria->limit);
|
||||
return [
|
||||
'payloads' => array_slice($storedPayloads, 0, $criteria->limit),
|
||||
'payloads' => $this->hydrateExternalImages($limitedStoredPayloads),
|
||||
'stats' => $stats,
|
||||
'savedIds' => $storedIds,
|
||||
];
|
||||
}
|
||||
|
||||
$apiPayloads = $this->fetchExternalPayloads($criteria, $externalQuery);
|
||||
$filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->genre);
|
||||
$useSearchOnlyFastPath = $preferFastExternal && !$this->requiresDetailedExternalFetch($criteria);
|
||||
$apiPayloads = $useSearchOnlyFastPath
|
||||
? $this->fetchExternalPayloadsSearchOnly($criteria, $externalQuery)
|
||||
: $this->fetchExternalPayloads($criteria, $externalQuery);
|
||||
$filteredApiPayloads = $this->filterPayloadsByArtist($apiPayloads['payloads'], $criteria->artist);
|
||||
if (!$skipGenrePostFilter) {
|
||||
$filteredApiPayloads = $this->filterPayloadsByGenre($filteredApiPayloads, $criteria->genre);
|
||||
}
|
||||
$filteredApiPayloads = $this->filterPayloadsByYear($filteredApiPayloads, $criteria->yearFrom, $criteria->yearTo);
|
||||
$mergedPayloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit);
|
||||
|
||||
return [
|
||||
'payloads' => $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit),
|
||||
'payloads' => $this->hydrateExternalImages($mergedPayloads),
|
||||
'stats' => $this->mergeStats($stats, $apiPayloads['stats']),
|
||||
'savedIds' => $this->mergeSavedIds($storedIds, $apiPayloads['savedIds']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fastest path: search payload only (name/id/image), no detail fan-out.
|
||||
*
|
||||
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
|
||||
*/
|
||||
private function fetchExternalPayloadsSearchOnly(AlbumSearchCriteria $criteria, string $externalQuery): array
|
||||
{
|
||||
$perPage = max($criteria->limit, 10);
|
||||
$seenIds = [];
|
||||
$payloads = [];
|
||||
|
||||
for ($page = 1; $page <= self::MAX_FAST_SEARCH_PAGES && count($payloads) < $criteria->limit; $page++) {
|
||||
$this->incrementCount('external_pages_scanned');
|
||||
$searchStartedAt = microtime(true);
|
||||
$result = $this->externalClient->searchAlbums($externalQuery, $perPage, $page);
|
||||
$this->recordTimingMs('lastfm_search_total', $searchStartedAt, true);
|
||||
$this->incrementCount('lastfm_search_calls');
|
||||
$searchItems = (array) ($result['albums']['items'] ?? []);
|
||||
if ($searchItems === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($searchItems as $searchItem) {
|
||||
if (!is_array($searchItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = trim((string) ($searchItem['id'] ?? ''));
|
||||
if ($id === '' || isset($seenIds[$id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seenIds[$id] = true;
|
||||
$payloads[] = $searchItem;
|
||||
if (count($payloads) >= $criteria->limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->setCount('external_candidates', count($payloads));
|
||||
$this->setCount('external_valid', count($payloads));
|
||||
|
||||
return [
|
||||
'payloads' => array_slice($payloads, 0, $criteria->limit),
|
||||
'stats' => [],
|
||||
'savedIds' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
|
||||
*/
|
||||
private function fetchExternalPayloads(AlbumSearchCriteria $criteria, string $externalQuery): array
|
||||
{
|
||||
if (!$this->requiresDetailedExternalFetch($criteria)) {
|
||||
return $this->fetchExternalPayloadsLight($criteria, $externalQuery);
|
||||
}
|
||||
|
||||
$perPage = max($criteria->limit, 10);
|
||||
$maxPages = 5;
|
||||
$maxPages = self::MAX_EXTERNAL_SEARCH_PAGES;
|
||||
$seenSearchIds = [];
|
||||
$persistableIds = [];
|
||||
$persistableLookup = [];
|
||||
$imagesByPersistableId = [];
|
||||
$pagesScanned = 0;
|
||||
|
||||
for ($page = 1; $page <= $maxPages && count($persistableIds) < $criteria->limit; $page++) {
|
||||
$pagesScanned++;
|
||||
$result = $this->lastFmClient->searchAlbums($externalQuery, $perPage, $page);
|
||||
$this->incrementCount('external_pages_scanned');
|
||||
$searchStartedAt = microtime(true);
|
||||
$result = $this->externalClient->searchAlbums($externalQuery, $perPage, $page);
|
||||
$this->recordTimingMs('lastfm_search_total', $searchStartedAt, true);
|
||||
$this->incrementCount('lastfm_search_calls');
|
||||
$searchItems = (array) ($result['albums']['items'] ?? []);
|
||||
if ($searchItems === []) {
|
||||
break;
|
||||
@@ -172,8 +345,13 @@ class AlbumSearchService
|
||||
if ($ids === []) {
|
||||
continue;
|
||||
}
|
||||
$this->incrementCount('external_candidates', count($ids));
|
||||
|
||||
$full = $this->lastFmClient->getAlbums($ids);
|
||||
$getAlbumsStartedAt = microtime(true);
|
||||
$full = $this->externalClient->getAlbums($ids, 'list');
|
||||
$this->recordTimingMs('lastfm_getalbums_total', $getAlbumsStartedAt, true);
|
||||
$this->incrementCount('lastfm_getalbums_calls');
|
||||
$this->incrementCount('lastfm_getalbums_requested_ids', count($ids));
|
||||
$albumsPayload = is_array($full) ? ((array) ($full['albums'] ?? [])) : [];
|
||||
if ($albumsPayload === []) {
|
||||
continue;
|
||||
@@ -194,6 +372,12 @@ class AlbumSearchService
|
||||
if (!isset($persistableLookup[$albumId])) {
|
||||
$persistableLookup[$albumId] = true;
|
||||
$persistableIds[] = $albumId;
|
||||
$this->incrementCount('external_valid');
|
||||
}
|
||||
|
||||
$images = $payload['images'] ?? null;
|
||||
if (is_array($images) && $images !== []) {
|
||||
$imagesByPersistableId[$albumId] = $images;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,9 +408,12 @@ class AlbumSearchService
|
||||
|
||||
$payloads = [];
|
||||
foreach ($orderedIds as $externalId) {
|
||||
$payloads[] = $existing[$externalId]->toTemplateArray();
|
||||
$payload = $existing[$externalId]->toTemplateArray();
|
||||
if (isset($imagesByPersistableId[$externalId]) && is_array($imagesByPersistableId[$externalId])) {
|
||||
$payload['images'] = $imagesByPersistableId[$externalId];
|
||||
}
|
||||
$payloads[] = $payload;
|
||||
}
|
||||
$payloads = $this->hydrateExternalImages($payloads);
|
||||
|
||||
return [
|
||||
'payloads' => $payloads,
|
||||
@@ -235,29 +422,156 @@ class AlbumSearchService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast path: use search payloads directly and avoid per-result detail lookups.
|
||||
*
|
||||
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
|
||||
*/
|
||||
private function fetchExternalPayloadsLight(AlbumSearchCriteria $criteria, string $externalQuery): array
|
||||
{
|
||||
$perPage = max($criteria->limit, 10);
|
||||
$maxCandidateIdsPerPage = max($criteria->limit + self::VALIDATION_ID_HEADROOM, $criteria->limit);
|
||||
$seenIds = [];
|
||||
$payloads = [];
|
||||
$persistedExternalIdLookup = [];
|
||||
$imagesByPersistedId = [];
|
||||
|
||||
for ($page = 1; $page <= self::MAX_EXTERNAL_SEARCH_PAGES && count($payloads) < $criteria->limit; $page++) {
|
||||
$this->incrementCount('external_pages_scanned');
|
||||
$searchStartedAt = microtime(true);
|
||||
$result = $this->externalClient->searchAlbums($externalQuery, $perPage, $page);
|
||||
$this->recordTimingMs('lastfm_search_total', $searchStartedAt, true);
|
||||
$this->incrementCount('lastfm_search_calls');
|
||||
$searchItems = (array) ($result['albums']['items'] ?? []);
|
||||
if ($searchItems === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
$candidateIds = [];
|
||||
foreach ($searchItems as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = trim((string) ($item['id'] ?? ''));
|
||||
if ($id === '' || isset($seenIds[$id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seenIds[$id] = true;
|
||||
$candidateIds[] = $id;
|
||||
if (count($candidateIds) >= $maxCandidateIdsPerPage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($candidateIds === []) {
|
||||
continue;
|
||||
}
|
||||
$this->incrementCount('external_candidates', count($candidateIds));
|
||||
|
||||
foreach (array_chunk($candidateIds, self::DETAIL_VALIDATION_BATCH_SIZE) as $candidateChunk) {
|
||||
if (count($payloads) >= $criteria->limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$getAlbumsStartedAt = microtime(true);
|
||||
$full = $this->externalClient->getAlbums($candidateChunk, 'list');
|
||||
$this->recordTimingMs('lastfm_getalbums_total', $getAlbumsStartedAt, true);
|
||||
$this->incrementCount('lastfm_getalbums_calls');
|
||||
$this->incrementCount('lastfm_getalbums_requested_ids', count($candidateChunk));
|
||||
$albumsPayload = is_array($full) ? ((array) ($full['albums'] ?? [])) : [];
|
||||
if ($albumsPayload === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$genresByAlbum = $this->tagResolver->resolveGenresForAlbums($albumsPayload);
|
||||
$ingestablePayloads = [];
|
||||
foreach ($albumsPayload as $albumPayload) {
|
||||
if (!is_array($albumPayload) || !$this->hasAtLeastOneTrack($albumPayload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$externalId = trim((string) ($albumPayload['id'] ?? ''));
|
||||
if ($externalId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->albumRepository->upsertFromExternalAlbum(
|
||||
$albumPayload,
|
||||
$genresByAlbum[$externalId] ?? []
|
||||
);
|
||||
$persistedExternalIdLookup[$externalId] = true;
|
||||
$this->incrementCount('external_valid');
|
||||
$images = $albumPayload['images'] ?? null;
|
||||
if (is_array($images) && $images !== []) {
|
||||
$imagesByPersistedId[$externalId] = $images;
|
||||
}
|
||||
$ingestablePayloads[] = $albumPayload;
|
||||
}
|
||||
|
||||
foreach ($ingestablePayloads as $ingestablePayload) {
|
||||
$payloads[] = $ingestablePayload;
|
||||
if (count($payloads) >= $criteria->limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($persistedExternalIdLookup !== []) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
if ($payloads === []) {
|
||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
||||
}
|
||||
|
||||
$orderedPayloadIds = $this->extractExternalIds($payloads);
|
||||
$ids = array_values(array_unique(array_merge($orderedPayloadIds, array_keys($persistedExternalIdLookup))));
|
||||
$saved = $this->albumRepository->findByExternalIdsKeyed($ids);
|
||||
$savedIds = array_values(array_keys($saved));
|
||||
|
||||
// Return persisted entities (not raw Last.fm payloads) so UI/API always render
|
||||
// the same canonical data source after ingestion.
|
||||
$persistedPayloads = [];
|
||||
foreach ($orderedPayloadIds as $externalId) {
|
||||
if (!isset($saved[$externalId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$persistedPayload = $saved[$externalId]->toTemplateArray();
|
||||
if (isset($imagesByPersistedId[$externalId]) && is_array($imagesByPersistedId[$externalId])) {
|
||||
$persistedPayload['images'] = $imagesByPersistedId[$externalId];
|
||||
}
|
||||
$persistedPayloads[] = $persistedPayload;
|
||||
if (count($persistedPayloads) >= $criteria->limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($persistedPayloads === []) {
|
||||
return ['payloads' => [], 'stats' => [], 'savedIds' => $savedIds];
|
||||
}
|
||||
|
||||
return [
|
||||
'payloads' => $persistedPayloads,
|
||||
'stats' => $savedIds !== [] ? $this->reviewRepository->getAggregatesForAlbumIds($savedIds) : [],
|
||||
'savedIds' => $savedIds,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function isIngestableExternalAlbum(array $payload): bool
|
||||
{
|
||||
$releaseDate = trim((string) ($payload['release_date'] ?? ''));
|
||||
if ($releaseDate === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tracks = $payload['tracks']['items'] ?? null;
|
||||
if (!is_array($tracks) || $tracks === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($tracks as $track) {
|
||||
$name = trim((string) ($track['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return $this->hasAtLeastOneTrack($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,6 +588,7 @@ class AlbumSearchService
|
||||
$criteria->yearTo ?? 0,
|
||||
$criteria->limit
|
||||
);
|
||||
$this->setCount('stored_user', count($userAlbums));
|
||||
|
||||
if ($userAlbums === []) {
|
||||
return ['payloads' => [], 'stats' => []];
|
||||
@@ -283,11 +598,36 @@ class AlbumSearchService
|
||||
$userStats = $this->reviewRepository->getAggregatesForAlbumEntityIds($entityIds);
|
||||
|
||||
return [
|
||||
'payloads' => array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums),
|
||||
'payloads' => $this->filterPayloadsByTrackCount(array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums)),
|
||||
'stats' => $this->mapUserStatsToAlbumIds($userAlbums, $userStats),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function hasAtLeastOneTrack(array $payload): bool
|
||||
{
|
||||
$totalTracks = (int) ($payload['total_tracks'] ?? 0);
|
||||
if ($totalTracks > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$tracks = $payload['tracks']['items'] ?? null;
|
||||
if (!is_array($tracks) || $tracks === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($tracks as $track) {
|
||||
$name = trim((string) ($track['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Album> $userAlbums
|
||||
* @param array<int,array{count:int,avg:float}> $userStats
|
||||
@@ -446,6 +786,40 @@ class AlbumSearchService
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $payloads
|
||||
* @return array<int,array<mixed>>
|
||||
*/
|
||||
private function filterPayloadsByArtist(array $payloads, string $artistNeedle): array
|
||||
{
|
||||
$needle = trim(mb_strtolower($artistNeedle));
|
||||
if ($needle === '') {
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
return array_values(array_filter($payloads, static function (array $payload) use ($needle): bool {
|
||||
$artists = $payload['artists'] ?? [];
|
||||
if (!is_array($artists)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($artists as $artist) {
|
||||
$name = '';
|
||||
if (is_array($artist)) {
|
||||
$name = trim((string) ($artist['name'] ?? ''));
|
||||
} else {
|
||||
$name = trim((string) $artist);
|
||||
}
|
||||
|
||||
if ($name !== '' && str_contains(mb_strtolower($name), $needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $payloads
|
||||
* @return array<int,array<mixed>>
|
||||
@@ -472,6 +846,15 @@ class AlbumSearchService
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $payloads
|
||||
* @return array<int,array<mixed>>
|
||||
*/
|
||||
private function filterPayloadsByTrackCount(array $payloads): array
|
||||
{
|
||||
return array_values(array_filter($payloads, fn(array $payload): bool => $this->hasAtLeastOneTrack($payload)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $payloads
|
||||
* @return array<int,array<mixed>>
|
||||
@@ -483,6 +866,12 @@ class AlbumSearchService
|
||||
if ((string) ($payload['source'] ?? '') !== 'external') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingImages = $payload['images'] ?? [];
|
||||
if (is_array($existingImages) && $existingImages !== []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = trim((string) ($payload['id'] ?? ''));
|
||||
if ($id !== '') {
|
||||
$externalIds[] = $id;
|
||||
@@ -493,7 +882,15 @@ class AlbumSearchService
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
$full = $this->lastFmClient->getAlbums(array_values(array_unique($externalIds)));
|
||||
if (count($externalIds) > self::MAX_IMAGE_HYDRATION_ALBUMS) {
|
||||
$externalIds = array_slice($externalIds, 0, self::MAX_IMAGE_HYDRATION_ALBUMS);
|
||||
}
|
||||
|
||||
$hydrateStartedAt = microtime(true);
|
||||
$full = $this->externalClient->getAlbums(array_values(array_unique($externalIds)), 'list');
|
||||
$this->recordTimingMs('hydrate_images', $hydrateStartedAt);
|
||||
$this->incrementCount('lastfm_getalbums_calls');
|
||||
$this->incrementCount('lastfm_getalbums_requested_ids', count(array_unique($externalIds)));
|
||||
$albums = is_array($full) ? ((array) ($full['albums'] ?? [])) : [];
|
||||
if ($albums === []) {
|
||||
return $payloads;
|
||||
@@ -525,4 +922,83 @@ class AlbumSearchService
|
||||
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
private function requiresDetailedExternalFetch(AlbumSearchCriteria $criteria): bool
|
||||
{
|
||||
if ($this->externalClient->getProviderName() === 'tidal') {
|
||||
// TIDAL search listing payloads are identifier-first; detailed fetch ensures
|
||||
// artist/genre/track metadata is present before filtering and persistence.
|
||||
return true;
|
||||
}
|
||||
|
||||
return $criteria->genre !== ''
|
||||
|| $criteria->yearFrom !== null
|
||||
|| $criteria->yearTo !== null;
|
||||
}
|
||||
|
||||
private function resetSearchProfile(AlbumSearchCriteria $criteria, string $externalQuery, bool $preferFastExternal): void
|
||||
{
|
||||
$this->lastSearchProfile = [
|
||||
'query' => $externalQuery,
|
||||
'source' => $criteria->source,
|
||||
'limit' => $criteria->limit,
|
||||
'external_provider' => $this->externalClient->getProviderName(),
|
||||
'prefer_fast_external' => $preferFastExternal,
|
||||
'timings_ms' => [
|
||||
'total' => 0.0,
|
||||
'external' => 0.0,
|
||||
'user' => 0.0,
|
||||
'compose' => 0.0,
|
||||
'lastfm_search_total' => 0.0,
|
||||
'lastfm_getalbums_total' => 0.0,
|
||||
'hydrate_images' => 0.0,
|
||||
],
|
||||
'counts' => [
|
||||
'stored_external' => 0,
|
||||
'stored_user' => 0,
|
||||
'external_pages_scanned' => 0,
|
||||
'external_candidates' => 0,
|
||||
'external_valid' => 0,
|
||||
'lastfm_search_calls' => 0,
|
||||
'lastfm_getalbums_calls' => 0,
|
||||
'lastfm_getalbums_requested_ids' => 0,
|
||||
'result_albums' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function recordTimingMs(string $key, float $startedAt, bool $accumulate = false): void
|
||||
{
|
||||
if (!is_array($this->lastSearchProfile['timings_ms'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$elapsed = round((microtime(true) - $startedAt) * 1000, 2);
|
||||
if ($accumulate) {
|
||||
$current = (float) ($this->lastSearchProfile['timings_ms'][$key] ?? 0.0);
|
||||
$this->lastSearchProfile['timings_ms'][$key] = round($current + $elapsed, 2);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->lastSearchProfile['timings_ms'][$key] = $elapsed;
|
||||
}
|
||||
|
||||
private function incrementCount(string $key, int $incrementBy = 1): void
|
||||
{
|
||||
if (!is_array($this->lastSearchProfile['counts'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$current = (int) ($this->lastSearchProfile['counts'][$key] ?? 0);
|
||||
$this->lastSearchProfile['counts'][$key] = $current + $incrementBy;
|
||||
}
|
||||
|
||||
private function setCount(string $key, int $value): void
|
||||
{
|
||||
if (!is_array($this->lastSearchProfile['counts'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->lastSearchProfile['counts'][$key] = $value;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user