Update search indexes and queries usage

This commit is contained in:
DariusIII
2026-04-11 11:36:58 +02:00
parent b919963729
commit bdbd1cbb9a
13 changed files with 522 additions and 221 deletions
+2 -2
View File
@@ -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}";
+16 -6
View File
@@ -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.
*
+14 -4
View File
@@ -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;
}
}
}
}
+14 -4
View File
@@ -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;
}
}
}
}
+15 -4
View File
@@ -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;
}
}
}
}
@@ -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;
}
@@ -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<string, mixed> $externalIds Keys: imdbid, tmdbid, traktid
* @return array<string, mixed>|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<string, mixed> $externalIds Keys: tvdb, trakt, tvmaze, tvrage, imdb, tmdb
* @return array<string, mixed>|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<int, array<string, mixed>> $externalIdSets
* @return array<string, mixed> 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.
@@ -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<string, mixed> $externalIds
* @return array<string, mixed>|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<string, mixed> $externalIds
* @return array<string, mixed>|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<int, array<string, mixed>> $externalIdSets
* @return array<string, mixed>
*/
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<string, mixed> $externalIds
* @return list<array<string, mixed>>
*/
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<string, mixed> $categoryIds Array of category IDs to filter by (empty for all categories)
* @param int $limit Maximum number of results
* @return array<string, mixed>
* @return array<string, mixed>
*/
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];
@@ -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<string, mixed> $externalIds
* @return array<string, mixed>|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<string, mixed> $externalIds
* @return array<string, mixed>|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<int, array<string, mixed>> $externalIdSets
* @return array<string, mixed>
*/
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;
}
/**
+10 -26
View File
@@ -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);
+27
View File
@@ -371,6 +371,15 @@ class SearchService extends Manager implements SearchServiceInterface
return $this->driver()->searchMovieByExternalId($field, $value);
}
/**
* @param array<string, mixed> $externalIds
* @return array<string, mixed>|null
*/
public function searchMovieByExternalIds(array $externalIds): ?array
{
return $this->driver()->searchMovieByExternalIds($externalIds);
}
/**
* @param array<string, string> $fieldTerms
* @return array{imdbids: list<string>, movieinfo_ids: list<int>, data: list<array<string, mixed>>}
@@ -438,6 +447,15 @@ class SearchService extends Manager implements SearchServiceInterface
return $this->driver()->searchTvShowByExternalId($field, $value);
}
/**
* @param array<string, mixed> $externalIds
* @return array<string, mixed>|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<int, array<string, mixed>> $externalIdSets
* @return array<string, mixed>
*/
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.
+56 -45
View File
@@ -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
-9
View File
@@ -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'),