config = config('sphinxsearch'); $this->connection = ['host' => $this->config['host'], 'port' => $this->config['port']]; $this->manticoreSearch = new Client($this->connection); $this->search = new Search($this->manticoreSearch); $this->cli = new ColorCLI; } /** * Insert release into ManticoreSearch releases_rt realtime index */ public function insertRelease(array $parameters): void { if (empty($parameters['id'])) { Log::warning('ManticoreSearch: Cannot insert release without ID'); return; } try { $document = [ 'name' => $parameters['name'] ?? '', 'searchname' => $parameters['searchname'] ?? '', 'fromname' => $parameters['fromname'] ?? '', 'categories_id' => (string) ($parameters['categories_id'] ?? ''), 'filename' => $parameters['filename'] ?? '', ]; $this->manticoreSearch->table($this->config['indexes']['releases']) ->replaceDocument($document, $parameters['id']); } catch (ResponseException $e) { Log::error('ManticoreSearch insertRelease ResponseException: '.$e->getMessage(), [ 'release_id' => $parameters['id'], 'index' => $this->config['indexes']['releases'], ]); } catch (RuntimeException $e) { Log::error('ManticoreSearch insertRelease RuntimeException: '.$e->getMessage(), [ 'release_id' => $parameters['id'], ]); } catch (\Throwable $e) { Log::error('ManticoreSearch insertRelease unexpected error: '.$e->getMessage(), [ 'release_id' => $parameters['id'], 'trace' => $e->getTraceAsString(), ]); } } /** * Insert release into Manticore RT table. */ public function insertPredb(array $parameters): void { if (empty($parameters['id'])) { Log::warning('ManticoreSearch: Cannot insert predb without ID'); return; } try { $document = [ 'title' => $parameters['title'] ?? '', 'filename' => $parameters['filename'] ?? '', 'source' => $parameters['source'] ?? '', ]; $this->manticoreSearch->table($this->config['indexes']['predb']) ->replaceDocument($document, $parameters['id']); } catch (ResponseException $e) { Log::error('ManticoreSearch insertPredb ResponseException: '.$e->getMessage(), [ 'predb_id' => $parameters['id'], ]); } catch (RuntimeException $e) { Log::error('ManticoreSearch insertPredb RuntimeException: '.$e->getMessage(), [ 'predb_id' => $parameters['id'], ]); } catch (\Throwable $e) { Log::error('ManticoreSearch insertPredb unexpected error: '.$e->getMessage(), [ 'predb_id' => $parameters['id'], ]); } } /** * Delete release from Manticore RT tables. * * @param array $identifiers ['g' => Release GUID(mandatory), 'id' => ReleaseID(optional, pass false)] */ public function deleteRelease(array $identifiers): void { if (empty($identifiers['g'])) { Log::warning('ManticoreSearch: Cannot delete release without GUID'); return; } try { if ($identifiers['i'] === false || empty($identifiers['i'])) { $release = Release::query()->where('guid', $identifiers['g'])->first(['id']); $identifiers['i'] = $release?->id; } if (! empty($identifiers['i'])) { $this->manticoreSearch->table($this->config['indexes']['releases']) ->deleteDocument($identifiers['i']); } else { Log::warning('ManticoreSearch: Could not find release ID for deletion', [ 'guid' => $identifiers['g'], ]); } } catch (ResponseException $e) { Log::error('ManticoreSearch deleteRelease error: '.$e->getMessage(), [ 'guid' => $identifiers['g'], 'id' => $identifiers['i'] ?? null, ]); } } /** * Escapes characters that are treated as special operators by the query language parser. */ public static function escapeString(string $string): string { if ($string === '*' || empty($string)) { return ''; } $from = ['\\', '(', ')', '@', '~', '"', '&', '/', '$', '=', "'", '--', '[', ']', '!', '-']; $to = ['\\\\', '\(', '\)', '\@', '\~', '\"', '\&', '\/', '\$', '\=', "\'", '\--', '\[', '\]', '\!', '\-']; $string = str_replace($from, $to, $string); // Clean up trailing special characters $string = rtrim($string, '-!'); return trim($string); } public function updateRelease(int|string $releaseID): void { if (empty($releaseID)) { Log::warning('ManticoreSearch: Cannot update release without ID'); return; } try { $release = Release::query() ->where('releases.id', $releaseID) ->leftJoin('release_files as rf', 'releases.id', '=', 'rf.releases_id') ->select([ 'releases.id', 'releases.name', 'releases.searchname', 'releases.fromname', 'releases.categories_id', DB::raw('IFNULL(GROUP_CONCAT(rf.name SEPARATOR " "),"") filename'), ]) ->groupBy('releases.id') ->first(); if ($release !== null) { $this->insertRelease($release->toArray()); } else { Log::warning('ManticoreSearch: Release not found for update', ['id' => $releaseID]); } } catch (\Throwable $e) { Log::error('ManticoreSearch updateRelease error: '.$e->getMessage(), [ 'release_id' => $releaseID, ]); } } /** * Update Manticore Predb index for given predb_id. */ public function updatePreDb(array $parameters): void { if (empty($parameters)) { Log::warning('ManticoreSearch: Cannot update predb with empty parameters'); return; } $this->insertPredb($parameters); } public function truncateRTIndex(array $indexes = []): bool { if (empty($indexes)) { $this->cli->error('You need to provide index name to truncate'); return false; } $success = true; foreach ($indexes as $index) { if (! \in_array($index, $this->config['indexes'], true)) { $this->cli->error('Unsupported index: '.$index); $success = false; continue; } try { $this->manticoreSearch->table($index)->truncate(); $this->cli->info('Truncating index '.$index.' finished.'); } catch (ResponseException $e) { if ($e->getMessage() === 'Invalid index') { $this->createIndexIfNotExists($index); } else { $this->cli->error('Error truncating index '.$index.': '.$e->getMessage()); $success = false; } } catch (\Throwable $e) { $this->cli->error('Unexpected error truncating index '.$index.': '.$e->getMessage()); $success = false; } } return $success; } /** * Create index if it doesn't exist */ private function createIndexIfNotExists(string $index): void { try { if ($index === 'releases_rt') { $this->manticoreSearch->table($index)->create([ 'name' => ['type' => 'string'], 'searchname' => ['type' => 'string'], 'fromname' => ['type' => 'string'], 'filename' => ['type' => 'string'], 'categories_id' => ['type' => 'integer'], ]); $this->cli->info('Created releases_rt index'); } elseif ($index === 'predb_rt') { $this->manticoreSearch->table($index)->create([ 'title' => ['type' => 'string'], 'filename' => ['type' => 'string'], 'source' => ['type' => 'string'], ]); $this->cli->info('Created predb_rt index'); } } catch (\Throwable $e) { $this->cli->error('Error creating index '.$index.': '.$e->getMessage()); } } /** * Optimize the RT indices. */ public function optimizeRTIndex(): bool { $success = true; foreach ($this->config['indexes'] as $index) { try { $this->manticoreSearch->table($index)->flush(); $this->manticoreSearch->table($index)->optimize(); Log::info("Successfully optimized index: {$index}"); } catch (ResponseException $e) { Log::error('Failed to optimize index '.$index.': '.$e->getMessage()); $success = false; } catch (\Throwable $e) { Log::error('Unexpected error optimizing index '.$index.': '.$e->getMessage()); $success = false; } } return $success; } public function searchIndexes(string $rt_index, ?string $searchString, array $column = [], array $searchArray = []): array { if (empty($rt_index)) { Log::warning('ManticoreSearch: Index name is required for search'); return []; } // Create cache key for search results $cacheKey = md5(serialize([ 'index' => $rt_index, 'search' => $searchString, 'columns' => $column, 'array' => $searchArray, ])); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } try { $query = $this->search->setTable($rt_index) ->option('ranker', 'sph04') ->maxMatches(10000) ->limit(10000) ->sort('id', 'desc') ->stripBadUtf8(true); if (! empty($searchArray)) { $searchTerms = []; foreach ($searchArray as $key => $value) { if (! empty($value)) { $escapedValue = self::escapeString($value); if (! empty($escapedValue)) { $searchTerms[] = '@@relaxed @'.$key.' '.$escapedValue; } } } if (! empty($searchTerms)) { $query->search(implode(' ', $searchTerms)); } else { return []; } } elseif (! empty($searchString)) { $escapedSearch = self::escapeString($searchString); if (empty($escapedSearch)) { return []; } $searchColumns = ''; if (! empty($column)) { if (count($column) > 1) { $searchColumns = '@('.implode(',', $column).')'; } else { $searchColumns = '@'.$column[0]; } } $query->search('@@relaxed '.$searchColumns.' '.$escapedSearch); } else { return []; } $results = $query->get(); $resultIds = []; $resultData = []; foreach ($results as $doc) { $resultIds[] = $doc->getId(); $resultData[] = $doc->getData(); } $result = [ 'id' => $resultIds, 'data' => $resultData, ]; // Cache results for 5 minutes Cache::put($cacheKey, $result, now()->addMinutes(5)); return $result; } catch (ResponseException $e) { Log::error('ManticoreSearch searchIndexes ResponseException: '.$e->getMessage(), [ 'index' => $rt_index, 'search' => $searchString, ]); return []; } catch (RuntimeException $e) { Log::error('ManticoreSearch searchIndexes RuntimeException: '.$e->getMessage(), [ 'index' => $rt_index, 'search' => $searchString, ]); return []; } catch (\Throwable $e) { Log::error('ManticoreSearch searchIndexes unexpected error: '.$e->getMessage(), [ 'index' => $rt_index, 'search' => $searchString, ]); return []; } } }