mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 00:01:21 +00:00
453 lines
15 KiB
PHP
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;
|
|
}
|
|
}
|