1118 lines
39 KiB
PHP
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;
|
|
}
|
|
}
|