*/ private const RELEASE_TEXT_FIELDS = ['searchname^3', 'plainsearchname^2']; private static ?Client $client = null; private static ?bool $availabilityCache = null; private static ?int $availabilityCacheTime = null; /** * @var array */ protected array $config; /** * @param array $config */ public function __construct(array $config = []) { $this->config = ! empty($config) ? $config : config('search.drivers.elasticsearch'); } /** * Get the driver name. */ public function getDriverName(): string { return 'elasticsearch'; } /** * Check if Elasticsearch is available. */ public function isAvailable(): bool { return $this->isElasticsearchAvailable(); } public function searchReleasePage(ReleaseSearchQuery $query): SearchPage { $startedAt = hrtime(true); $result = $this->searchReleasesFiltered($query->criteria(), $query->limit, $query->offset); return new SearchPage( ids: array_map(static fn (int|string $id): int => (int) $id, $result['ids']), total: (int) $result['total'], fuzzy: (bool) $result['fuzzy'], driver: $this->getDriverName(), available: (bool) ($result['available'] ?? true), durationMs: (hrtime(true) - $startedAt) / 1_000_000, lastSortValues: $result['last_sort'] ?? [], hasMore: (bool) ($result['has_more'] ?? (($query->offset + count($result['ids'])) < (int) $result['total'])), ); } /** * Get or create an Elasticsearch client with proper cURL configuration. * * Uses a singleton pattern to reuse the client connection across requests. * * @throws RuntimeException When client initialization fails */ private function getClient(): Client { if (self::$client === null) { try { if (empty($this->config)) { throw new RuntimeException('Elasticsearch configuration not found'); } if (config('app.debug')) { Log::debug('Elasticsearch client initializing', [ 'hosts' => ElasticsearchClientFactory::buildHosts($this->config['hosts'] ?? []), ]); } self::$client = ElasticsearchClientFactory::make($this->config); if (config('app.debug')) { Log::debug('Elasticsearch client initialized successfully'); } } catch (\Throwable $e) { Log::error('Failed to initialize Elasticsearch client: '.$e->getMessage(), [ 'exception_class' => get_class($e), ]); throw new RuntimeException('Elasticsearch client initialization failed: '.$e->getMessage()); } } return self::$client; } /** * Check if Elasticsearch is available. * * Caches the availability status to avoid frequent ping requests. */ private function isElasticsearchAvailable(): bool { $now = time(); // Return cached result if still valid if (self::$availabilityCache !== null && self::$availabilityCacheTime !== null && ($now - self::$availabilityCacheTime) < self::AVAILABILITY_CHECK_CACHE_TTL) { return self::$availabilityCache; } try { $client = $this->getClient(); $result = ElasticsearchResponseHelper::boolResponse($client, fn (Client $elasticClient) => $elasticClient->ping()); if (config('app.debug')) { Log::debug('Elasticsearch ping result', ['available' => $result]); } self::$availabilityCache = $result; self::$availabilityCacheTime = $now; return $result; } catch (\Throwable $e) { Log::warning('Elasticsearch is not available: '.$e->getMessage(), [ 'exception_class' => get_class($e), ]); self::$availabilityCache = false; self::$availabilityCacheTime = $now; return false; } } /** * Reset the client connection (useful for testing or reconnection). */ public function resetConnection(): void { self::$client = null; self::$availabilityCache = null; self::$availabilityCacheTime = null; } /** * Check if autocomplete is enabled. */ public function isAutocompleteEnabled(): bool { return ($this->config['autocomplete']['enabled'] ?? true) && $this->isElasticsearchAvailable(); } /** * Check if suggest is enabled. */ public function isSuggestEnabled(): bool { return ($this->config['suggest']['enabled'] ?? true) && $this->isElasticsearchAvailable(); } /** * Check if fuzzy search is enabled. */ public function isFuzzyEnabled(): bool { return ($this->config['fuzzy']['enabled'] ?? true) && $this->isElasticsearchAvailable(); } /** * Get fuzzy search configuration. * * @return array */ public function getFuzzyConfig(): array { return $this->config['fuzzy'] ?? [ 'enabled' => true, 'fuzziness' => 'AUTO', 'prefix_length' => 2, 'max_expansions' => 50, ]; } /** * Get the releases index name. */ public function getReleasesIndex(): string { return $this->config['indexes']['releases'] ?? 'releases'; } /** * Get the predb index name. */ public function getPredbIndex(): string { return $this->config['indexes']['predb'] ?? 'predb'; } /** * Get the movies index name. */ public function getMoviesIndex(): string { return $this->config['indexes']['movies'] ?? 'movies'; } /** * Get the TV shows index name. */ public function getTvShowsIndex(): string { return $this->config['indexes']['tvshows'] ?? 'tvshows'; } /** * Escape special characters for Elasticsearch. */ public static function escapeString(string $string): string { if (empty($string) || $string === '*') { return ''; } // Replace dots with spaces for release name searches $string = str_replace('.', ' ', $string); // Remove multiple consecutive spaces $string = preg_replace('/\s+/', ' ', trim($string)); return $string; } /** * Get autocomplete suggestions for a search query. * Searches the releases index and returns matching searchnames. * * @param string $query The partial search query * @param string|null $index Index to search (defaults to releases index) * @return array */ public function autocomplete(string $query, ?string $index = null): array { if (! $this->isAutocompleteEnabled()) { return []; } $query = trim($query); $minLength = (int) ($this->config['autocomplete']['min_length'] ?? self::AUTOCOMPLETE_MIN_LENGTH); if (strlen($query) < $minLength) { return []; } $index = $index ?? $this->getReleasesIndex(); $cacheKey = 'es:autocomplete:'.md5($index.$query); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } $suggestions = []; $maxResults = (int) ($this->config['autocomplete']['max_results'] ?? self::AUTOCOMPLETE_MAX_RESULTS); try { $client = $this->getClient(); // Use a prefix/match query on searchname field $searchParams = [ 'index' => $index, 'body' => [ 'query' => [ 'bool' => [ 'should' => [ // Prefix match for autocomplete-like behavior [ 'match_phrase_prefix' => [ 'searchname' => [ 'query' => $query, 'max_expansions' => 50, ], ], ], // Also include regular match for better results [ 'match' => [ 'searchname' => [ 'query' => $query, 'fuzziness' => 'AUTO', ], ], ], ], 'minimum_should_match' => 1, ], ], 'size' => $maxResults * 3, '_source' => ['searchname'], 'sort' => [ // Sort by date first to get latest results, then by score ['add_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']], ['_score' => ['order' => 'desc']], ], ], ]; $response = $client->search($searchParams); $seen = []; if (isset($response['hits']['hits'])) { foreach ($response['hits']['hits'] as $hit) { $searchname = $hit['_source']['searchname'] ?? ''; if (empty($searchname)) { continue; } // Create a clean suggestion from the searchname $suggestion = $this->extractSuggestion($searchname, $query); if (! empty($suggestion) && ! isset($seen[strtolower($suggestion)])) { $seen[strtolower($suggestion)] = true; $suggestions[] = [ 'suggest' => $suggestion, 'distance' => 0, 'docs' => 1, ]; } if (count($suggestions) >= $maxResults) { break; } } } } catch (\Throwable $e) { if (config('app.debug')) { Log::warning('ElasticSearch autocomplete error: '.$e->getMessage()); } } if (! empty($suggestions)) { $cacheMinutes = (int) ($this->config['autocomplete']['cache_minutes'] ?? self::AUTOCOMPLETE_CACHE_MINUTES); Cache::put($cacheKey, $suggestions, now()->addMinutes($cacheMinutes)); } return $suggestions; } /** * Get spell correction suggestions ("Did you mean?"). * * @param string $query The search query to check * @param string|null $index Index to use for suggestions * @return array */ public function suggest(string $query, ?string $index = null): array { if (! $this->isSuggestEnabled()) { return []; } $query = trim($query); if (empty($query)) { return []; } $index = $index ?? $this->getReleasesIndex(); $cacheKey = 'es:suggest:'.md5($index.$query); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } $suggestions = []; try { $client = $this->getClient(); // Use Elasticsearch suggest API with phrase suggester $searchParams = [ 'index' => $index, 'body' => [ 'suggest' => [ 'text' => $query, 'searchname_suggest' => [ 'phrase' => [ 'field' => 'searchname', 'size' => 5, 'gram_size' => 3, 'direct_generator' => [ [ 'field' => 'searchname', 'suggest_mode' => 'popular', ], ], 'highlight' => [ 'pre_tag' => '', 'post_tag' => '', ], ], ], ], ], ]; $response = $client->search($searchParams); if (isset($response['suggest']['searchname_suggest'][0]['options'])) { foreach ($response['suggest']['searchname_suggest'][0]['options'] as $option) { $suggestedText = $option['text'] ?? ''; if (! empty($suggestedText) && strtolower($suggestedText) !== strtolower($query)) { $suggestions[] = [ 'suggest' => $suggestedText, 'distance' => 1, 'docs' => (int) ($option['freq'] ?? 1), ]; } } } } catch (\Throwable $e) { if (config('app.debug')) { Log::debug('ElasticSearch native suggest failed: '.$e->getMessage()); } } // Fallback: if native suggest didn't work, use fuzzy search if (empty($suggestions)) { $suggestions = $this->suggestFallback($query, $index); } if (! empty($suggestions)) { Cache::put($cacheKey, $suggestions, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES)); } return $suggestions; } /** * Fallback suggest using fuzzy search on searchnames. * * @param string $query The search query * @param string $index Index to search * @return array */ private function suggestFallback(string $query, string $index): array { try { $client = $this->getClient(); // Use fuzzy match to find similar terms $searchParams = [ 'index' => $index, 'body' => [ 'query' => [ 'match' => [ 'searchname' => [ 'query' => $query, 'fuzziness' => 'AUTO', ], ], ], 'size' => 20, '_source' => ['searchname'], ], ]; $response = $client->search($searchParams); // Extract common terms from results that differ from the query $termCounts = []; if (isset($response['hits']['hits'])) { foreach ($response['hits']['hits'] as $hit) { $searchname = $hit['_source']['searchname'] ?? ''; $words = preg_split('/[\s.\-_]+/', strtolower($searchname)); foreach ($words as $word) { if (strlen($word) >= 3 && $word !== strtolower($query)) { $distance = levenshtein(strtolower($query), $word); if ($distance > 0 && $distance <= 3) { if (! isset($termCounts[$word])) { $termCounts[$word] = ['count' => 0, 'distance' => $distance]; } $termCounts[$word]['count']++; } } } } } // Sort by count uasort($termCounts, fn ($a, $b) => $b['count'] - $a['count']); $suggestions = []; foreach (array_slice($termCounts, 0, 5, true) as $term => $data) { $suggestions[] = [ 'suggest' => $term, 'distance' => $data['distance'], 'docs' => $data['count'], ]; } return $suggestions; } catch (\Throwable $e) { if (config('app.debug')) { Log::warning('ElasticSearch suggest fallback error: '.$e->getMessage()); } return []; } } /** * Extract a clean suggestion from a searchname. * * @param string $searchname The full searchname * @param string $query The user's query * @return string|null The extracted suggestion */ private function extractSuggestion(string $searchname, string $query): ?string { // Clean up the searchname - remove file extensions $clean = preg_replace('/\.(mkv|avi|mp4|wmv|nfo|nzb|par2|rar|zip|r\d+)$/i', '', $searchname); // Replace dots and underscores with spaces for readability $clean = str_replace(['.', '_'], ' ', $clean); // Remove multiple spaces $clean = preg_replace('/\s+/', ' ', $clean); $clean = trim($clean); if (empty($clean)) { return null; } // If the clean name is reasonable length, use it if (strlen($clean) <= 80) { return $clean; } // For very long names, try to extract the relevant part $pos = stripos($clean, $query); if ($pos !== false) { $start = max(0, $pos - 10); $extracted = substr($clean, $start, 80); if ($start > 0) { $extracted = preg_replace('/^\S*\s/', '', $extracted); } $extracted = preg_replace('/\s\S*$/', '', $extracted); return trim($extracted); } return substr($clean, 0, 80); } /** * Search releases index. * * @param array|string $phrases Search phrases - can be a string, indexed array of terms, or associative array with field names * @param int $limit Maximum number of results * @return array Array of release IDs */ public function searchReleases(array|string $phrases, int $limit = 1000): array { // Normalize the input to a search string if (is_string($phrases)) { $searchString = $phrases; } else { // Check if it's an associative array (has string keys like 'searchname') $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0; if ($isAssociative) { // Extract values from associative array $searchString = implode(' ', array_values($phrases)); } else { // Indexed array - combine values $searchString = implode(' ', $phrases); } } $result = $this->indexSearch($searchString, $limit); return is_array($result) ? $result : $result->toArray(); } /** * Search releases with fuzzy fallback. * * If exact search returns no results and fuzzy is enabled, this method * will automatically try a fuzzy search as a fallback. * * @param array|string $phrases Search phrases * @param int $limit Maximum number of results * @param bool $forceFuzzy Force fuzzy search regardless of exact results * @return array Array with 'ids' (release IDs) and 'fuzzy' (bool indicating if fuzzy was used) */ public function searchReleasesWithFuzzy(array|string $phrases, int $limit = 1000, bool $forceFuzzy = false): array { // First try exact search unless forcing fuzzy if (! $forceFuzzy) { $exactResults = $this->searchReleases($phrases, $limit); if (! empty($exactResults)) { return [ 'ids' => $exactResults, 'fuzzy' => false, ]; } } // If exact search returned nothing (or forcing fuzzy) and fuzzy is enabled, try fuzzy search if ($this->isFuzzyEnabled()) { $fuzzyResults = $this->fuzzySearchReleases($phrases, $limit); if (! empty($fuzzyResults)) { return [ 'ids' => $fuzzyResults, 'fuzzy' => true, ]; } } return [ 'ids' => [], 'fuzzy' => false, ]; } /** * Perform fuzzy search on releases index. * * Uses Elasticsearch's fuzzy matching to find results with typo tolerance. * * @param array|string $phrases Search phrases * @param int $limit Maximum number of results * @return array Array of release IDs */ public function fuzzySearchReleases(array|string $phrases, int $limit = 1000): array { if (! $this->isFuzzyEnabled() || ! $this->isElasticsearchAvailable()) { return []; } // Normalize the input to a search string if (is_string($phrases)) { $searchString = $phrases; } else { $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0; if ($isAssociative) { $searchString = implode(' ', array_values($phrases)); } else { $searchString = implode(' ', $phrases); } } $keywords = $this->sanitizeSearchTerms($searchString); if (empty($keywords)) { return []; } $fuzzyConfig = $this->getFuzzyConfig(); $cacheKey = $this->buildCacheKey('fuzzy_search', [$keywords, $limit, $fuzzyConfig]); // @phpstan-ignore argument.type $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { $client = $this->getClient(); // Build fuzzy query $searchParams = [ 'index' => $this->getReleasesIndex(), 'body' => [ 'query' => [ 'bool' => [ 'should' => [ // Fuzzy match on searchname [ 'match' => [ 'searchname' => [ 'query' => $keywords, 'fuzziness' => $fuzzyConfig['fuzziness'] ?? 'AUTO', 'prefix_length' => $fuzzyConfig['prefix_length'] ?? 2, 'max_expansions' => $fuzzyConfig['max_expansions'] ?? 50, 'boost' => 2, ], ], ], // Fuzzy match on plainsearchname [ 'match' => [ 'plainsearchname' => [ 'query' => $keywords, 'fuzziness' => $fuzzyConfig['fuzziness'] ?? 'AUTO', 'prefix_length' => $fuzzyConfig['prefix_length'] ?? 2, 'max_expansions' => $fuzzyConfig['max_expansions'] ?? 50, 'boost' => 1.5, ], ], ], // Fuzzy match on name [ 'match' => [ 'name' => [ 'query' => $keywords, 'fuzziness' => $fuzzyConfig['fuzziness'] ?? 'AUTO', 'prefix_length' => $fuzzyConfig['prefix_length'] ?? 2, 'max_expansions' => $fuzzyConfig['max_expansions'] ?? 50, 'boost' => 1.2, ], ], ], ], 'minimum_should_match' => 1, ], ], 'size' => min($limit, self::MAX_RESULTS), '_source' => false, 'sort' => [ ['_score' => ['order' => 'desc']], ], ], ]; $response = $client->search($searchParams); $ids = []; if (isset($response['hits']['hits'])) { foreach ($response['hits']['hits'] as $hit) { $ids[] = (int) $hit['_id']; } } Cache::put($cacheKey, $ids, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES)); return $ids; // @phpstan-ignore return.type } catch (ElasticsearchException $e) { Log::error('ElasticSearch fuzzySearchReleases error: '.$e->getMessage(), [ 'keywords' => $keywords, 'limit' => $limit, ]); return []; } catch (\Throwable $e) { Log::error('ElasticSearch fuzzySearchReleases unexpected error: '.$e->getMessage(), [ 'keywords' => $keywords, ]); return []; } } /** * Search the predb index. * * @param array|string $searchTerm Search term(s) * @return array Array of predb records */ public function searchPredb(array|string $searchTerm): array { $result = $this->predbIndexSearch($searchTerm); return is_array($result) ? $result : $result->toArray(); } /** * Search releases index. * * @param array|string $phrases Search phrases * @param int $limit Maximum number of results * @return array|Collection Array of release IDs */ public function indexSearch(array|string $phrases, int $limit): array|Collection { if (empty($phrases) || ! $this->isElasticsearchAvailable()) { if (config('app.debug')) { Log::debug('ElasticSearch indexSearch: empty phrases or ES not available', [ 'phrases_empty' => empty($phrases), 'es_available' => $this->isElasticsearchAvailable(), ]); } return []; } $keywords = $this->sanitizeSearchTerms($phrases); if (config('app.debug')) { Log::debug('ElasticSearch indexSearch: sanitized keywords', [ 'original' => is_array($phrases) ? implode(' ', $phrases) : $phrases, 'sanitized' => $keywords, ]); } if (empty($keywords)) { if (config('app.debug')) { Log::debug('ElasticSearch indexSearch: keywords empty after sanitization'); } return []; } $cacheKey = $this->buildCacheKey('index_search', [$keywords, $limit]); // @phpstan-ignore argument.type $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { $search = $this->buildSearchQuery( index: $this->getReleasesIndex(), keywords: $keywords, fields: self::RELEASE_TEXT_FIELDS, // @phpstan-ignore argument.type limit: $limit ); $result = $this->executeSearch($search); Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES)); return $result; } catch (ElasticsearchException $e) { Log::error('ElasticSearch indexSearch error: '.$e->getMessage(), [ 'keywords' => $keywords, 'limit' => $limit, ]); return []; } } /** * Search releases for API requests. * * @param array|string $searchName Search name(s) * @param int $limit Maximum number of results * @return array|Collection Array of release IDs */ public function indexSearchApi(array|string $searchName, int $limit): array|Collection { if (empty($searchName) || ! $this->isElasticsearchAvailable()) { return []; } $keywords = $this->sanitizeSearchTerms($searchName); if (empty($keywords)) { return []; } $cacheKey = $this->buildCacheKey('api_search', [$keywords, $limit]); // @phpstan-ignore argument.type $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { $search = $this->buildSearchQuery( index: $this->getReleasesIndex(), keywords: $keywords, fields: self::RELEASE_TEXT_FIELDS, // @phpstan-ignore argument.type limit: $limit ); $result = $this->executeSearch($search); Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES)); return $result; } catch (ElasticsearchException $e) { Log::error('ElasticSearch indexSearchApi error: '.$e->getMessage(), [ 'keywords' => $keywords, 'limit' => $limit, ]); return []; } } /** * Search releases for TV/Movie/Audio (TMA) matching. * * @param array|string $name Name(s) to search * @param int $limit Maximum number of results * @return array|Collection Array of release IDs */ public function indexSearchTMA(array|string $name, int $limit): array|Collection { if (empty($name) || ! $this->isElasticsearchAvailable()) { return []; } $keywords = $this->sanitizeSearchTerms($name); if (empty($keywords)) { return []; } $cacheKey = $this->buildCacheKey('tma_search', [$keywords, $limit]); // @phpstan-ignore argument.type $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { $search = $this->buildSearchQuery( index: $this->getReleasesIndex(), keywords: $keywords, fields: self::RELEASE_TEXT_FIELDS, // @phpstan-ignore argument.type limit: $limit, options: [ 'boost' => 1.2, ] ); $result = $this->executeSearch($search); Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES)); return $result; } catch (ElasticsearchException $e) { Log::error('ElasticSearch indexSearchTMA error: '.$e->getMessage(), [ 'keywords' => $keywords, 'limit' => $limit, ]); return []; } } /** * Search predb index. * * @param array|string $searchTerm Search term(s) * @return array|Collection Array of predb records */ public function predbIndexSearch(array|string $searchTerm): array|Collection { if (empty($searchTerm) || ! $this->isElasticsearchAvailable()) { return []; } $keywords = $this->sanitizeSearchTerms($searchTerm); if (empty($keywords)) { return []; } $cacheKey = $this->buildCacheKey('predb_search', [$keywords]); // @phpstan-ignore argument.type $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { $search = $this->buildSearchQuery( index: $this->getPredbIndex(), keywords: $keywords, fields: ['title^2', 'filename'], // @phpstan-ignore argument.type limit: 1000, options: [ 'fuzziness' => 'AUTO', ], includeDateSort: false ); $result = $this->executeSearch($search, fullResults: true); Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES)); return $result; } catch (ElasticsearchException $e) { Log::error('ElasticSearch predbIndexSearch error: '.$e->getMessage(), [ 'keywords' => $keywords, ]); return []; } } /** * Insert a release into the index. * * @param array $parameters Release data with 'id', 'name', 'searchname', etc. */ public function insertRelease(array $parameters): void { if (empty($parameters['id']) || ! $this->isElasticsearchAvailable()) { if (empty($parameters['id'])) { Log::warning('ElasticSearch: Cannot insert release without ID'); } return; } try { $client = $this->getClient(); $client->index($this->buildReleaseDocument($parameters)); } catch (ElasticsearchException $e) { Log::error('ElasticSearch insertRelease error: '.$e->getMessage(), [ 'release_id' => $parameters['id'], ]); } catch (\Throwable $e) { Log::error('ElasticSearch insertRelease unexpected error: '.$e->getMessage(), [ 'release_id' => $parameters['id'], ]); } } /** * Update a release in the index. * * @param int|string $releaseID Release ID */ public function updateRelease(int|string $releaseID): void { if (empty($releaseID)) { Log::warning('ElasticSearch: Cannot update release without ID'); return; } if (! $this->isElasticsearchAvailable()) { return; } try { $release = Release::query() ->where('releases.id', $releaseID) ->leftJoin('release_files as rf', 'releases.id', '=', 'rf.releases_id') ->leftJoin('movieinfo as mi', 'releases.movieinfo_id', '=', 'mi.id') ->leftJoin('videos as v', 'releases.videos_id', '=', 'v.id') ->select([ 'releases.id', 'releases.name', 'releases.searchname', 'releases.fromname', 'releases.categories_id', 'releases.size', 'releases.postdate', 'releases.adddate', 'releases.totalpart', 'releases.grabs', 'releases.passwordstatus', 'releases.groups_id', 'releases.nzbstatus', 'releases.haspreview', 'releases.imdbid', 'releases.videos_id', 'releases.movieinfo_id', DB::raw('IFNULL(mi.tmdbid, 0) AS tmdbid'), DB::raw('IFNULL(mi.traktid, 0) AS traktid'), DB::raw('IFNULL(v.tvdb, 0) AS tvdb'), DB::raw('IFNULL(v.tvmaze, 0) AS tvmaze'), DB::raw('IFNULL(v.tvrage, 0) AS tvrage'), DB::raw('IFNULL(GROUP_CONCAT(rf.name SEPARATOR " "),"") filename'), ]) ->groupBy([ 'releases.id', 'releases.name', 'releases.searchname', 'releases.fromname', 'releases.categories_id', 'releases.size', 'releases.postdate', 'releases.adddate', 'releases.totalpart', 'releases.grabs', 'releases.passwordstatus', 'releases.groups_id', 'releases.nzbstatus', 'releases.haspreview', 'releases.imdbid', 'releases.videos_id', 'releases.movieinfo_id', 'mi.tmdbid', 'mi.traktid', 'v.tvdb', 'v.tvmaze', 'v.tvrage', ]) ->first(); if ($release === null) { Log::warning('ElasticSearch: Release not found for update, removing from index', ['id' => $releaseID]); $this->deleteRelease((int) $releaseID); return; } $client = $this->getClient(); $client->index($this->buildReleaseDocument($release->toArray())); } catch (ElasticsearchException $e) { Log::error('ElasticSearch updateRelease error: '.$e->getMessage(), [ 'release_id' => $releaseID, ]); } catch (\Throwable $e) { Log::error('ElasticSearch updateRelease unexpected error: '.$e->getMessage(), [ 'release_id' => $releaseID, ]); } } /** * Insert a predb record into the index. * * @param array $parameters Predb data with 'id', 'title', 'source', 'filename' */ public function insertPredb(array $parameters): void { if (empty($parameters['id'])) { Log::warning('ElasticSearch: Cannot insert predb without ID'); return; } if (! $this->isElasticsearchAvailable()) { return; } try { $data = [ 'body' => [ 'id' => $parameters['id'], 'title' => $parameters['title'] ?? '', 'source' => $parameters['source'] ?? '', 'filename' => $parameters['filename'] ?? '', ], 'index' => $this->getPredbIndex(), 'id' => $parameters['id'], ]; $client = $this->getClient(); $client->index($data); } catch (ElasticsearchException $e) { Log::error('ElasticSearch insertPreDb error: '.$e->getMessage(), [ 'predb_id' => $parameters['id'], ]); } catch (\Throwable $e) { Log::error('ElasticSearch insertPreDb unexpected error: '.$e->getMessage(), [ 'predb_id' => $parameters['id'], ]); } } /** * Update a predb record in the index. * * @param array $parameters Predb data with 'id', 'title', 'source', 'filename' */ public function updatePreDb(array $parameters): void { if (empty($parameters['id'])) { Log::warning('ElasticSearch: Cannot update predb without ID'); return; } if (! $this->isElasticsearchAvailable()) { return; } try { $data = [ 'body' => [ 'doc' => [ 'id' => $parameters['id'], 'title' => $parameters['title'] ?? '', 'filename' => $parameters['filename'] ?? '', 'source' => $parameters['source'] ?? '', ], 'doc_as_upsert' => true, ], 'index' => $this->getPredbIndex(), 'id' => $parameters['id'], ]; $client = $this->getClient(); $client->update($data); } catch (ElasticsearchException $e) { Log::error('ElasticSearch updatePreDb error: '.$e->getMessage(), [ 'predb_id' => $parameters['id'], ]); } catch (\Throwable $e) { Log::error('ElasticSearch updatePreDb unexpected error: '.$e->getMessage(), [ 'predb_id' => $parameters['id'], ]); } } /** * Delete a release from the index. * * @param int $id Release ID */ public function deleteRelease(int $id): void { if (empty($id) || ! $this->isElasticsearchAvailable()) { if (empty($id)) { Log::warning('ElasticSearch: Cannot delete release without ID'); } return; } try { $client = $this->getClient(); $client->delete([ 'index' => $this->getReleasesIndex(), 'id' => $id, ]); } catch (\Throwable $e) { if (ElasticsearchResponseHelper::isNotFound($e)) { if (config('app.debug')) { Log::debug('ElasticSearch deleteRelease: document not found', ['release_id' => $id]); } return; } if ($e instanceof ElasticsearchException) { Log::error('ElasticSearch deleteRelease error: '.$e->getMessage(), [ 'release_id' => $id, ]); return; } Log::error('ElasticSearch deleteRelease unexpected error: '.$e->getMessage(), [ 'release_id' => $id, ]); } } /** * Delete a predb record from the index. * * @param int $id Predb ID */ public function deletePreDb(int $id): void { if (empty($id) || ! $this->isElasticsearchAvailable()) { if (empty($id)) { Log::warning('ElasticSearch: Cannot delete predb without ID'); } return; } try { $client = $this->getClient(); $client->delete([ 'index' => $this->getPredbIndex(), 'id' => $id, ]); } catch (\Throwable $e) { if (ElasticsearchResponseHelper::isNotFound($e)) { if (config('app.debug')) { Log::debug('ElasticSearch deletePreDb: document not found', ['predb_id' => $id]); } return; } if ($e instanceof ElasticsearchException) { Log::error('ElasticSearch deletePreDb error: '.$e->getMessage(), [ 'predb_id' => $id, ]); return; } Log::error('ElasticSearch deletePreDb unexpected error: '.$e->getMessage(), [ 'predb_id' => $id, ]); } } /** * Bulk insert multiple releases into the index. * * @param array $releases Array of release data arrays * @return array Results with 'success' and 'errors' counts */ public function bulkInsertReleases(array $releases): array { if (empty($releases) || ! $this->isElasticsearchAvailable()) { return ['success' => 0, 'errors' => 0]; } $success = 0; $errors = 0; $params = ['body' => []]; foreach ($releases as $release) { if (empty($release['id'])) { $errors++; continue; } $searchNameDotless = $this->createPlainSearchName($release['searchname'] ?? ''); $params['body'][] = [ 'index' => [ '_index' => $this->getReleasesIndex(), '_id' => $release['id'], ], ]; $params['body'][] = [ 'id' => $release['id'], 'name' => (string) ($release['name'] ?? ''), 'searchname' => (string) ($release['searchname'] ?? ''), 'plainsearchname' => $searchNameDotless, 'fromname' => (string) ($release['fromname'] ?? ''), 'categories_id' => (int) ($release['categories_id'] ?? 0), 'filename' => (string) ($release['filename'] ?? ''), ]; $success++; } if (! empty($params['body'])) { try { $client = $this->getClient(); $response = $client->bulk($params); if (isset($response['errors']) && $response['errors']) { foreach ($response['items'] as $item) { if (isset($item['index']['error'])) { $errors++; $success--; if (config('app.debug')) { Log::error('ElasticSearch bulkInsertReleases error: '.json_encode($item['index']['error'])); } } } } } catch (\Throwable $e) { Log::error('ElasticSearch bulkInsertReleases error: '.$e->getMessage()); $errors += $success; $success = 0; } } return ['success' => $success, 'errors' => $errors]; } /** * Bulk insert multiple predb records into the index. * * @param array $predbRecords Array of predb data arrays * @return array Results with 'success' and 'errors' counts */ public function bulkInsertPredb(array $predbRecords): array { if (empty($predbRecords) || ! $this->isElasticsearchAvailable()) { return ['success' => 0, 'errors' => 0]; } $success = 0; $errors = 0; $params = ['body' => []]; foreach ($predbRecords as $predb) { if (empty($predb['id'])) { $errors++; continue; } $params['body'][] = [ 'index' => [ '_index' => $this->getPredbIndex(), '_id' => $predb['id'], ], ]; $params['body'][] = [ 'id' => $predb['id'], 'title' => (string) ($predb['title'] ?? ''), 'filename' => (string) ($predb['filename'] ?? ''), 'source' => (string) ($predb['source'] ?? ''), ]; $success++; } if (! empty($params['body'])) { try { $client = $this->getClient(); $response = $client->bulk($params); if (isset($response['errors']) && $response['errors']) { foreach ($response['items'] as $item) { if (isset($item['index']['error'])) { $errors++; $success--; if (config('app.debug')) { Log::error('ElasticSearch bulkInsertPredb error: '.json_encode($item['index']['error'])); } } } } } catch (\Throwable $e) { Log::error('ElasticSearch bulkInsertPredb error: '.$e->getMessage()); $errors += $success; $success = 0; } } return ['success' => $success, 'errors' => $errors]; } /** * Check if an index exists. * * @param string $index Index name */ public function indexExists(string $index): bool { if (! $this->isElasticsearchAvailable()) { return false; } try { $client = $this->getClient(); return ElasticsearchResponseHelper::boolResponse($client, fn (Client $elasticClient) => $elasticClient->indices()->exists(['index' => $index])); } catch (\Throwable $e) { Log::error('ElasticSearch indexExists error: '.$e->getMessage(), ['index' => $index]); return false; } } /** * Truncate/clear an index (remove all documents). * Implements SearchServiceInterface::truncateIndex * * @param array|string $indexes Index name(s) to truncate */ public function truncateIndex(array|string $indexes): void { if (! $this->isElasticsearchAvailable()) { return; } $indexArray = is_array($indexes) ? $indexes : [$indexes]; foreach ($indexArray as $index) { try { $client = $this->getClient(); // Check if index exists if (! ElasticsearchResponseHelper::boolResponse($client, fn (Client $elasticClient) => $elasticClient->indices()->exists(['index' => $index]))) { Log::info("ElasticSearch truncateIndex: index {$index} does not exist, skipping"); continue; } // Delete all documents from the index $client->deleteByQuery([ 'index' => $index, 'body' => [ 'query' => ['match_all' => (object) []], ], 'conflicts' => 'proceed', ]); // Force refresh to ensure deletions are visible $client->indices()->refresh(['index' => $index]); Log::info("ElasticSearch: Truncated index {$index}"); } catch (\Throwable $e) { Log::error("ElasticSearch truncateIndex error for {$index}: ".$e->getMessage()); } } } /** * Optimize index for better search performance. * Implements SearchServiceInterface::optimizeIndex */ public function optimizeIndex(): void { if (! $this->isElasticsearchAvailable()) { return; } try { $client = $this->getClient(); // Force merge the releases index $client->indices()->forcemerge([ 'index' => $this->getReleasesIndex(), 'max_num_segments' => 1, ]); // Force merge the predb index $client->indices()->forcemerge([ 'index' => $this->getPredbIndex(), 'max_num_segments' => 1, ]); // Refresh both indexes $client->indices()->refresh(['index' => $this->getReleasesIndex()]); $client->indices()->refresh(['index' => $this->getPredbIndex()]); Log::info('ElasticSearch: Optimized indexes'); } catch (\Throwable $e) { Log::error('ElasticSearch optimizeIndex error: '.$e->getMessage()); } } /** * Get cluster health information. * * @return array Health information or empty array on failure */ public function getClusterHealth(): array { if (! $this->isElasticsearchAvailable()) { return []; } try { $client = $this->getClient(); return ElasticsearchResponseHelper::asArray($client->cluster()->health()); } catch (\Throwable $e) { Log::error('ElasticSearch getClusterHealth error: '.$e->getMessage()); return []; } } /** * Get index statistics. * * @param string $index Index name * @return array Statistics or empty array on failure */ public function getIndexStats(string $index): array { if (! $this->isElasticsearchAvailable()) { return []; } try { $client = $this->getClient(); return ElasticsearchResponseHelper::asArray($client->indices()->stats(['index' => $index])); } catch (\Throwable $e) { Log::error('ElasticSearch getIndexStats error: '.$e->getMessage(), ['index' => $index]); return []; } } /** * Sanitize search terms for Elasticsearch. * * Uses the global sanitize() helper function which properly escapes * Elasticsearch query string special characters. * * @param array|string $terms Search terms * @return string Sanitized search string */ private function sanitizeSearchTerms(array|string $terms): string { if (is_array($terms)) { $terms = implode(' ', array_filter($terms)); } $terms = trim($terms); if (empty($terms)) { return ''; } // Use the original sanitize() helper function that properly handles // Elasticsearch query string escaping if (function_exists('sanitize')) { return sanitize($terms); } // Fallback if sanitize function doesn't exist // Replace dots with spaces for release name searches $terms = str_replace('.', ' ', $terms); // Remove multiple consecutive spaces $terms = preg_replace('/\s+/', ' ', trim($terms)); return $terms; } /** * Build a cache key for search results. * * @param string $prefix Cache key prefix * @param array $params Parameters to include in key */ private function buildCacheKey(string $prefix, array $params): string { return 'es_'.$prefix.'_'.md5(serialize($params)); } /** * Build a search query array. * * @param string $index Index name * @param string $keywords Sanitized keywords * @param array $fields Fields to search with boosts * @param int $limit Maximum results * @param array $options Additional query_string options * @param bool $includeDateSort Include date sorting * @return array */ private function buildSearchQuery( string $index, string $keywords, array $fields, int $limit, array $options = [], bool $includeDateSort = true, bool $useScroll = false ): array { $queryString = array_merge([ 'query' => $keywords, 'fields' => $fields, 'analyze_wildcard' => true, 'default_operator' => 'and', ], $options); $sort = [['_score' => ['order' => 'desc']]]; if ($includeDateSort) { $sort[] = ['add_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']]; $sort[] = ['post_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']]; } $search = [ 'index' => $index, 'body' => [ 'query' => [ 'query_string' => $queryString, ], 'size' => min($limit, self::MAX_RESULTS), 'sort' => $sort, '_source' => ['id'], 'track_total_hits' => true, ], ]; if ($useScroll) { $search['scroll'] = self::SCROLL_TIMEOUT; } return $search; } /** * Build a release document for indexing. * * @param array $parameters Release parameters * @return array */ private function buildReleaseDocument(array $parameters): array { $searchNameDotless = $this->createPlainSearchName($parameters['searchname'] ?? ''); return [ 'body' => [ 'id' => $parameters['id'], 'name' => $parameters['name'] ?? '', 'searchname' => $parameters['searchname'] ?? '', 'plainsearchname' => $searchNameDotless, 'fromname' => $parameters['fromname'] ?? '', 'categories_id' => $parameters['categories_id'] ?? 0, 'imdbid' => (string) ($parameters['imdbid'] ?? ''), 'filename' => $parameters['filename'] ?? '', 'add_date' => now()->format('Y-m-d H:i:s'), 'post_date' => $parameters['postdate'] ?? now()->format('Y-m-d H:i:s'), ], 'index' => $this->getReleasesIndex(), 'id' => $parameters['id'], ]; } /** * Create a plain search name by removing dots and dashes. * * @param string $searchName Original search name */ private function createPlainSearchName(string $searchName): string { return str_replace(['.', '-'], ' ', $searchName); } /** * Execute a search with scroll support. * * @param array $search Search query * @param bool $fullResults Return full source documents instead of just IDs * @return array */ protected function executeSearch(array $search, bool $fullResults = false): array { if (empty($search) || ! $this->isElasticsearchAvailable()) { return []; } $scrollId = null; $useScroll = isset($search['scroll']) && is_string($search['scroll']) && $search['scroll'] !== ''; try { $client = $this->getClient(); // Log the search query for debugging if (config('app.debug')) { Log::debug('ElasticSearch executing search', [ 'index' => $search['index'] ?? 'unknown', 'query' => $search['body']['query'] ?? [], ]); } $results = $client->search($search); // Log the number of hits if (config('app.debug')) { $totalHits = $results['hits']['total']['value'] ?? $results['hits']['total'] ?? 0; Log::debug('ElasticSearch search results', [ 'total_hits' => $totalHits, 'returned_hits' => count($results['hits']['hits'] ?? []), ]); } $searchResult = []; 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'])) { break; } $scrollId = $results['_scroll_id']; $results = $client->scroll([ '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 } catch (ElasticsearchException $e) { Log::error('ElasticSearch search error: '.$e->getMessage()); return []; } catch (\Throwable $e) { Log::error('ElasticSearch search unexpected error: '.$e->getMessage()); return []; } finally { // Clean up scroll context $this->clearScrollContext($scrollId); } } /** * Clear a scroll context to free server resources. * * @param string|null $scrollId Scroll ID to clear */ private function clearScrollContext(?string $scrollId): void { if ($scrollId === null) { return; } try { $client = $this->getClient(); $client->clearScroll(['scroll_id' => $scrollId]); } catch (\Throwable $e) { // Ignore errors when clearing scroll - it's just cleanup if (config('app.debug')) { Log::debug('Failed to clear scroll context: '.$e->getMessage()); } } } /** * Execute a bulk operation. * * @param array $params Bulk operation parameters */ private function executeBulk(array $params): void { if (empty($params['body'])) { return; } try { $client = $this->getClient(); $response = $client->bulk($params); if (! empty($response['errors'])) { foreach ($response['items'] as $item) { $operation = $item['index'] ?? $item['update'] ?? $item['delete'] ?? []; if (isset($operation['error'])) { Log::error('ElasticSearch bulk operation error', [ 'id' => $operation['_id'] ?? 'unknown', 'error' => $operation['error'], ]); } } } } catch (ElasticsearchException $e) { Log::error('ElasticSearch bulk error: '.$e->getMessage()); } catch (\Throwable $e) { Log::error('ElasticSearch bulk unexpected error: '.$e->getMessage()); } } /** * Insert a movie into the movies search index. * * @param array $parameters Movie data */ public function insertMovie(array $parameters): void { if (empty($parameters['id'])) { Log::warning('ElasticSearch: Cannot insert movie without ID'); return; } try { $client = $this->getClient(); $document = [ 'id' => $parameters['id'], 'imdbid' => (string) ($parameters['imdbid'] ?? ''), 'tmdbid' => (int) ($parameters['tmdbid'] ?? 0), 'traktid' => (int) ($parameters['traktid'] ?? 0), 'title' => (string) ($parameters['title'] ?? ''), 'year' => (string) ($parameters['year'] ?? ''), 'genre' => (string) ($parameters['genre'] ?? ''), 'actors' => (string) ($parameters['actors'] ?? ''), 'director' => (string) ($parameters['director'] ?? ''), 'rating' => (string) ($parameters['rating'] ?? ''), 'plot' => (string) ($parameters['plot'] ?? ''), ]; $client->index([ 'index' => $this->getMoviesIndex(), 'id' => $parameters['id'], 'body' => $document, ]); } catch (ElasticsearchException $e) { Log::error('ElasticSearch insertMovie error: '.$e->getMessage(), [ 'movie_id' => $parameters['id'], ]); } catch (\Throwable $e) { Log::error('ElasticSearch insertMovie unexpected error: '.$e->getMessage(), [ 'movie_id' => $parameters['id'], ]); } } /** * Update a movie in the search index. * * @param int $movieId Movie ID */ public function updateMovie(int $movieId): void { if (empty($movieId)) { Log::warning('ElasticSearch: Cannot update movie without ID'); return; } try { $movie = MovieInfo::find($movieId); if ($movie !== null) { $this->insertMovie($movie->toArray()); } else { Log::warning('ElasticSearch: Movie not found for update', ['id' => $movieId]); } } catch (\Throwable $e) { Log::error('ElasticSearch updateMovie error: '.$e->getMessage(), [ 'movie_id' => $movieId, ]); } } /** * Delete a movie from the search index. * * @param int $id Movie ID */ public function deleteMovie(int $id): void { if (empty($id)) { Log::warning('ElasticSearch: Cannot delete movie without ID'); return; } try { $client = $this->getClient(); $client->delete([ 'index' => $this->getMoviesIndex(), 'id' => $id, ]); } catch (\Throwable $e) { if (ElasticsearchResponseHelper::isNotFound($e)) { return; } if ($e instanceof ElasticsearchException) { Log::error('ElasticSearch deleteMovie error: '.$e->getMessage(), [ 'id' => $id, ]); } } } /** * Bulk insert multiple movies into the index. * * @param array $movies Array of movie data arrays * @return array Results with 'success' and 'errors' counts */ public function bulkInsertMovies(array $movies): array { if (empty($movies)) { return ['success' => 0, 'errors' => 0]; } $success = 0; $errors = 0; $params = ['body' => []]; foreach ($movies as $movie) { if (empty($movie['id'])) { $errors++; continue; } $params['body'][] = [ 'index' => [ '_index' => $this->getMoviesIndex(), '_id' => $movie['id'], ], ]; $params['body'][] = [ 'id' => $movie['id'], 'imdbid' => (string) ($movie['imdbid'] ?? ''), 'tmdbid' => (int) ($movie['tmdbid'] ?? 0), 'traktid' => (int) ($movie['traktid'] ?? 0), 'title' => (string) ($movie['title'] ?? ''), 'year' => (string) ($movie['year'] ?? ''), 'genre' => (string) ($movie['genre'] ?? ''), 'actors' => (string) ($movie['actors'] ?? ''), 'director' => (string) ($movie['director'] ?? ''), 'rating' => (string) ($movie['rating'] ?? ''), 'plot' => (string) ($movie['plot'] ?? ''), ]; $success++; } if (! empty($params['body'])) { try { $this->executeBulk($params); } catch (\Throwable $e) { Log::error('ElasticSearch bulkInsertMovies error: '.$e->getMessage()); $errors += $success; $success = 0; } } return ['success' => $success, 'errors' => $errors]; } /** * Search the movies index. * * @param array|string $searchTerm Search term(s) * @param int $limit Maximum number of results * @return array Array with 'id' (movie IDs) and 'data' (movie data) */ public function searchMovies(array|string $searchTerm, int $limit = 1000): array { $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm; $escapedSearch = self::escapeString($searchString); if (empty($escapedSearch)) { return ['id' => [], 'data' => []]; } $cacheKey = 'es:movies:'.md5($escapedSearch.$limit); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { $client = $this->getClient(); $searchParams = [ 'index' => $this->getMoviesIndex(), 'body' => [ 'query' => [ 'multi_match' => [ 'query' => $escapedSearch, 'fields' => ['title^3', 'actors', 'director', 'genre'], 'type' => 'best_fields', 'fuzziness' => 'AUTO', ], ], 'size' => min($limit, self::MAX_RESULTS), ], ]; $response = $client->search($searchParams); $resultIds = []; $resultData = []; if (isset($response['hits']['hits'])) { foreach ($response['hits']['hits'] as $hit) { $resultIds[] = $hit['_id']; $resultData[] = $hit['_source']; } } $result = ['id' => $resultIds, 'data' => $resultData]; Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL_MINUTES)); return $result; } catch (\Throwable $e) { Log::error('ElasticSearch searchMovies error: '.$e->getMessage()); return ['id' => [], 'data' => []]; } } /** * {@inheritdoc} */ public function searchMoviesByFields(array $fieldTerms, int $limit = 5000): array { $allowed = ['title' => true, 'director' => true, 'actors' => true, 'genre' => true]; $filtered = []; foreach ($fieldTerms as $key => $value) { $k = (string) $key; if (isset($allowed[$k]) && is_string($value) && trim($value) !== '') { $filtered[$k] = trim($value); } } if ($filtered === [] || ! $this->isElasticsearchAvailable()) { return ['imdbids' => [], 'movieinfo_ids' => [], 'data' => []]; } $must = []; foreach ($filtered as $field => $value) { $must[] = [ 'match' => [ $field => [ 'query' => $value, 'operator' => 'and', ], ], ]; } try { $client = $this->getClient(); $response = $client->search([ 'index' => $this->getMoviesIndex(), 'body' => [ 'query' => ['bool' => ['must' => $must]], 'size' => min($limit, self::MAX_RESULTS), ], ]); $imdbids = []; $movieinfoIds = []; $data = []; foreach ($response['hits']['hits'] ?? [] as $hit) { $movieinfoIds[] = (int) ($hit['_id'] ?? 0); $src = $hit['_source'] ?? []; $data[] = $src; $imdb = (string) ($src['imdbid'] ?? ''); if ($imdb !== '') { $imdbids[] = $imdb; } } return [ 'imdbids' => array_values(array_unique($imdbids)), 'movieinfo_ids' => $movieinfoIds, 'data' => $data, ]; } catch (\Throwable $e) { Log::error('ElasticSearch searchMoviesByFields error: '.$e->getMessage()); return ['imdbids' => [], 'movieinfo_ids' => [], 'data' => []]; } } /** * Search movies by external ID (IMDB, TMDB, Trakt). * * @param string $field Field name (imdbid, tmdbid, traktid) * @param int|string $value The external ID value * @return array|null Movie data or null if not found */ public function searchMovieByExternalId(string $field, int|string $value): ?array { if (empty($value)) { return null; } 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; } try { $client = $this->getClient(); $searchParams = [ 'index' => $this->getMoviesIndex(), 'body' => [ 'query' => [ 'bool' => [ 'should' => $shouldClauses, 'minimum_should_match' => 1, ], ], 'size' => 1, ], ]; $response = $client->search($searchParams); if (isset($response['hits']['hits'][0])) { $data = $response['hits']['hits'][0]['_source']; $data['id'] = $response['hits']['hits'][0]['_id']; Cache::put($cacheKey, $data, now()->addMinutes(self::CACHE_TTL_MINUTES)); return $data; } } catch (\Throwable $e) { Log::error('ElasticSearch searchMovieByExternalIds error: '.$e->getMessage(), [ 'externalIds' => $externalIds, ]); } return null; } /** * Insert a TV show into the tvshows search index. * * @param array $parameters TV show data */ public function insertTvShow(array $parameters): void { if (empty($parameters['id'])) { Log::warning('ElasticSearch: Cannot insert TV show without ID'); return; } try { $client = $this->getClient(); $document = [ 'id' => $parameters['id'], 'title' => (string) ($parameters['title'] ?? ''), 'tvdb' => (int) ($parameters['tvdb'] ?? 0), 'trakt' => (int) ($parameters['trakt'] ?? 0), 'tvmaze' => (int) ($parameters['tvmaze'] ?? 0), 'tvrage' => (int) ($parameters['tvrage'] ?? 0), 'imdb' => (string) ($parameters['imdb'] ?? ''), 'tmdb' => (int) ($parameters['tmdb'] ?? 0), 'started' => (string) ($parameters['started'] ?? ''), 'type' => (int) ($parameters['type'] ?? 0), ]; $client->index([ 'index' => $this->getTvShowsIndex(), 'id' => $parameters['id'], 'body' => $document, ]); } catch (ElasticsearchException $e) { Log::error('ElasticSearch insertTvShow error: '.$e->getMessage(), [ 'tvshow_id' => $parameters['id'], ]); } catch (\Throwable $e) { Log::error('ElasticSearch insertTvShow unexpected error: '.$e->getMessage(), [ 'tvshow_id' => $parameters['id'], ]); } } /** * Update a TV show in the search index. * * @param int $videoId Video/TV show ID */ public function updateTvShow(int $videoId): void { if (empty($videoId)) { Log::warning('ElasticSearch: Cannot update TV show without ID'); return; } try { $video = Video::find($videoId); if ($video !== null) { $this->insertTvShow($video->toArray()); } else { Log::warning('ElasticSearch: TV show not found for update', ['id' => $videoId]); } } catch (\Throwable $e) { Log::error('ElasticSearch updateTvShow error: '.$e->getMessage(), [ 'tvshow_id' => $videoId, ]); } } /** * Delete a TV show from the search index. * * @param int $id TV show ID */ public function deleteTvShow(int $id): void { if (empty($id)) { Log::warning('ElasticSearch: Cannot delete TV show without ID'); return; } try { $client = $this->getClient(); $client->delete([ 'index' => $this->getTvShowsIndex(), 'id' => $id, ]); } catch (\Throwable $e) { if (ElasticsearchResponseHelper::isNotFound($e)) { return; } if ($e instanceof ElasticsearchException) { Log::error('ElasticSearch deleteTvShow error: '.$e->getMessage(), [ 'id' => $id, ]); } } } /** * Bulk insert multiple TV shows into the index. * * @param array $tvShows Array of TV show data arrays * @return array Results with 'success' and 'errors' counts */ public function bulkInsertTvShows(array $tvShows): array { if (empty($tvShows)) { return ['success' => 0, 'errors' => 0]; } $success = 0; $errors = 0; $params = ['body' => []]; foreach ($tvShows as $tvShow) { if (empty($tvShow['id'])) { $errors++; continue; } $params['body'][] = [ 'index' => [ '_index' => $this->getTvShowsIndex(), '_id' => $tvShow['id'], ], ]; $params['body'][] = [ 'id' => $tvShow['id'], 'title' => (string) ($tvShow['title'] ?? ''), 'tvdb' => (int) ($tvShow['tvdb'] ?? 0), 'trakt' => (int) ($tvShow['trakt'] ?? 0), 'tvmaze' => (int) ($tvShow['tvmaze'] ?? 0), 'tvrage' => (int) ($tvShow['tvrage'] ?? 0), 'imdb' => (string) ($tvShow['imdb'] ?? ''), 'tmdb' => (int) ($tvShow['tmdb'] ?? 0), 'started' => (string) ($tvShow['started'] ?? ''), 'type' => (int) ($tvShow['type'] ?? 0), ]; $success++; } if (! empty($params['body'])) { try { $this->executeBulk($params); } catch (\Throwable $e) { Log::error('ElasticSearch bulkInsertTvShows error: '.$e->getMessage()); $errors += $success; $success = 0; } } return ['success' => $success, 'errors' => $errors]; } /** * Search the TV shows index. * * @param array|string $searchTerm Search term(s) * @param int $limit Maximum number of results * @return array Array with 'id' (TV show IDs) and 'data' (TV show data) */ public function searchTvShows(array|string $searchTerm, int $limit = 1000): array { $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm; $escapedSearch = self::escapeString($searchString); if (empty($escapedSearch)) { return ['id' => [], 'data' => []]; } $cacheKey = 'es:tvshows:'.md5($escapedSearch.$limit); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { $client = $this->getClient(); $searchParams = [ 'index' => $this->getTvShowsIndex(), 'body' => [ 'query' => [ 'match' => [ 'title' => [ 'query' => $escapedSearch, 'fuzziness' => 'AUTO', ], ], ], 'size' => min($limit, self::MAX_RESULTS), ], ]; $response = $client->search($searchParams); $resultIds = []; $resultData = []; if (isset($response['hits']['hits'])) { foreach ($response['hits']['hits'] as $hit) { $resultIds[] = $hit['_id']; $resultData[] = $hit['_source']; } } $result = ['id' => $resultIds, 'data' => $resultData]; Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL_MINUTES)); return $result; } catch (\Throwable $e) { Log::error('ElasticSearch searchTvShows error: '.$e->getMessage()); return ['id' => [], 'data' => []]; } } /** * Search TV shows by external ID (TVDB, Trakt, TVMaze, TVRage, IMDB, TMDB). * * @param string $field Field name (tvdb, trakt, tvmaze, tvrage, imdb, tmdb) * @param int|string $value The external ID value * @return array|null TV show data or null if not found */ public function searchTvShowByExternalId(string $field, int|string $value): ?array { if (empty($value)) { return null; } 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; } try { $client = $this->getClient(); $searchParams = [ 'index' => $this->getTvShowsIndex(), 'body' => [ 'query' => [ 'bool' => [ 'should' => $shouldClauses, 'minimum_should_match' => 1, ], ], 'size' => 1, ], ]; $response = $client->search($searchParams); if (isset($response['hits']['hits'][0])) { $data = $response['hits']['hits'][0]['_source']; $data['id'] = $response['hits']['hits'][0]['_id']; Cache::put($cacheKey, $data, now()->addMinutes(self::CACHE_TTL_MINUTES)); return $data; } } catch (\Throwable $e) { Log::error('ElasticSearch searchTvShowByExternalIds error: '.$e->getMessage(), [ 'externalIds' => $externalIds, ]); } return null; } /** * Search releases by external media IDs. * Used to find releases associated with a specific movie or TV show. * * @param array $externalIds Associative array of external IDs * @param int $limit Maximum number of results * @return array Array of release IDs */ public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array { return $this->searchReleasesByMultipleExternalIds([$externalIds], $limit); } /** * @param array> $externalIdSets * @return array */ public function searchReleasesByMultipleExternalIds(array $externalIdSets, int $limit = 1000): array { if ($externalIdSets === []) { return []; } $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; } try { $client = $this->getClient(); $searchParams = [ 'index' => $this->getReleasesIndex(), 'body' => [ 'query' => [ 'bool' => [ 'should' => $shouldClauses, 'minimum_should_match' => 1, ], ], 'size' => min($limit, self::MAX_RESULTS), '_source' => false, ], ]; $response = $client->search($searchParams); $resultIds = []; foreach ($response['hits']['hits'] ?? [] as $hit) { $resultIds[] = $hit['_id']; } if ($resultIds !== []) { Cache::put($cacheKey, $resultIds, now()->addMinutes(self::CACHE_TTL_MINUTES)); } return $resultIds; // @phpstan-ignore return.type } catch (\Throwable $e) { 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. * * @param array $categoryIds Array of category IDs to filter by * @param int $limit Maximum number of results * @return array */ public function searchReleasesByCategory(array $categoryIds, int $limit = 1000): array { if (empty($categoryIds)) { return []; } // Filter out invalid category IDs (-1 means "all categories") $validCategoryIds = array_filter($categoryIds, fn ($id) => $id > 0); if (empty($validCategoryIds)) { return []; } $cacheKey = 'es:releases:cat:'.md5(serialize($validCategoryIds).':'.$limit); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { $client = $this->getClient(); $searchParams = [ 'index' => $this->getReleasesIndex(), 'body' => [ 'query' => [ 'terms' => [ 'categories_id' => array_map('intval', $validCategoryIds), ], ], 'size' => min($limit, self::MAX_RESULTS), '_source' => false, ], ]; $response = $client->search($searchParams); $resultIds = []; if (isset($response['hits']['hits'])) { foreach ($response['hits']['hits'] as $hit) { $resultIds[] = $hit['_id']; } } if (! empty($resultIds)) { Cache::put($cacheKey, $resultIds, now()->addMinutes(self::CACHE_TTL_MINUTES)); } return $resultIds; // @phpstan-ignore return.type } catch (\Throwable $e) { Log::error('ElasticSearch searchReleasesByCategory error: '.$e->getMessage(), [ 'categoryIds' => $categoryIds, ]); } return []; } /** * Combined search: text search with category filtering. * First searches by text, then filters by category IDs using the search index. * * @param string $searchTerm Search text * @param array $categoryIds Array of category IDs to filter by (empty for all categories) * @param int $limit Maximum number of results * @return array */ public function searchReleasesWithCategoryFilter(string $searchTerm, array $categoryIds = [], int $limit = 1000): array { if (empty($searchTerm)) { // If no search term, just filter by category return $this->searchReleasesByCategory($categoryIds, $limit); } // Filter out invalid category IDs $validCategoryIds = array_filter($categoryIds, fn ($id) => $id > 0); $cacheKey = 'es:releases:search_cat:'.md5($searchTerm.':'.serialize($validCategoryIds).':'.$limit); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { $client = $this->getClient(); $query = [ 'bool' => [ 'must' => [ [ 'multi_match' => [ 'query' => $searchTerm, 'fields' => self::RELEASE_TEXT_FIELDS, 'type' => 'cross_fields', 'operator' => 'and', 'fuzziness' => 'AUTO', ], ], ], ], ]; // Add category filter if provided if (! empty($validCategoryIds)) { $query['bool']['filter'] = [ 'terms' => [ 'categories_id' => array_map('intval', $validCategoryIds), ], ]; } $searchParams = [ 'index' => $this->getReleasesIndex(), 'body' => [ 'query' => $query, 'size' => min($limit, self::MAX_RESULTS), '_source' => false, ], ]; $response = $client->search($searchParams); $resultIds = []; if (isset($response['hits']['hits'])) { foreach ($response['hits']['hits'] as $hit) { $resultIds[] = $hit['_id']; } } if (! empty($resultIds)) { Cache::put($cacheKey, $resultIds, now()->addMinutes(self::CACHE_TTL_MINUTES)); } return $resultIds; } catch (\Throwable $e) { Log::error('ElasticSearch searchReleasesWithCategoryFilter error: '.$e->getMessage(), [ 'searchTerm' => $searchTerm, 'categoryIds' => $categoryIds, ]); } return []; } /** * {@inheritdoc} */ public function searchReleasesFiltered(array $criteria, int $limit, int $offset = 0): array { if (! $this->isElasticsearchAvailable()) { return ['ids' => [], 'total' => 0, 'fuzzy' => false, 'available' => false]; } $phrases = $criteria['phrases'] ?? null; $hasText = $phrases !== null && $phrases !== '' && $phrases !== -1; $tryFuzzy = (bool) ($criteria['try_fuzzy'] ?? true); $run = function (bool $useFuzzy) use ($criteria, $limit, $offset, $hasText, $phrases): array { $filter = $this->buildElasticsearchReleaseFilters($criteria); $must = []; if ($hasText) { if (is_string($phrases)) { $text = trim($phrases); if ($text === '') { return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy]; } $multi = [ 'query' => $text, 'fields' => self::RELEASE_TEXT_FIELDS, 'type' => 'cross_fields', 'operator' => 'and', ]; if ($useFuzzy && $this->isFuzzyEnabled()) { $multi['fuzziness'] = $this->getFuzzyConfig()['fuzziness'] ?? 'AUTO'; } $must[] = ['multi_match' => $multi]; } elseif (is_array($phrases) && $this->isAssociativeStringArray($phrases)) { $must = $this->buildReleaseFieldSpecificMustClauses($phrases, $useFuzzy); if ($must === []) { return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy]; } } else { $text = implode(' ', array_values(array_filter( is_array($phrases) ? $phrases : [], static fn ($v): bool => $v !== null && $v !== '' && $v !== -1 ))); $text = trim($text); if ($text === '') { return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy]; } $multi = [ 'query' => $text, 'fields' => self::RELEASE_TEXT_FIELDS, 'type' => 'cross_fields', 'operator' => 'and', ]; if ($useFuzzy && $this->isFuzzyEnabled()) { $multi['fuzziness'] = $this->getFuzzyConfig()['fuzziness'] ?? 'AUTO'; } $must[] = ['multi_match' => $multi]; } } if ($must !== [] && $filter !== []) { $query = ['bool' => ['must' => $must, 'filter' => $filter]]; } elseif ($must !== []) { $query = ['bool' => ['must' => $must]]; } elseif ($filter !== []) { $query = ['bool' => ['filter' => $filter]]; } else { $query = ['match_all' => new \stdClass]; } $sortField = (string) ($criteria['sort_field'] ?? 'postdate_ts'); $sortDir = strtolower((string) ($criteria['sort_dir'] ?? 'desc')); $allowedSort = ['postdate_ts', 'adddate_ts', 'size', 'totalpart', 'grabs', 'categories_id', 'id']; if (! in_array($sortField, $allowedSort, true)) { $sortField = 'postdate_ts'; } if ($sortField === 'id') { $sortField = 'postdate_ts'; } $order = $sortDir === 'asc' ? 'asc' : 'desc'; try { $client = $this->getClient(); $body = [ 'query' => $query, 'sort' => [ [$sortField => ['order' => $order]], ['id' => ['order' => $order]], ], 'size' => max(1, min($limit, self::MAX_RESULTS)), 'track_total_hits' => (bool) ($criteria['track_total'] ?? true), '_source' => false, ]; $cursorSort = $criteria['cursor_sort'] ?? null; if (is_array($cursorSort) && count($cursorSort) === 2) { $body['search_after'] = array_values($cursorSort); } else { $body['from'] = max(0, $offset); } $response = $client->search([ 'index' => $this->getReleasesIndex(), 'body' => $body, ]); $ids = []; $hits = $response['hits']['hits'] ?? []; foreach ($hits as $hit) { $ids[] = (int) ($hit['_id'] ?? 0); } $total = (int) ($response['hits']['total']['value'] ?? $response['hits']['total'] ?? 0); $lastHit = $hits === [] ? null : $hits[array_key_last($hits)]; return [ 'ids' => $ids, 'total' => $total, 'fuzzy' => $useFuzzy, 'available' => true, 'last_sort' => is_array($lastHit) && is_array($lastHit['sort'] ?? null) ? array_values($lastHit['sort']) : [], 'has_more' => count($ids) === max(1, min($limit, self::MAX_RESULTS)) && ($total === 0 || count($ids) + $offset < $total), ]; } catch (\Throwable $e) { Log::error('ElasticSearch searchReleasesFiltered error: '.$e->getMessage()); return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy, 'available' => false]; } }; if ($hasText) { $first = $run(false); if ($first['ids'] !== [] || ($first['available'] ?? true) === false || ! $tryFuzzy || ! $this->isFuzzyEnabled()) { return $first; } return $run(true); } return $run(false); } /** * @param array $phrases * @return list> */ private function buildReleaseFieldSpecificMustClauses(array $phrases, bool $useFuzzy): array { $fieldMap = [ 'searchname' => self::RELEASE_TEXT_FIELDS, 'plainsearchname' => ['plainsearchname'], 'name' => ['name'], 'fromname' => ['fromname'], 'filename' => ['filename'], ]; $must = []; foreach ($phrases as $field => $rawValue) { if (! is_string($field) || ! isset($fieldMap[$field])) { continue; } $value = is_scalar($rawValue) ? trim((string) $rawValue) : ''; if ($value === '' || $value === '-1') { continue; } $fields = $fieldMap[$field]; if (count($fields) > 1) { $multi = [ 'query' => $value, 'fields' => $fields, 'type' => 'cross_fields', 'operator' => 'and', ]; if ($useFuzzy && $this->isFuzzyEnabled()) { $multi['fuzziness'] = $this->getFuzzyConfig()['fuzziness'] ?? 'AUTO'; } $must[] = ['multi_match' => $multi]; continue; } $fieldName = $fields[0]; $match = [ 'query' => $value, 'operator' => 'and', ]; if ($useFuzzy && $this->isFuzzyEnabled()) { $match['fuzziness'] = $this->getFuzzyConfig()['fuzziness'] ?? 'AUTO'; } $must[] = ['match' => [$fieldName => $match]]; } return $must; } /** * @param array $phrases */ private function isAssociativeStringArray(array $phrases): bool { return count(array_filter(array_keys($phrases), 'is_string')) > 0; } /** * @return list> */ private function buildElasticsearchReleaseFilters(array $criteria): array { $filter = []; $releaseIds = $criteria['release_ids'] ?? null; if (is_array($releaseIds) && $releaseIds !== []) { $valid = array_values(array_filter( array_map(static fn ($id): int => (int) $id, $releaseIds), static fn (int $id): bool => $id > 0 )); if ($valid !== []) { $filter[] = ['ids' => ['values' => array_map(static fn (int $id): string => (string) $id, $valid)]]; } } $categoryIds = $criteria['category_ids'] ?? null; if (is_array($categoryIds) && $categoryIds !== []) { $valid = array_values(array_filter($categoryIds, static fn ($id): bool => (int) $id > 0)); if ($valid !== []) { $filter[] = ['terms' => ['categories_id' => array_map(static fn ($id): int => (int) $id, $valid)]]; } } $excluded = $criteria['excluded_category_ids'] ?? []; if ($excluded !== []) { $filter[] = ['bool' => ['must_not' => [ ['terms' => ['categories_id' => array_map(static fn ($id): int => (int) $id, $excluded)]], ]]]; } $minSize = (int) ($criteria['min_size'] ?? 0); if ($minSize > 0) { $filter[] = ['range' => ['size' => ['gte' => $minSize]]]; } $maxSize = (int) ($criteria['max_size'] ?? 0); if ($maxSize > 0) { $filter[] = ['range' => ['size' => ['lte' => $maxSize]]]; } $maxAge = (int) ($criteria['max_age_days'] ?? 0); if ($maxAge > 0) { $cutoff = time() - ($maxAge * 86400); $filter[] = ['range' => ['postdate_ts' => ['gte' => $cutoff]]]; } $minDate = (int) ($criteria['min_date'] ?? 0); if ($minDate > 0) { $filter[] = ['range' => ['postdate_ts' => ['gte' => $minDate]]]; } $maxDate = (int) ($criteria['max_date'] ?? 0); if ($maxDate > 0) { $filter[] = ['range' => ['postdate_ts' => ['lte' => $maxDate]]]; } $gid = $criteria['groups_id'] ?? null; if ($gid !== null && (int) $gid > 0) { $filter[] = ['term' => ['groups_id' => (int) $gid]]; } $allowRar = (bool) ($criteria['password_allow_rar'] ?? false); if ($allowRar) { $filter[] = ['range' => ['passwordstatus' => ['lte' => 1]]]; } else { $filter[] = ['range' => ['passwordstatus' => ['lte' => 0]]]; } if (array_key_exists('password_status_min', $criteria) && $criteria['password_status_min'] !== null) { $filter[] = ['range' => ['passwordstatus' => ['gte' => (int) $criteria['password_status_min']]]]; } return $filter; } public function getSecondaryIndexName(SecondarySearchIndex $index): string { $configured = $this->config['indexes'][$index->value] ?? null; if (is_string($configured) && $configured !== '') { return $configured; } return match ($index) { SecondarySearchIndex::Music => 'music', SecondarySearchIndex::Books => 'books', SecondarySearchIndex::Games => 'games', SecondarySearchIndex::Console => 'console', SecondarySearchIndex::Steam => 'steam', SecondarySearchIndex::Anime => 'anime', }; } public function insertSecondary(SecondarySearchIndex $index, int $id, array $document): void { if ($id <= 0) { return; } try { $this->getClient()->index([ 'index' => $this->getSecondaryIndexName($index), 'id' => (string) $id, 'body' => $document, ]); } catch (\Throwable $e) { Log::error('ElasticSearch insertSecondary error: '.$e->getMessage(), [ 'secondary' => $index->value, 'id' => $id, ]); } } public function updateSecondary(SecondarySearchIndex $index, int $id): void { if ($id <= 0) { return; } try { match ($index) { SecondarySearchIndex::Music => $this->insertSecondary($index, $id, SecondaryIndexDocuments::music(MusicInfo::query()->findOrFail($id))), SecondarySearchIndex::Books => $this->insertSecondary($index, $id, SecondaryIndexDocuments::book(BookInfo::query()->findOrFail($id))), SecondarySearchIndex::Games => $this->insertSecondary($index, $id, SecondaryIndexDocuments::games(GamesInfo::query()->findOrFail($id))), SecondarySearchIndex::Console => $this->insertSecondary($index, $id, SecondaryIndexDocuments::console(ConsoleInfo::query()->findOrFail($id))), SecondarySearchIndex::Steam => $this->insertSecondary($index, $id, SecondaryIndexDocuments::steam(SteamApp::query()->findOrFail($id))), SecondarySearchIndex::Anime => null, }; } catch (\Throwable $e) { Log::warning('ElasticSearch updateSecondary skipped: '.$e->getMessage(), [ 'secondary' => $index->value, 'id' => $id, ]); } } public function deleteSecondary(SecondarySearchIndex $index, int $id): void { if ($id <= 0) { return; } try { $this->getClient()->delete([ 'index' => $this->getSecondaryIndexName($index), 'id' => (string) $id, ]); } catch (\Throwable $e) { Log::debug('ElasticSearch deleteSecondary: '.$e->getMessage(), [ 'secondary' => $index->value, 'id' => $id, ]); } } /** * @param array> $documents * @return array */ public function bulkInsertSecondary(SecondarySearchIndex $index, array $documents): array { if ($documents === []) { return ['success' => 0, 'errors' => 0]; } $validDocs = []; $errors = 0; $indexName = $this->getSecondaryIndexName($index); foreach ($documents as $doc) { $docId = (int) ($doc['id'] ?? 0); if ($docId <= 0) { $errors++; continue; } unset($doc['id']); $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]; } /** * @return array{id: list, data: list>} */ public function searchSecondary(SecondarySearchIndex $index, string $query, int $limit = 100): array { $query = trim($query); if ($query === '') { return ['id' => [], 'data' => []]; } $fields = $index->fulltextFields(); $escapedSearch = self::escapeString($query); if ($escapedSearch === '') { return ['id' => [], 'data' => []]; } try { $response = $this->getClient()->search([ 'index' => $this->getSecondaryIndexName($index), 'body' => [ 'query' => [ 'multi_match' => [ 'query' => $escapedSearch, 'fields' => $fields, 'operator' => 'and', ], ], 'size' => max(1, min($limit, self::MAX_RESULTS)), '_source' => true, ], ]); $ids = []; $data = []; foreach ($response['hits']['hits'] ?? [] as $hit) { $ids[] = (int) ($hit['_id'] ?? 0); $data[] = (array) ($hit['_source'] ?? []); } return ['id' => $ids, 'data' => $data]; } catch (\Throwable $e) { Log::error('ElasticSearch searchSecondary error: '.$e->getMessage(), ['secondary' => $index->value]); return ['id' => [], 'data' => []]; } } /** * @return list */ public function searchAnimeTitle(string $query, int $limit = 100): array { $hits = $this->searchSecondary(SecondarySearchIndex::Anime, $query, $limit); $seen = []; foreach ($hits['data'] as $row) { $aid = (int) ($row['anidbid'] ?? 0); if ($aid > 0) { $seen[$aid] = true; } } return array_keys($seen); } }