This commit is contained in:
boris committed 2026-04-02 15:42:38 +01:00
1 parent 72a99a5bcd
commit 75756ac6ec
10 files changed
+1144 -32

No files matched your search

+128 -15
View File
@@ -37,12 +37,16 @@ class AlbumSearchService
/**
* Runs the end-to-end search flow and returns review stats plus saved IDs.
*/
public function search(AlbumSearchCriteria $criteria, bool $preferFastExternal = false): AlbumSearchResult
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);
$this->resetSearchProfile($criteria, $externalQuery, $preferFastExternal, $hydrateExternalImages);
$shouldQueryExternal = $criteria->shouldUseExternal()
&& (
@@ -60,7 +64,12 @@ class AlbumSearchService
if ($shouldQueryExternal) {
$externalStartedAt = microtime(true);
$externalData = $this->resolveExternalAlbums($criteria, $externalQuery, $preferFastExternal);
$externalData = $this->resolveExternalAlbums(
$criteria,
$externalQuery,
$preferFastExternal,
$hydrateExternalImages
);
$externalPayloads = $externalData['payloads'];
$stats = $this->mergeStats($stats, $externalData['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $externalData['savedIds']);
@@ -105,9 +114,13 @@ class AlbumSearchService
private function buildExternalSearchQuery(AlbumSearchCriteria $criteria): string
{
if ($this->externalClient->getProviderName() === 'musicbrainz') {
$providerName = $this->externalClient->getProviderName();
if ($providerName === 'musicbrainz') {
return $this->buildMusicBrainzSearchQuery($criteria);
}
if ($providerName === 'tidal') {
return $this->buildTidalSearchQuery($criteria);
}
$parts = [];
if ($criteria->query !== '') {
@@ -134,6 +147,37 @@ class AlbumSearchService
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 = [];
@@ -198,7 +242,12 @@ class AlbumSearchService
/**
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
private function resolveExternalAlbums(AlbumSearchCriteria $criteria, string $externalQuery, bool $preferFastExternal): array
private function resolveExternalAlbums(
AlbumSearchCriteria $criteria,
string $externalQuery,
bool $preferFastExternal,
bool $hydrateExternalImages
): array
{
$providerName = $this->externalClient->getProviderName();
$skipGenrePostFilter = $providerName === 'musicbrainz' && $criteria->genre !== '';
@@ -217,6 +266,7 @@ class AlbumSearchService
$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);
@@ -231,7 +281,9 @@ class AlbumSearchService
if (!$shouldFetchFromExternal) {
$limitedStoredPayloads = array_slice($storedPayloads, 0, $criteria->limit);
return [
'payloads' => $this->hydrateExternalImages($limitedStoredPayloads),
'payloads' => $hydrateExternalImages
? $this->hydrateExternalImages($limitedStoredPayloads)
: $limitedStoredPayloads,
'stats' => $stats,
'savedIds' => $storedIds,
];
@@ -239,9 +291,10 @@ class AlbumSearchService
$useSearchOnlyFastPath = $preferFastExternal && !$this->requiresDetailedExternalFetch($criteria);
$apiPayloads = $useSearchOnlyFastPath
? $this->fetchExternalPayloadsSearchOnly($criteria, $externalQuery)
: $this->fetchExternalPayloads($criteria, $externalQuery);
$filteredApiPayloads = $this->filterPayloadsByArtist($apiPayloads['payloads'], $criteria->artist);
? $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);
}
@@ -249,7 +302,7 @@ class AlbumSearchService
$mergedPayloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit);
return [
'payloads' => $this->hydrateExternalImages($mergedPayloads),
'payloads' => $hydrateExternalImages ? $this->hydrateExternalImages($mergedPayloads) : $mergedPayloads,
'stats' => $this->mergeStats($stats, $apiPayloads['stats']),
'savedIds' => $this->mergeSavedIds($storedIds, $apiPayloads['savedIds']),
];
@@ -260,7 +313,11 @@ class AlbumSearchService
*
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
private function fetchExternalPayloadsSearchOnly(AlbumSearchCriteria $criteria, string $externalQuery): array
private function fetchExternalPayloadsSearchOnly(
AlbumSearchCriteria $criteria,
string $externalQuery,
bool $skipGenreFilter = false
): array
{
$perPage = max($criteria->limit, 10);
$seenIds = [];
@@ -286,6 +343,9 @@ class AlbumSearchService
if ($id === '' || isset($seenIds[$id])) {
continue;
}
if (!$this->payloadMatchesCriteria($searchItem, $criteria, $skipGenreFilter)) {
continue;
}
$seenIds[$id] = true;
$payloads[] = $searchItem;
@@ -308,10 +368,14 @@ class AlbumSearchService
/**
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
private function fetchExternalPayloads(AlbumSearchCriteria $criteria, string $externalQuery): array
private function fetchExternalPayloads(
AlbumSearchCriteria $criteria,
string $externalQuery,
bool $skipGenreFilter = false
): array
{
if (!$this->requiresDetailedExternalFetch($criteria)) {
return $this->fetchExternalPayloadsLight($criteria, $externalQuery);
return $this->fetchExternalPayloadsLight($criteria, $externalQuery, $skipGenreFilter);
}
$perPage = max($criteria->limit, 10);
@@ -363,6 +427,9 @@ class AlbumSearchService
if ($albumId === '' || !$this->isIngestableExternalAlbum((array) $payload)) {
continue;
}
if (!$this->payloadMatchesCriteria((array) $payload, $criteria, $skipGenreFilter)) {
continue;
}
$this->albumRepository->upsertFromExternalAlbum(
(array) $payload,
@@ -427,7 +494,11 @@ class AlbumSearchService
*
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
private function fetchExternalPayloadsLight(AlbumSearchCriteria $criteria, string $externalQuery): array
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);
@@ -496,6 +567,9 @@ class AlbumSearchService
if ($externalId === '') {
continue;
}
if (!$this->payloadMatchesCriteria($albumPayload, $criteria, $skipGenreFilter)) {
continue;
}
$this->albumRepository->upsertFromExternalAlbum(
$albumPayload,
@@ -786,6 +860,23 @@ class AlbumSearchService
}));
}
/**
* @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>>
@@ -855,6 +946,22 @@ class AlbumSearchService
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>>
@@ -936,7 +1043,12 @@ class AlbumSearchService
|| $criteria->yearTo !== null;
}
private function resetSearchProfile(AlbumSearchCriteria $criteria, string $externalQuery, bool $preferFastExternal): void
private function resetSearchProfile(
AlbumSearchCriteria $criteria,
string $externalQuery,
bool $preferFastExternal,
bool $hydrateExternalImages
): void
{
$this->lastSearchProfile = [
'query' => $externalQuery,
@@ -944,6 +1056,7 @@ class AlbumSearchService
'limit' => $criteria->limit,
'external_provider' => $this->externalClient->getProviderName(),
'prefer_fast_external' => $preferFastExternal,
'image_hydration_enabled' => $hydrateExternalImages,
'timings_ms' => [
'total' => 0.0,
'external' => 0.0,