diff --git a/app/Console/Commands/NntmuxCheckIndex.php b/app/Console/Commands/NntmuxCheckIndex.php index 23e68dfb4..367177120 100644 --- a/app/Console/Commands/NntmuxCheckIndex.php +++ b/app/Console/Commands/NntmuxCheckIndex.php @@ -140,8 +140,8 @@ class NntmuxCheckIndex extends Command 'anime' => 'anime_rt', default => 'releases_rt', }; - $host = config('nntmux.manticore.host', '127.0.0.1'); - $port = config('nntmux.manticore.port', 9308); + $host = (string) config('search.drivers.manticore.host', '127.0.0.1'); + $port = (int) config('search.drivers.manticore.port', 9308); // Use HTTP API endpoint $baseUrl = "http://{$host}:{$port}"; diff --git a/app/Http/Controllers/Api/RSS.php b/app/Http/Controllers/Api/RSS.php index dc37a156f..26915e36c 100644 --- a/app/Http/Controllers/Api/RSS.php +++ b/app/Http/Controllers/Api/RSS.php @@ -106,13 +106,14 @@ class RSS extends ApiController } } - $result = Cache::get(md5($sql)); + $cacheKey = $this->buildVersionedCacheKey($sql); + $result = Cache::get($cacheKey); if ($result !== null) { return $result; } $result = Release::fromQuery($sql); - Cache::put(md5($sql), $result, $expiresAt); + Cache::put($cacheKey, $result, $expiresAt); return $result; } @@ -209,13 +210,14 @@ class RSS extends ApiController ); $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); - $result = Cache::get(md5($sql)); + $cacheKey = $this->buildVersionedCacheKey($sql); + $result = Cache::get($cacheKey); if ($result !== null) { return $result; } $result = Release::fromQuery($sql); - Cache::put(md5($sql), $result, $expiresAt); + Cache::put($cacheKey, $result, $expiresAt); return $result; } @@ -238,13 +240,14 @@ class RSS extends ApiController ); $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); - $result = Cache::get(md5($sql)); + $cacheKey = $this->buildVersionedCacheKey($sql); + $result = Cache::get($cacheKey); if ($result !== null) { return $result; } $result = Release::fromQuery($sql); - Cache::put(md5($sql), $result, $expiresAt); + Cache::put($cacheKey, $result, $expiresAt); return $result; } @@ -381,6 +384,13 @@ class RSS extends ApiController }); } + private function buildVersionedCacheKey(string $sql): string + { + $cacheVersion = (int) Cache::get('release_search_cache_version', 1); + + return md5($cacheVersion.$sql); + } + /** * Get the first instance of a column from a table where the column is greater than 0, ordered by a specified column. * diff --git a/app/Services/BookService.php b/app/Services/BookService.php index 17cac7293..60f32d73f 100644 --- a/app/Services/BookService.php +++ b/app/Services/BookService.php @@ -97,10 +97,20 @@ class BookService $q = MetadataSearchLookup::normalizeBooleanSearchWords($searchWords); if ($q !== '') { $hits = Search::searchSecondary(SecondarySearchIndex::Books, $q, 25); - foreach ($hits['id'] as $bid) { - $found = BookInfo::query()->where('id', $bid)->first(); - if ($found !== null) { - return $found; + $bookIds = array_values(array_map('intval', $hits['id'] ?? [])); + if ($bookIds !== []) { + $rowsById = BookInfo::query() + ->whereIn('id', $bookIds) + ->get() + ->keyBy('id'); + + foreach ($bookIds as $bookId) { + if ($rowsById->has($bookId)) { + /** @var BookInfo $book */ + $book = $rowsById->get($bookId); + + return $book; + } } } } diff --git a/app/Services/ConsoleService.php b/app/Services/ConsoleService.php index 514f2bf04..0004d273c 100644 --- a/app/Services/ConsoleService.php +++ b/app/Services/ConsoleService.php @@ -106,10 +106,20 @@ class ConsoleService $q = MetadataSearchLookup::normalizeBooleanSearchWords($searchWords); if ($q !== '') { $hits = Search::searchSecondary(SecondarySearchIndex::Console, $q, 25); - foreach ($hits['id'] as $cid) { - $found = ConsoleInfo::query()->where('id', $cid)->first(); - if ($found !== null) { - return $found; + $consoleIds = array_values(array_map('intval', $hits['id'] ?? [])); + if ($consoleIds !== []) { + $rowsById = ConsoleInfo::query() + ->whereIn('id', $consoleIds) + ->get() + ->keyBy('id'); + + foreach ($consoleIds as $consoleId) { + if ($rowsById->has($consoleId)) { + /** @var ConsoleInfo $console */ + $console = $rowsById->get($consoleId); + + return $console; + } } } } diff --git a/app/Services/MusicService.php b/app/Services/MusicService.php index 3db3f4b86..36839ee08 100644 --- a/app/Services/MusicService.php +++ b/app/Services/MusicService.php @@ -93,10 +93,21 @@ class MusicService $q = MetadataSearchLookup::normalizeBooleanSearchWords($searchwords); if ($q !== '') { $hits = Search::searchSecondary(SecondarySearchIndex::Music, $q, 25); - foreach ($hits['id'] as $mid) { - $found = MusicInfo::query()->with('genre')->where('id', $mid)->first(); - if ($found !== null) { - return $found; + $musicIds = array_values(array_map('intval', $hits['id'] ?? [])); + if ($musicIds !== []) { + $rowsById = MusicInfo::query() + ->with('genre') + ->whereIn('id', $musicIds) + ->get() + ->keyBy('id'); + + foreach ($musicIds as $musicId) { + if ($rowsById->has($musicId)) { + /** @var MusicInfo $music */ + $music = $rowsById->get($musicId); + + return $music; + } } } } diff --git a/app/Services/Releases/ReleaseSearchService.php b/app/Services/Releases/ReleaseSearchService.php index c1d892409..fa22d836c 100644 --- a/app/Services/Releases/ReleaseSearchService.php +++ b/app/Services/Releases/ReleaseSearchService.php @@ -338,7 +338,7 @@ class ReleaseSearchService $offset ); - $cacheKey = md5($sql); + $cacheKey = md5($this->getCacheVersion().$sql); $cachedReleases = Cache::get($cacheKey); if ($cachedReleases !== null) { return $cachedReleases; @@ -969,7 +969,8 @@ class ReleaseSearchService $whereSql ); $sql = sprintf('%s ORDER BY postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset); - $releases = Cache::get(md5($sql)); + $cacheKey = md5($this->getCacheVersion().$sql); + $releases = Cache::get($cacheKey); if ($releases !== null) { return $releases; } @@ -980,7 +981,7 @@ class ReleaseSearchService ); } $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); - Cache::put(md5($sql), $releases, $expiresAt); + Cache::put($cacheKey, $releases, $expiresAt); return $releases; } @@ -1060,7 +1061,8 @@ class ReleaseSearchService $limit, $offset ); - $releases = Cache::get(md5($sql)); + $cacheKey = md5($this->getCacheVersion().$sql); + $releases = Cache::get($cacheKey); if ($releases !== null) { return $releases; } @@ -1069,7 +1071,7 @@ class ReleaseSearchService $releases[0]->_totalrows = $this->getPagerCount($baseSql); } $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); - Cache::put(md5($sql), $releases, $expiresAt); + Cache::put($cacheKey, $releases, $expiresAt); return $releases; } diff --git a/app/Services/Search/Contracts/SearchServiceInterface.php b/app/Services/Search/Contracts/SearchServiceInterface.php index bf6664ff8..72e906717 100644 --- a/app/Services/Search/Contracts/SearchServiceInterface.php +++ b/app/Services/Search/Contracts/SearchServiceInterface.php @@ -231,6 +231,14 @@ interface SearchServiceInterface */ public function searchMovieByExternalId(string $field, int|string $value): ?array; + /** + * Search movies by any provided external IDs in one call. + * + * @param array $externalIds Keys: imdbid, tmdbid, traktid + * @return array|null Movie data or null if not found + */ + public function searchMovieByExternalIds(array $externalIds): ?array; + /** * Search movies index by field-specific terms (title, director, actors, genre). * @@ -286,6 +294,14 @@ interface SearchServiceInterface */ public function searchTvShowByExternalId(string $field, int|string $value): ?array; + /** + * Search TV shows by any provided external IDs in one call. + * + * @param array $externalIds Keys: tvdb, trakt, tvmaze, tvrage, imdb, tmdb + * @return array|null TV show data or null if not found + */ + public function searchTvShowByExternalIds(array $externalIds): ?array; + /** * Search releases by external media IDs. * Used to find releases associated with a specific movie or TV show. @@ -296,6 +312,14 @@ interface SearchServiceInterface */ public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array; + /** + * Search releases by multiple external-ID sets in a single call. + * + * @param array> $externalIdSets + * @return array Array of release IDs + */ + public function searchReleasesByMultipleExternalIds(array $externalIdSets, int $limit = 1000): array; + /** * Search releases by category ID using the search index. * This provides a fast way to get release IDs for a specific category without hitting the database. diff --git a/app/Services/Search/Drivers/ElasticSearchDriver.php b/app/Services/Search/Drivers/ElasticSearchDriver.php index e67fc7647..b204fc276 100644 --- a/app/Services/Search/Drivers/ElasticSearchDriver.php +++ b/app/Services/Search/Drivers/ElasticSearchDriver.php @@ -1663,7 +1663,8 @@ class ElasticSearchDriver implements SearchDriverInterface array $fields, int $limit, array $options = [], - bool $includeDateSort = true + bool $includeDateSort = true, + bool $useScroll = false ): array { $queryString = array_merge([ 'query' => $keywords, @@ -1679,8 +1680,7 @@ class ElasticSearchDriver implements SearchDriverInterface $sort[] = ['post_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']]; } - return [ - 'scroll' => self::SCROLL_TIMEOUT, + $search = [ 'index' => $index, 'body' => [ 'query' => [ @@ -1692,6 +1692,12 @@ class ElasticSearchDriver implements SearchDriverInterface 'track_total_hits' => true, ], ]; + + if ($useScroll) { + $search['scroll'] = self::SCROLL_TIMEOUT; + } + + return $search; } /** @@ -1746,6 +1752,7 @@ class ElasticSearchDriver implements SearchDriverInterface } $scrollId = null; + $useScroll = isset($search['scroll']) && is_string($search['scroll']) && $search['scroll'] !== ''; try { $client = $this->getClient(); @@ -1771,14 +1778,19 @@ class ElasticSearchDriver implements SearchDriverInterface $searchResult = []; - while (isset($results['hits']['hits']) && count($results['hits']['hits']) > 0) { - foreach ($results['hits']['hits'] as $result) { - if ($fullResults) { - $searchResult[] = $result['_source']; - } else { - $searchResult[] = $result['_source']['id'] ?? $result['_id']; - } + foreach ($results['hits']['hits'] ?? [] as $result) { + if ($fullResults) { + $searchResult[] = $result['_source']; + } else { + $searchResult[] = $result['_source']['id'] ?? $result['_id']; } + } + + if (! $useScroll) { + return $searchResult; + } + + while (isset($results['hits']['hits']) && count($results['hits']['hits']) > 0) { // Handle scrolling for large result sets if (! isset($results['_scroll_id'])) { @@ -1790,6 +1802,14 @@ class ElasticSearchDriver implements SearchDriverInterface 'scroll_id' => $scrollId, 'scroll' => self::SCROLL_TIMEOUT, ]); + + foreach ($results['hits']['hits'] ?? [] as $result) { + if ($fullResults) { + $searchResult[] = $result['_source']; + } else { + $searchResult[] = $result['_source']['id'] ?? $result['_id']; + } + } } return $searchResult; // @phpstan-ignore return.type @@ -2171,11 +2191,43 @@ class ElasticSearchDriver implements SearchDriverInterface */ public function searchMovieByExternalId(string $field, int|string $value): ?array { - if (empty($value) || ! in_array($field, ['imdbid', 'tmdbid', 'traktid'])) { + if (empty($value)) { return null; } - $cacheKey = 'es:movie:'.$field.':'.$value; + return $this->searchMovieByExternalIds([$field => $value]); + } + + /** + * @param array $externalIds + * @return array|null + */ + public function searchMovieByExternalIds(array $externalIds): ?array + { + $allowedFields = ['imdbid', 'tmdbid', 'traktid']; + $shouldClauses = []; + $cacheableValues = []; + + foreach ($allowedFields as $field) { + $value = $externalIds[$field] ?? null; + if (empty($value)) { + continue; + } + + $typedValue = $field === 'imdbid' ? (string) $value : (int) $value; + if ($typedValue === '' || $typedValue === 0) { + continue; + } + + $shouldClauses[] = ['term' => [$field => $typedValue]]; + $cacheableValues[$field] = $typedValue; + } + + if ($shouldClauses === []) { + return null; + } + + $cacheKey = 'es:movie:any:'.md5(serialize($cacheableValues)); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; @@ -2188,8 +2240,9 @@ class ElasticSearchDriver implements SearchDriverInterface 'index' => $this->getMoviesIndex(), 'body' => [ 'query' => [ - 'term' => [ - $field => $field === 'imdbid' ? (string) $value : (int) $value, + 'bool' => [ + 'should' => $shouldClauses, + 'minimum_should_match' => 1, ], ], 'size' => 1, @@ -2207,9 +2260,8 @@ class ElasticSearchDriver implements SearchDriverInterface } } catch (\Throwable $e) { - Log::error('ElasticSearch searchMovieByExternalId error: '.$e->getMessage(), [ - 'field' => $field, - 'value' => $value, + Log::error('ElasticSearch searchMovieByExternalIds error: '.$e->getMessage(), [ + 'externalIds' => $externalIds, ]); } @@ -2456,11 +2508,43 @@ class ElasticSearchDriver implements SearchDriverInterface */ public function searchTvShowByExternalId(string $field, int|string $value): ?array { - if (empty($value) || ! in_array($field, ['tvdb', 'trakt', 'tvmaze', 'tvrage', 'imdb', 'tmdb'])) { + if (empty($value)) { return null; } - $cacheKey = 'es:tvshow:'.$field.':'.$value; + return $this->searchTvShowByExternalIds([$field => $value]); + } + + /** + * @param array $externalIds + * @return array|null + */ + public function searchTvShowByExternalIds(array $externalIds): ?array + { + $allowedFields = ['tvdb', 'trakt', 'tvmaze', 'tvrage', 'imdb', 'tmdb']; + $shouldClauses = []; + $cacheableValues = []; + + foreach ($allowedFields as $field) { + $value = $externalIds[$field] ?? null; + if (empty($value)) { + continue; + } + + $typedValue = $field === 'imdb' ? (string) $value : (int) $value; + if ($typedValue === '' || $typedValue === 0) { + continue; + } + + $shouldClauses[] = ['term' => [$field => $typedValue]]; + $cacheableValues[$field] = $typedValue; + } + + if ($shouldClauses === []) { + return null; + } + + $cacheKey = 'es:tvshow:any:'.md5(serialize($cacheableValues)); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; @@ -2473,8 +2557,9 @@ class ElasticSearchDriver implements SearchDriverInterface 'index' => $this->getTvShowsIndex(), 'body' => [ 'query' => [ - 'term' => [ - $field => (int) $value, + 'bool' => [ + 'should' => $shouldClauses, + 'minimum_should_match' => 1, ], ], 'size' => 1, @@ -2492,9 +2577,8 @@ class ElasticSearchDriver implements SearchDriverInterface } } catch (\Throwable $e) { - Log::error('ElasticSearch searchTvShowByExternalId error: '.$e->getMessage(), [ - 'field' => $field, - 'value' => $value, + Log::error('ElasticSearch searchTvShowByExternalIds error: '.$e->getMessage(), [ + 'externalIds' => $externalIds, ]); } @@ -2511,11 +2595,42 @@ class ElasticSearchDriver implements SearchDriverInterface */ public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array { - if (empty($externalIds)) { + return $this->searchReleasesByMultipleExternalIds([$externalIds], $limit); + } + + /** + * @param array> $externalIdSets + * @return array + */ + public function searchReleasesByMultipleExternalIds(array $externalIdSets, int $limit = 1000): array + { + if ($externalIdSets === []) { return []; } - $cacheKey = 'es:releases:extid:'.md5(serialize($externalIds)); + $shouldClauses = []; + $cachePayload = []; + + foreach ($externalIdSets as $externalIds) { + $setClauses = $this->buildReleaseExternalIdShouldClauses($externalIds); + if ($setClauses === []) { + continue; + } + + $shouldClauses[] = [ + 'bool' => [ + 'should' => $setClauses, + 'minimum_should_match' => 1, + ], + ]; + $cachePayload[] = $externalIds; + } + + if ($shouldClauses === []) { + return []; + } + + $cacheKey = 'es:releases:extid_sets:'.md5(serialize([$cachePayload, $limit])); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; @@ -2524,17 +2639,6 @@ class ElasticSearchDriver implements SearchDriverInterface try { $client = $this->getClient(); - $shouldClauses = []; - foreach ($externalIds as $field => $value) { - if (! empty($value) && in_array($field, ['imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze', 'tvrage'])) { - $shouldClauses[] = ['term' => [$field => $field === 'imdbid' ? (string) $value : (int) $value]]; - } - } - - if (empty($shouldClauses)) { - return []; - } - $searchParams = [ 'index' => $this->getReleasesIndex(), 'body' => [ @@ -2552,27 +2656,42 @@ class ElasticSearchDriver implements SearchDriverInterface $response = $client->search($searchParams); $resultIds = []; - if (isset($response['hits']['hits'])) { - foreach ($response['hits']['hits'] as $hit) { - $resultIds[] = $hit['_id']; - } + foreach ($response['hits']['hits'] ?? [] as $hit) { + $resultIds[] = $hit['_id']; } - if (! empty($resultIds)) { + if ($resultIds !== []) { Cache::put($cacheKey, $resultIds, now()->addMinutes(self::CACHE_TTL_MINUTES)); } return $resultIds; // @phpstan-ignore return.type - } catch (\Throwable $e) { - Log::error('ElasticSearch searchReleasesByExternalId error: '.$e->getMessage(), [ - 'externalIds' => $externalIds, + Log::error('ElasticSearch searchReleasesByMultipleExternalIds error: '.$e->getMessage(), [ + 'externalIdSets' => $externalIdSets, ]); } return []; } + /** + * @param array $externalIds + * @return list> + */ + private function buildReleaseExternalIdShouldClauses(array $externalIds): array + { + $clauses = []; + foreach ($externalIds as $field => $value) { + if (empty($value) || ! in_array($field, ['imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze', 'tvrage'], true)) { + continue; + } + + $clauses[] = ['term' => [$field => $field === 'imdbid' ? (string) $value : (int) $value]]; + } + + return $clauses; + } + /** * Search releases by category ID using the search index. * This provides a fast way to get release IDs for a specific category without hitting the database. @@ -2647,7 +2766,6 @@ class ElasticSearchDriver implements SearchDriverInterface * @param array $categoryIds Array of category IDs to filter by (empty for all categories) * @param int $limit Maximum number of results * @return array - * @return array */ public function searchReleasesWithCategoryFilter(string $searchTerm, array $categoryIds = [], int $limit = 1000): array { @@ -2975,7 +3093,7 @@ class ElasticSearchDriver implements SearchDriverInterface return ['success' => 0, 'errors' => 0]; } - $success = 0; + $validDocs = []; $errors = 0; $indexName = $this->getSecondaryIndexName($index); @@ -2986,18 +3104,33 @@ class ElasticSearchDriver implements SearchDriverInterface continue; } + unset($doc['id']); - try { - $this->getClient()->index([ - 'index' => $indexName, - 'id' => (string) $docId, - 'body' => $doc, - ]); - $success++; - } catch (\Throwable $e) { - $errors++; - Log::error('ElasticSearch bulkInsertSecondary row error: '.$e->getMessage()); - } + $validDocs[] = ['id' => $docId, 'body' => $doc]; + } + + if ($validDocs === []) { + return ['success' => 0, 'errors' => $errors]; + } + + $params = ['body' => []]; + foreach ($validDocs as $doc) { + $params['body'][] = [ + 'index' => [ + '_index' => $indexName, + '_id' => (string) $doc['id'], + ], + ]; + $params['body'][] = $doc['body']; + } + + $success = count($validDocs); + try { + $this->executeBulk($params); + } catch (\Throwable $e) { + Log::error('ElasticSearch bulkInsertSecondary error: '.$e->getMessage(), ['secondary' => $index->value]); + $errors += $success; + $success = 0; } return ['success' => $success, 'errors' => $errors]; diff --git a/app/Services/Search/Drivers/ManticoreSearchDriver.php b/app/Services/Search/Drivers/ManticoreSearchDriver.php index 9d3770077..7aaee00a1 100644 --- a/app/Services/Search/Drivers/ManticoreSearchDriver.php +++ b/app/Services/Search/Drivers/ManticoreSearchDriver.php @@ -1865,35 +1865,61 @@ class ManticoreSearchDriver implements SearchDriverInterface */ public function searchMovieByExternalId(string $field, int|string $value): ?array { - if (empty($value) || ! in_array($field, ['imdbid', 'tmdbid', 'traktid'])) { + if (empty($value)) { return null; } - $cacheKey = 'manticore:movie:'.$field.':'.$value; + return $this->searchMovieByExternalIds([$field => $value]); + } + + /** + * @param array $externalIds + * @return array|null + */ + public function searchMovieByExternalIds(array $externalIds): ?array + { + $allowedFields = ['imdbid', 'tmdbid', 'traktid']; + $normalized = []; + + foreach ($allowedFields as $field) { + $value = $externalIds[$field] ?? null; + if (empty($value)) { + continue; + } + + $normalized[$field] = $field === 'imdbid' ? (string) $value : (int) $value; + } + + if ($normalized === []) { + return null; + } + + $cacheKey = 'manticore:movie:any:'.md5(serialize($normalized)); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { - $query = (new Search($this->manticoreSearch)) - ->setTable($this->getMoviesIndex()) - ->filter($field, '=', (int) $value) - ->limit(1); + foreach ($normalized as $field => $value) { + $query = (new Search($this->manticoreSearch)) + ->setTable($this->getMoviesIndex()) + ->filter($field, '=', $value) + ->limit(1); - $results = $query->get(); + $results = $query->get(); - foreach ($results as $doc) { - $data = $doc->getData(); - $data['id'] = $doc->getId(); - Cache::put($cacheKey, $data, now()->addMinutes($this->config['cache_minutes'] ?? 5)); + foreach ($results as $doc) { + $data = $doc->getData(); + $data['id'] = $doc->getId(); + Cache::put($cacheKey, $data, now()->addMinutes($this->config['cache_minutes'] ?? 5)); - return $data; + return $data; + } } } catch (\Throwable $e) { - Log::error('ManticoreSearch searchMovieByExternalId error: '.$e->getMessage(), [ - 'field' => $field, - 'value' => $value, + Log::error('ManticoreSearch searchMovieByExternalIds error: '.$e->getMessage(), [ + 'externalIds' => $externalIds, ]); } @@ -2065,35 +2091,61 @@ class ManticoreSearchDriver implements SearchDriverInterface */ public function searchTvShowByExternalId(string $field, int|string $value): ?array { - if (empty($value) || ! in_array($field, ['tvdb', 'trakt', 'tvmaze', 'tvrage', 'imdb', 'tmdb'])) { + if (empty($value)) { return null; } - $cacheKey = 'manticore:tvshow:'.$field.':'.$value; + return $this->searchTvShowByExternalIds([$field => $value]); + } + + /** + * @param array $externalIds + * @return array|null + */ + public function searchTvShowByExternalIds(array $externalIds): ?array + { + $allowedFields = ['tvdb', 'trakt', 'tvmaze', 'tvrage', 'imdb', 'tmdb']; + $normalized = []; + + foreach ($allowedFields as $field) { + $value = $externalIds[$field] ?? null; + if (empty($value)) { + continue; + } + + $normalized[$field] = $field === 'imdb' ? (string) $value : (int) $value; + } + + if ($normalized === []) { + return null; + } + + $cacheKey = 'manticore:tvshow:any:'.md5(serialize($normalized)); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { - $query = (new Search($this->manticoreSearch)) - ->setTable($this->getTvShowsIndex()) - ->filter($field, '=', (int) $value) - ->limit(1); + foreach ($normalized as $field => $value) { + $query = (new Search($this->manticoreSearch)) + ->setTable($this->getTvShowsIndex()) + ->filter($field, '=', $value) + ->limit(1); - $results = $query->get(); + $results = $query->get(); - foreach ($results as $doc) { - $data = $doc->getData(); - $data['id'] = $doc->getId(); - Cache::put($cacheKey, $data, now()->addMinutes($this->config['cache_minutes'] ?? 5)); + foreach ($results as $doc) { + $data = $doc->getData(); + $data['id'] = $doc->getId(); + Cache::put($cacheKey, $data, now()->addMinutes($this->config['cache_minutes'] ?? 5)); - return $data; + return $data; + } } } catch (\Throwable $e) { - Log::error('ManticoreSearch searchTvShowByExternalId error: '.$e->getMessage(), [ - 'field' => $field, - 'value' => $value, + Log::error('ManticoreSearch searchTvShowByExternalIds error: '.$e->getMessage(), [ + 'externalIds' => $externalIds, ]); } @@ -2110,47 +2162,83 @@ class ManticoreSearchDriver implements SearchDriverInterface */ public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array { - if (empty($externalIds)) { + return $this->searchReleasesByMultipleExternalIds([$externalIds], $limit); + } + + /** + * @param array> $externalIdSets + * @return array + */ + public function searchReleasesByMultipleExternalIds(array $externalIdSets, int $limit = 1000): array + { + if ($externalIdSets === []) { return []; } - $cacheKey = 'manticore:releases:extid:'.md5(serialize($externalIds)); + $normalizedLimit = min($limit, 10000); + $cacheKey = 'manticore:releases:extid_sets:'.md5(serialize([$externalIdSets, $normalizedLimit])); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } - try { - $query = (new Search($this->manticoreSearch)) - ->setTable($this->getReleasesIndex()) - ->limit(min($limit, 10000)); - - // Add filters for each external ID provided + $queries = []; + foreach ($externalIdSets as $externalIds) { foreach ($externalIds as $field => $value) { - if (! empty($value) && in_array($field, ['imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze', 'tvrage'])) { - $query->filter($field, '=', $field === 'imdbid' ? (string) $value : (int) $value); + if (empty($value) || ! in_array($field, ['imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze', 'tvrage'], true)) { + continue; } + + $typedValue = $field === 'imdbid' ? (string) $value : (int) $value; + $key = $field.':'.$typedValue; + $queries[$key] = [ + 'field' => $field, + 'value' => $typedValue, + ]; } - - $results = $query->get(); - - $resultIds = []; - foreach ($results as $doc) { - $resultIds[] = $doc->getId(); - } - - if (! empty($resultIds)) { - Cache::put($cacheKey, $resultIds, now()->addMinutes($this->config['cache_minutes'] ?? 5)); - } - - return $resultIds; - } catch (\Throwable $e) { - Log::error('ManticoreSearch searchReleasesByExternalId error: '.$e->getMessage(), [ - 'externalIds' => $externalIds, - ]); } - return []; + if ($queries === []) { + return []; + } + + $resultIds = []; + $seen = []; + + try { + foreach ($queries as $queryData) { + $query = (new Search($this->manticoreSearch)) + ->setTable($this->getReleasesIndex()) + ->filter($queryData['field'], '=', $queryData['value']) + ->limit($normalizedLimit); + + $results = $query->get(); + foreach ($results as $doc) { + $docId = (int) $doc->getId(); + if (isset($seen[$docId])) { + continue; + } + + $seen[$docId] = true; + $resultIds[] = $docId; + if (count($resultIds) >= $normalizedLimit) { + break 2; + } + } + } + } catch (\Throwable $e) { + Log::error('ManticoreSearch searchReleasesByMultipleExternalIds error: '.$e->getMessage(), [ + 'externalIdSets' => $externalIdSets, + ]); + + return []; + } + + if ($resultIds !== []) { + Cache::put($cacheKey, $resultIds, now()->addMinutes($this->config['cache_minutes'] ?? 5)); + } + + return $resultIds; } /** diff --git a/app/Services/Search/MediaSearchService.php b/app/Services/Search/MediaSearchService.php index 3067b597f..3c63c329e 100644 --- a/app/Services/Search/MediaSearchService.php +++ b/app/Services/Search/MediaSearchService.php @@ -32,16 +32,7 @@ class MediaSearchService */ public function findMovie(array $externalIds): ?array { - foreach (['imdbid', 'tmdbid', 'traktid'] as $field) { - if (! empty($externalIds[$field])) { - $result = Search::searchMovieByExternalId($field, $externalIds[$field]); - if ($result !== null) { - return $result; - } - } - } - - return null; + return Search::searchMovieByExternalIds($externalIds); } /** @@ -52,16 +43,7 @@ class MediaSearchService */ public function findTvShow(array $externalIds): ?array { - foreach (['tvdb', 'trakt', 'tvmaze', 'tvrage', 'imdb', 'tmdb'] as $field) { - if (! empty($externalIds[$field])) { - $result = Search::searchTvShowByExternalId($field, $externalIds[$field]); - if ($result !== null) { - return $result; - } - } - } - - return null; + return Search::searchTvShowByExternalIds($externalIds); } /** @@ -90,7 +72,7 @@ class MediaSearchService } // Collect all external IDs from matched movies - $allReleaseIds = []; + $externalIdSets = []; foreach ($movieResults['data'] ?? [] as $movie) { $externalIds = []; @@ -106,11 +88,12 @@ class MediaSearchService } if (! empty($externalIds)) { - $releaseIds = Search::searchReleasesByExternalId($externalIds, $limit); - $allReleaseIds = array_merge($allReleaseIds, $releaseIds); + $externalIdSets[] = $externalIds; } } + $allReleaseIds = Search::searchReleasesByMultipleExternalIds($externalIdSets, $limit); + // Remove duplicates and limit results $allReleaseIds = array_unique($allReleaseIds); $allReleaseIds = array_slice($allReleaseIds, 0, $limit); @@ -148,7 +131,7 @@ class MediaSearchService } // Collect all external IDs from matched TV shows - $allReleaseIds = []; + $externalIdSets = []; foreach ($tvResults['data'] ?? [] as $tvShow) { $externalIds = []; @@ -167,11 +150,12 @@ class MediaSearchService } if (! empty($externalIds)) { - $releaseIds = Search::searchReleasesByExternalId($externalIds, $limit); - $allReleaseIds = array_merge($allReleaseIds, $releaseIds); + $externalIdSets[] = $externalIds; } } + $allReleaseIds = Search::searchReleasesByMultipleExternalIds($externalIdSets, $limit); + // Remove duplicates and limit results $allReleaseIds = array_unique($allReleaseIds); $allReleaseIds = array_slice($allReleaseIds, 0, $limit); diff --git a/app/Services/Search/SearchService.php b/app/Services/Search/SearchService.php index 1e8b342f1..10b549604 100644 --- a/app/Services/Search/SearchService.php +++ b/app/Services/Search/SearchService.php @@ -371,6 +371,15 @@ class SearchService extends Manager implements SearchServiceInterface return $this->driver()->searchMovieByExternalId($field, $value); } + /** + * @param array $externalIds + * @return array|null + */ + public function searchMovieByExternalIds(array $externalIds): ?array + { + return $this->driver()->searchMovieByExternalIds($externalIds); + } + /** * @param array $fieldTerms * @return array{imdbids: list, movieinfo_ids: list, data: list>} @@ -438,6 +447,15 @@ class SearchService extends Manager implements SearchServiceInterface return $this->driver()->searchTvShowByExternalId($field, $value); } + /** + * @param array $externalIds + * @return array|null + */ + public function searchTvShowByExternalIds(array $externalIds): ?array + { + return $this->driver()->searchTvShowByExternalIds($externalIds); + } + /** * Search releases by external media IDs. * Used to find releases associated with a specific movie or TV show. @@ -450,6 +468,15 @@ class SearchService extends Manager implements SearchServiceInterface return $this->driver()->searchReleasesByExternalId($externalIds, $limit); } + /** + * @param array> $externalIdSets + * @return array + */ + public function searchReleasesByMultipleExternalIds(array $externalIdSets, int $limit = 1000): array + { + return $this->driver()->searchReleasesByMultipleExternalIds($externalIdSets, $limit); + } + /** * Search releases by category ID using the search index. * This provides a fast way to get release IDs for a specific category without hitting the database. diff --git a/app/Services/SteamService.php b/app/Services/SteamService.php index a6b975e5c..aab188bb2 100644 --- a/app/Services/SteamService.php +++ b/app/Services/SteamService.php @@ -488,64 +488,75 @@ class SteamService protected function findMatches(string $title, int $limit = 25): array { $variants = $this->generateQueryVariants($title); + $variants = array_values(array_unique(array_filter(array_map('trim', $variants), static fn (string $variant): bool => $variant !== ''))); $matches = []; $seenAppIds = []; - foreach ($variants as $variant) { - try { - if (Search::isAvailable()) { - $hits = Search::searchSecondary(SecondarySearchIndex::Steam, $variant, $limit); - foreach ($hits['data'] as $row) { - $appid = $row['appid'] ?? null; - $name = $row['name'] ?? null; - if ($appid === null || $name === null || $name === '' || isset($seenAppIds[$appid])) { - continue; - } - $score = $this->scoreTitle((string) $name, $title); - if ($score >= self::RELAXED_MATCH_THRESHOLD) { - $seenAppIds[(int) $appid] = true; - $matches[] = [ - 'appid' => (int) $appid, - 'name' => (string) $name, - 'score' => $score, - ]; - } - } - } - } catch (\Exception $e) { - Log::debug('SteamService: search index lookup failed', ['error' => $e->getMessage()]); - } - - // LIKE fallback - $likeTerm = $this->buildLikePattern($variant); - try { - $fallbacks = SteamApp::query() - ->select(['appid', 'name']) - ->where('name', 'like', $likeTerm) - ->limit($limit) - ->get(); - - foreach ($fallbacks as $row) { - $appid = $row->appid; - $name = $row->name; - - if (isset($seenAppIds[$appid])) { + try { + if (Search::isAvailable() && $variants !== []) { + $orQuery = implode(' | ', $variants); + $hits = Search::searchSecondary(SecondarySearchIndex::Steam, $orQuery, $limit); + foreach ($hits['data'] as $row) { + $appid = $row['appid'] ?? null; + $name = $row['name'] ?? null; + if ($appid === null || $name === null || $name === '' || isset($seenAppIds[$appid])) { continue; } - - $score = $this->scoreTitle($name, $title); + $score = $this->scoreTitle((string) $name, $title); if ($score >= self::RELAXED_MATCH_THRESHOLD) { - $seenAppIds[$appid] = true; + $seenAppIds[(int) $appid] = true; $matches[] = [ 'appid' => (int) $appid, - 'name' => $name, + 'name' => (string) $name, 'score' => $score, ]; } } - } catch (\Exception $e) { - Log::debug('SteamService: LIKE search failed', ['error' => $e->getMessage()]); } + } catch (\Exception $e) { + Log::debug('SteamService: search index lookup failed', ['error' => $e->getMessage()]); + } + + // LIKE fallback + try { + $fallbackQuery = SteamApp::query()->select(['appid', 'name']); + if ($variants !== []) { + $fallbackQuery->where(function ($query) use ($variants): void { + foreach ($variants as $index => $variant) { + $likeTerm = $this->buildLikePattern($variant); + if ($index === 0) { + $query->where('name', 'like', $likeTerm); + } else { + $query->orWhere('name', 'like', $likeTerm); + } + } + }); + } else { + $fallbackQuery->where('name', 'like', $this->buildLikePattern($title)); + } + + $fallbacks = $fallbackQuery->limit($limit)->get(); + + foreach ($fallbacks as $row) { + $appid = $row->appid; + $name = $row->name; + + if (isset($seenAppIds[$appid])) { + continue; + } + + $score = $this->scoreTitle($name, $title); + if ($score >= self::RELAXED_MATCH_THRESHOLD) { + $seenAppIds[$appid] = true; + $matches[] = [ + 'appid' => (int) $appid, + 'name' => $name, + 'score' => $score, + ]; + } + } + } catch (\Exception $e) { + Log::debug('SteamService: LIKE search failed', ['error' => $e->getMessage()]); } // Sort by score descending diff --git a/config/database.php b/config/database.php index e3548aa74..80480b53d 100644 --- a/config/database.php +++ b/config/database.php @@ -55,15 +55,6 @@ return [ 'sslmode' => 'prefer', ], - 'sphinx' => [ - 'driver' => 'mysql', - 'host' => env('MANTICORESEARCH_HOST', '127.0.0.1'), - 'port' => env('MANTICORESEARCH_PORT', 9306), - 'database' => '', - 'unix_socket' => '', - 'charset' => 'utf8', - ], - 'mariadb' => [ 'driver' => 'mariadb', 'url' => env('DB_URL'),