Files
newznab-tmux/Blacklight/ManticoreSearch.php
T
2025-11-12 11:43:44 +01:00

453 lines
15 KiB
PHP

<?php
namespace Blacklight;
use App\Models\Release;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Manticoresearch\Client;
use Manticoresearch\Exceptions\ResponseException;
use Manticoresearch\Exceptions\RuntimeException;
use Manticoresearch\Search;
/**
* Class ManticoreSearch.
*/
class ManticoreSearch
{
protected mixed $config;
protected array $connection;
public Client $manticoreSearch;
public Search $search;
private ColorCLI $cli;
/**
* Establishes a connection to ManticoreSearch HTTP port.
*/
public function __construct()
{
$this->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;
}
// Build query string once so we can retry if needed
$searchExpr = null;
if (! empty($searchArray)) {
$terms = [];
foreach ($searchArray as $key => $value) {
if (! empty($value)) {
$escapedValue = self::escapeString($value);
if (! empty($escapedValue)) {
$terms[] = '@@relaxed @'.$key.' '.$escapedValue;
}
}
}
if (! empty($terms)) {
$searchExpr = implode(' ', $terms);
} 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];
}
}
$searchExpr = '@@relaxed '.$searchColumns.' '.$escapedSearch;
} else {
return [];
}
// Avoid explicit sort for predb_rt to prevent Manticore's "too many sort-by attributes" error
$avoidSortForIndex = ($rt_index === 'predb_rt');
try {
// Use a fresh Search instance for every query to avoid parameter accumulation across calls
$query = (new Search($this->manticoreSearch))
->setTable($rt_index)
->option('ranker', 'sph04')
->maxMatches(10000)
->limit(10000)
->stripBadUtf8(true)
->search($searchExpr);
if (! $avoidSortForIndex) {
$query->sort('id', 'desc');
}
$results = $query->get();
} catch (ResponseException $e) {
// If we hit Manticore's "too many sort-by attributes" limit, retry once without explicit sorting
if (stripos($e->getMessage(), 'too many sort-by attributes') !== false) {
try {
$query = (new Search($this->manticoreSearch))
->setTable($rt_index)
->option('ranker', 'sph04')
->maxMatches(10000)
->limit(10000)
->stripBadUtf8(true)
->search($searchExpr);
$results = $query->get();
Log::warning('ManticoreSearch: Retried search without sorting due to sort-by attributes limit', [
'index' => $rt_index,
]);
} catch (ResponseException $e2) {
Log::error('ManticoreSearch searchIndexes ResponseException after retry: '.$e2->getMessage(), [
'index' => $rt_index,
'search' => $searchString,
]);
return [];
}
} else {
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 [];
}
// Parse results and cache
$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;
}
}