Files
tonehaus/src/Service/AlbumSearchService.php
T
2026-04-02 15:42:38 +01:00

1118 lines
39 KiB
PHP

<?php
namespace App\Service;
use App\Dto\AlbumSearchCriteria;
use App\Dto\AlbumSearchResult;
use App\Entity\Album;
use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* 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 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<string,mixed>
*/
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<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
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<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
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<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
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<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
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<string,mixed> $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<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>}
*/
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<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
* @return array<string,array{count:int,avg:float}>
*/
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<int,array<mixed>> $userPayloads
* @param array<int,array<mixed>> $externalPayloads
* @return array<int,array<mixed>>
*/
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<Album> $albums
* @return list<string>
*/
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<int,mixed> $searchItems
* @return list<string>
*/
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<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) {
$current[$key] = $value;
}
return $current;
}
/**
* @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 !== ''));
return array_values(array_unique($merged));
}
/**
* @param array<int,array<mixed>> $primary
* @param array<int,array<mixed>> $secondary
* @return array<int,array<mixed>>
*/
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<int,array<mixed>> $payloads
* @return array<int,array<mixed>>
*/
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<int,array<mixed>> $payloads
* @return array<int,array<mixed>>
*/
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<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>>
*/
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 filterPayloadsByTrackCount(array $payloads): array
{
return array_values(array_filter($payloads, fn(array $payload): bool => $this->hasAtLeastOneTrack($payload)));
}
/**
* @param array<string,mixed> $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<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;
}
$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;
}
}