*/ private array $lastSearchProfile = []; public function __construct( private readonly ExternalAlbumClientInterface $externalClient, private readonly AlbumRepository $albumRepository, private readonly ReviewRepository $reviewRepository, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, private readonly LastFmTagResolver $tagResolver, ) { } /** * Runs the end-to-end search flow and returns review stats plus saved IDs. */ public function search( AlbumSearchCriteria $criteria, bool $preferFastExternal = false, bool $hydrateExternalImages = true ): AlbumSearchResult { $searchStartedAt = microtime(true); $externalQuery = $this->buildExternalSearchQuery($criteria); $hasUserFilters = $this->hasUserFilters($criteria, $externalQuery); $this->resetSearchProfile($criteria, $externalQuery, $preferFastExternal, $hydrateExternalImages); $shouldQueryExternal = $criteria->shouldUseExternal() && ( $externalQuery !== '' || $criteria->genre !== '' || $criteria->yearFrom !== null || $criteria->yearTo !== null || $criteria->source === 'external' ); $stats = []; $savedIds = []; $externalPayloads = []; $userPayloads = []; if ($shouldQueryExternal) { $externalStartedAt = microtime(true); $externalData = $this->resolveExternalAlbums( $criteria, $externalQuery, $preferFastExternal, $hydrateExternalImages ); $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 */ public function getLastSearchProfile(): array { return $this->lastSearchProfile; } /** * Indicates whether Last.fm credentials are configured. */ public function isExternalConfigured(): bool { return $this->externalClient->isConfigured(); } private function buildExternalSearchQuery(AlbumSearchCriteria $criteria): string { $providerName = $this->externalClient->getProviderName(); if ($providerName === 'musicbrainz') { return $this->buildMusicBrainzSearchQuery($criteria); } if ($providerName === 'tidal') { return $this->buildTidalSearchQuery($criteria); } $parts = []; if ($criteria->query !== '') { $parts[] = $criteria->query; } if ($criteria->albumName !== '') { $parts[] = $criteria->albumName; } if ($criteria->artist !== '') { $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 buildTidalSearchQuery(AlbumSearchCriteria $criteria): string { $freeText = trim($criteria->query); if ($freeText !== '') { // Keep TIDAL search rooted in the main free-text query. return $freeText; } $fallbackParts = []; if ($criteria->albumName !== '') { $fallbackParts[] = $criteria->albumName; } if ($criteria->artist !== '') { $fallbackParts[] = $criteria->artist; } if ($fallbackParts !== []) { return trim(implode(' ', $fallbackParts)); } if ($criteria->genre !== '') { return $criteria->genre; } if ($criteria->yearFrom !== null) { return (string) $criteria->yearFrom; } if ($criteria->yearTo !== null) { return (string) $criteria->yearTo; } return ''; } 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') { return true; } return $externalQuery !== '' || $criteria->albumName !== '' || $criteria->artist !== '' || $criteria->genre !== '' || $criteria->yearFrom !== null || $criteria->yearTo !== null; } /** * @return array{payloads:array>,stats:array,savedIds:array} */ private function resolveExternalAlbums( AlbumSearchCriteria $criteria, string $externalQuery, bool $preferFastExternal, bool $hydrateExternalImages ): array { $providerName = $this->externalClient->getProviderName(); $skipGenrePostFilter = $providerName === 'musicbrainz' && $criteria->genre !== ''; $storedGenreFilter = $skipGenrePostFilter ? '' : $criteria->genre; $storedExternalAlbums = $this->albumRepository->searchExternalAlbums( $criteria->query, $criteria->albumName, $criteria->artist, $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->filterPayloadsByTrackCount($storedPayloads); $storedPayloads = $this->filterPayloadsByAlbumName($storedPayloads, $criteria->albumName); $storedPayloads = $this->filterPayloadsByArtist($storedPayloads, $criteria->artist); if (!$skipGenrePostFilter) { $storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre); } $storedPayloads = $this->filterPayloadsByYear($storedPayloads, $criteria->yearFrom, $criteria->yearTo); $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' => $hydrateExternalImages ? $this->hydrateExternalImages($limitedStoredPayloads) : $limitedStoredPayloads, 'stats' => $stats, 'savedIds' => $storedIds, ]; } $useSearchOnlyFastPath = $preferFastExternal && !$this->requiresDetailedExternalFetch($criteria); $apiPayloads = $useSearchOnlyFastPath ? $this->fetchExternalPayloadsSearchOnly($criteria, $externalQuery, $skipGenrePostFilter) : $this->fetchExternalPayloads($criteria, $externalQuery, $skipGenrePostFilter); $filteredApiPayloads = $this->filterPayloadsByAlbumName($apiPayloads['payloads'], $criteria->albumName); $filteredApiPayloads = $this->filterPayloadsByArtist($filteredApiPayloads, $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' => $hydrateExternalImages ? $this->hydrateExternalImages($mergedPayloads) : $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>,stats:array,savedIds:array} */ private function fetchExternalPayloadsSearchOnly( AlbumSearchCriteria $criteria, string $externalQuery, bool $skipGenreFilter = false ): 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; } if (!$this->payloadMatchesCriteria($searchItem, $criteria, $skipGenreFilter)) { 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>,stats:array,savedIds:array} */ private function fetchExternalPayloads( AlbumSearchCriteria $criteria, string $externalQuery, bool $skipGenreFilter = false ): array { if (!$this->requiresDetailedExternalFetch($criteria)) { return $this->fetchExternalPayloadsLight($criteria, $externalQuery, $skipGenreFilter); } $perPage = max($criteria->limit, 10); $maxPages = self::MAX_EXTERNAL_SEARCH_PAGES; $seenSearchIds = []; $persistableIds = []; $persistableLookup = []; $imagesByPersistableId = []; $pagesScanned = 0; for ($page = 1; $page <= $maxPages && count($persistableIds) < $criteria->limit; $page++) { $pagesScanned++; $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; } $ids = $this->extractExternalIds($searchItems); $ids = array_values(array_filter($ids, function (string $id) use (&$seenSearchIds): bool { if ($id === '' || isset($seenSearchIds[$id])) { return false; } $seenSearchIds[$id] = true; return true; })); if ($ids === []) { continue; } $this->incrementCount('external_candidates', count($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; } $genresByAlbum = $this->tagResolver->resolveGenresForAlbums($albumsPayload); foreach ($albumsPayload as $payload) { $albumId = (string) ($payload['id'] ?? ''); if ($albumId === '' || !$this->isIngestableExternalAlbum((array) $payload)) { continue; } if (!$this->payloadMatchesCriteria((array) $payload, $criteria, $skipGenreFilter)) { continue; } $this->albumRepository->upsertFromExternalAlbum( (array) $payload, $genresByAlbum[$albumId] ?? [] ); 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; } } } $this->logger->info('External album ingestion candidate resolution complete', [ 'query' => $externalQuery, 'requested_limit' => $criteria->limit, 'persistable_found' => count($persistableIds), 'pages_scanned' => $pagesScanned, ]); if ($persistableIds === []) { return ['payloads' => [], 'stats' => [], 'savedIds' => []]; } $this->entityManager->flush(); $existing = $this->albumRepository->findByExternalIdsKeyed($persistableIds); $orderedIds = []; foreach ($persistableIds as $externalId) { if (isset($existing[$externalId])) { $orderedIds[] = $externalId; } if (count($orderedIds) >= $criteria->limit) { break; } } $payloads = []; foreach ($orderedIds as $externalId) { $payload = $existing[$externalId]->toTemplateArray(); if (isset($imagesByPersistableId[$externalId]) && is_array($imagesByPersistableId[$externalId])) { $payload['images'] = $imagesByPersistableId[$externalId]; } $payloads[] = $payload; } return [ 'payloads' => $payloads, 'stats' => $this->reviewRepository->getAggregatesForAlbumIds($orderedIds), 'savedIds' => $orderedIds, ]; } /** * Fast path: use search payloads directly and avoid per-result detail lookups. * * @return array{payloads:array>,stats:array,savedIds:array} */ private function fetchExternalPayloadsLight( AlbumSearchCriteria $criteria, string $externalQuery, bool $skipGenreFilter = false ): 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; } if (!$this->payloadMatchesCriteria($albumPayload, $criteria, $skipGenreFilter)) { 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 $payload */ private function isIngestableExternalAlbum(array $payload): bool { $tracks = $payload['tracks']['items'] ?? null; if (!is_array($tracks) || $tracks === []) { return false; } return $this->hasAtLeastOneTrack($payload); } /** * @return array{payloads:array>,stats:array} */ private function resolveUserAlbums(AlbumSearchCriteria $criteria): array { $userAlbums = $this->albumRepository->searchUserAlbums( $criteria->query, $criteria->albumName, $criteria->artist, $criteria->genre, $criteria->yearFrom ?? 0, $criteria->yearTo ?? 0, $criteria->limit ); $this->setCount('stored_user', count($userAlbums)); if ($userAlbums === []) { return ['payloads' => [], 'stats' => []]; } $entityIds = array_values(array_map(static fn(Album $album) => (int) $album->getId(), $userAlbums)); $userStats = $this->reviewRepository->getAggregatesForAlbumEntityIds($entityIds); return [ 'payloads' => $this->filterPayloadsByTrackCount(array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums)), 'stats' => $this->mapUserStatsToAlbumIds($userAlbums, $userStats), ]; } /** * @param array $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 $userAlbums * @param array $userStats * @return array */ private function mapUserStatsToAlbumIds(array $userAlbums, array $userStats): array { $mapped = []; foreach ($userAlbums as $album) { $entityId = (int) $album->getId(); $albumId = (string) $album->getExternalId(); if ($albumId !== '' && isset($userStats[$entityId])) { $mapped[$albumId] = $userStats[$entityId]; } } return $mapped; } /** * @param array> $userPayloads * @param array> $externalPayloads * @return array> */ private function composeAlbumList(string $source, array $userPayloads, array $externalPayloads, int $limit): array { if ($source === 'user') { return array_slice($userPayloads, 0, $limit); } if ($source === 'external') { return array_slice($externalPayloads, 0, $limit); } return array_slice(array_merge($userPayloads, $externalPayloads), 0, $limit); } /** * @param list $albums * @return list */ private function collectExternalIds(array $albums): array { $ids = []; foreach ($albums as $album) { $externalId = (string) $album->getExternalId(); if ($externalId !== '') { $ids[] = $externalId; } } return array_values(array_unique($ids)); } /** * @param array $searchItems * @return list */ private function extractExternalIds(array $searchItems): array { $ids = []; foreach ($searchItems as $item) { $id = isset($item['id']) ? (string) $item['id'] : ''; if ($id !== '') { $ids[] = $id; } } return array_values(array_unique($ids)); } /** * @param array $current * @param array $updates * @return array */ private function mergeStats(array $current, array $updates): array { foreach ($updates as $key => $value) { $current[$key] = $value; } return $current; } /** * @param list $current * @param list $updates * @return list */ private function mergeSavedIds(array $current, array $updates): array { $merged = array_merge($current, array_filter($updates, static fn($id) => $id !== '')); return array_values(array_unique($merged)); } /** * @param array> $primary * @param array> $secondary * @return array> */ private function mergePayloadLists(array $primary, array $secondary, int $limit): array { $seen = []; $merged = []; foreach ($primary as $payload) { $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 = (string) ($payload['id'] ?? ''); if ($id !== '' && isset($seen[$id])) { continue; } $merged[] = $payload; if ($id !== '') { $seen[$id] = true; } if (count($merged) >= $limit) { break; } } return array_slice($merged, 0, $limit); } /** * @param array> $payloads * @return array> */ private function filterPayloadsByGenre(array $payloads, string $genreNeedle): array { $needle = trim(mb_strtolower($genreNeedle)); if ($needle === '') { return $payloads; } 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; })); } /** * @param array> $payloads * @return array> */ private function filterPayloadsByAlbumName(array $payloads, string $albumNeedle): array { $needle = trim(mb_strtolower($albumNeedle)); if ($needle === '') { return $payloads; } return array_values(array_filter($payloads, static function (array $payload) use ($needle): bool { $name = mb_strtolower(trim((string) ($payload['name'] ?? ''))); return $name !== '' && str_contains($name, $needle); })); } /** * @param array> $payloads * @return array> */ 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> $payloads * @return array> */ 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> $payloads * @return array> */ private function filterPayloadsByTrackCount(array $payloads): array { return array_values(array_filter($payloads, fn(array $payload): bool => $this->hasAtLeastOneTrack($payload))); } /** * @param array $payload */ private function payloadMatchesCriteria(array $payload, AlbumSearchCriteria $criteria, bool $skipGenreFilter): bool { $candidates = [$payload]; $candidates = $this->filterPayloadsByAlbumName($candidates, $criteria->albumName); $candidates = $this->filterPayloadsByArtist($candidates, $criteria->artist); if (!$skipGenreFilter) { $candidates = $this->filterPayloadsByGenre($candidates, $criteria->genre); } $candidates = $this->filterPayloadsByYear($candidates, $criteria->yearFrom, $criteria->yearTo); return $candidates !== []; } /** * @param array> $payloads * @return array> */ private function hydrateExternalImages(array $payloads): array { $externalIds = []; foreach ($payloads as $payload) { if ((string) ($payload['source'] ?? '') !== 'external') { continue; } $existingImages = $payload['images'] ?? []; if (is_array($existingImages) && $existingImages !== []) { continue; } $id = trim((string) ($payload['id'] ?? '')); if ($id !== '') { $externalIds[] = $id; } } if ($externalIds === []) { return $payloads; } 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; } $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; } 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, bool $hydrateExternalImages ): void { $this->lastSearchProfile = [ 'query' => $externalQuery, 'source' => $criteria->source, 'limit' => $criteria->limit, 'external_provider' => $this->externalClient->getProviderName(), 'prefer_fast_external' => $preferFastExternal, 'image_hydration_enabled' => $hydrateExternalImages, '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; } }