Update manticore indexes

This commit is contained in:
DariusIII
2026-07-15 14:22:31 +02:00
parent a37f3bd3c8
commit 9643d4dd0b
10 changed files with 513 additions and 349 deletions
+26 -204
View File
@@ -4,263 +4,85 @@ declare(strict_types=1);
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Enums\SecondarySearchIndex;
use App\Services\Search\Support\ManticoreClientFactory; use App\Services\Search\Support\ManticoreClientFactory;
use App\Services\Search\Support\ManticoreIndexRegistry;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Manticoresearch\Client; use Manticoresearch\Client;
use Manticoresearch\Exceptions\ResponseException; use Manticoresearch\Exceptions\ResponseException;
class CreateManticoreIndexes extends Command class CreateManticoreIndexes extends Command
{ {
/** protected $signature = 'manticore:create-indexes {--drop : Drop existing indexes before creating}';
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'manticore:create-indexes
{--drop : Drop existing indexes before creating}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create Manticore Search indexes based on configuration'; protected $description = 'Create Manticore Search indexes based on configuration';
protected Client $client; protected Client $client;
/**
* Execute the console command.
*/
public function handle(): int public function handle(): int
{ {
$this->info('Creating Manticore Search indexes...'); $this->info('Creating Manticore Search indexes...');
$this->client = ManticoreClientFactory::make(config('search.drivers.manticore', []));
$dropExisting = $this->option('drop');
$this->client = ManticoreClientFactory::make(config('search.drivers.manticore', config('manticoresearch', [])));
// We'll skip checking for data_dir this way since it may not be accessible via API
// but instead provide better error handling during index creation
// If you encounter data_dir errors, ensure it's properly set in config/manticore.conf:
// data_dir = /path/to/data
// And make sure the path exists and has proper permissions
try { try {
$this->client->nodes()->status(); $this->client->nodes()->status();
} catch (\Exception $e) { } catch (\Throwable $e) {
$this->error('Failed to connect to Manticore Search: '.$e->getMessage()); $this->error('Failed to connect to Manticore Search: '.$e->getMessage());
$this->info('Please check if Manticore Search is running and properly configured.'); $this->info('Check the configured host, authentication, and whether Manticore is running.');
return 1; return self::FAILURE;
} }
// Define indexes and their schema $configuredNames = config('search.drivers.manticore.indexes', []);
$indexes = [
'releases_rt' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => [
'name' => ['type' => 'text'],
'searchname' => ['type' => 'text'],
'fromname' => ['type' => 'text'],
'filename' => ['type' => 'text'],
'categories_id' => ['type' => 'integer'],
// External media IDs for efficient searching
'imdbid' => ['type' => 'string'],
'tmdbid' => ['type' => 'integer'],
'traktid' => ['type' => 'integer'],
'tvdb' => ['type' => 'integer'],
'tvmaze' => ['type' => 'integer'],
'tvrage' => ['type' => 'integer'],
'videos_id' => ['type' => 'integer'],
'movieinfo_id' => ['type' => 'integer'],
'size' => ['type' => 'bigint'],
'postdate_ts' => ['type' => 'bigint'],
'adddate_ts' => ['type' => 'bigint'],
'totalpart' => ['type' => 'integer'],
'grabs' => ['type' => 'integer'],
'passwordstatus' => ['type' => 'bigint'],
'groups_id' => ['type' => 'integer'],
'nzbstatus' => ['type' => 'integer'],
'haspreview' => ['type' => 'bigint'],
],
],
'predb_rt' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => [
'title' => ['type' => 'text', 'attribute' => true],
'filename' => ['type' => 'text', 'attribute' => true],
'source' => ['type' => 'string', 'attribute' => true],
],
],
'movies_rt' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => [
'imdbid' => ['type' => 'string'],
'tmdbid' => ['type' => 'integer'],
'traktid' => ['type' => 'integer'],
'title' => ['type' => 'text'],
'year' => ['type' => 'text'],
'genre' => ['type' => 'text'],
'actors' => ['type' => 'text'],
'director' => ['type' => 'text'],
'rating' => ['type' => 'text'],
'plot' => ['type' => 'text'],
],
],
'tvshows_rt' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => [
'title' => ['type' => 'text'],
'tvdb' => ['type' => 'integer'],
'trakt' => ['type' => 'integer'],
'tvmaze' => ['type' => 'integer'],
'tvrage' => ['type' => 'integer'],
'imdb' => ['type' => 'string'],
'tmdb' => ['type' => 'integer'],
'started' => ['type' => 'text'],
'type' => ['type' => 'integer'],
],
],
'music_rt' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => SecondarySearchIndex::Music->manticoreColumns(),
],
'books_rt' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => SecondarySearchIndex::Books->manticoreColumns(),
],
'games_rt' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => SecondarySearchIndex::Games->manticoreColumns(),
],
'console_rt' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => SecondarySearchIndex::Console->manticoreColumns(),
],
'steam_rt' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => SecondarySearchIndex::Steam->manticoreColumns(),
],
'anime_rt' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => SecondarySearchIndex::Anime->manticoreColumns(),
],
];
$hasErrors = false; $hasErrors = false;
foreach (ManticoreIndexRegistry::definitions() as $logical => $schema) {
// Create each index $indexName = (string) ($configuredNames[$logical] ?? $logical.'_rt');
foreach ($indexes as $indexName => $schema) { if (! $this->createIndex($indexName, $schema, (bool) $this->option('drop'))) {
if (! $this->createIndex($indexName, $schema, $dropExisting)) {
$hasErrors = true; $hasErrors = true;
} }
} }
if ($hasErrors) { if ($hasErrors) {
$this->error('Some errors occurred during index creation.'); $this->error('Some Manticore tables could not be created.');
return 1; return self::FAILURE;
} }
$this->info('All Manticore Search indexes created successfully!'); $this->info('All Manticore Search tables are ready.');
return 0; return self::SUCCESS;
} }
/** /** @param array<string, mixed> $schema */
* Create a single index with error handling.
*
* @param array<string, mixed> $schema
*/
protected function createIndex(string $indexName, array $schema, bool $dropExisting): bool protected function createIndex(string $indexName, array $schema, bool $dropExisting): bool
{ {
$this->info("Creating {$indexName} index..."); $tables = $this->client->tables();
$indices = $this->client->tables();
try { try {
// Optionally drop existing index
if ($dropExisting) { if ($dropExisting) {
try { $this->info("Dropping {$indexName}...");
$this->info("Dropping existing {$indexName} index..."); $tables->drop(['index' => $indexName, 'body' => ['silent' => true]]);
$indices->drop(['index' => $indexName, 'body' => ['silent' => true]]);
$this->info("Successfully dropped {$indexName} index.");
} catch (ResponseException $e) {
if (! str_contains($e->getMessage(), 'unknown index')) {
$this->warn("Warning when dropping {$indexName} index: ".$e->getMessage());
}
}
} }
// Instead of checking if index exists (which doesn't work), $tables->create(['index' => $indexName, 'body' => $schema]);
// try to create it directly and handle any errors $this->info("Created {$indexName}.");
// that might occur if it already exists
$response = $indices->create([
'index' => $indexName,
'body' => $schema,
]);
$this->info("Successfully created {$indexName} index.");
$this->line('Response: '.json_encode($response, JSON_PRETTY_PRINT));
return true; return true;
} catch (ResponseException $e) { } catch (ResponseException $e) {
// Check if the error is because the index already exists
if (str_contains($e->getMessage(), 'already exists')) { if (str_contains($e->getMessage(), 'already exists')) {
$this->warn("Index {$indexName} already exists. Use --drop option to recreate it."); $this->warn("{$indexName} already exists; use --drop during a maintenance rebuild.");
return true; return true;
} }
// Check for data_dir configuration error
if (str_contains($e->getMessage(), 'data_dir')) { if (str_contains($e->getMessage(), 'data_dir')) {
$this->error("Failed to create {$indexName} index: ".$e->getMessage()); $this->error("Failed to create {$indexName}: configure a writable Manticore data_dir.");
$this->newLine(); } else {
$this->warn('ManticoreSearch requires data_dir to be set in its configuration file.'); $this->error("Failed to create {$indexName}: ".$e->getMessage());
$this->info('To fix this, edit your ManticoreSearch config file (usually /etc/manticoresearch/manticore.conf):');
$this->line(' 1. Add or uncomment: data_dir = /var/lib/manticore');
$this->line(' 2. Ensure the directory exists and is writable by the manticore user');
$this->line(' 3. Restart ManticoreSearch: sudo systemctl restart manticore');
return false;
} }
$this->error("Failed to create {$indexName} index: ".$e->getMessage());
return false; return false;
} catch (\Exception $e) { } catch (\Throwable $e) {
$this->error("Failed to create {$indexName} index: ".$e->getMessage()); $this->error("Failed to create {$indexName}: ".$e->getMessage());
return false; return false;
} }
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Search\Support\ManticoreClientFactory;
use App\Services\Search\Support\ManticoreIndexRegistry;
use App\Services\Search\Support\ManticoreSchemaInspector;
use Illuminate\Console\Command;
final class InspectManticore extends Command
{
protected $signature = 'manticore:inspect {--json : Emit machine-readable JSON}';
protected $description = 'Report Manticore version and table schema compatibility';
public function handle(): int
{
try {
$client = ManticoreClientFactory::make(config('search.drivers.manticore', []));
$versionResponse = $client->sql('SELECT VERSION() AS version', true);
} catch (\Throwable $e) {
$this->error('Unable to connect to Manticore: '.$e->getMessage());
return self::FAILURE;
}
$report = ['version' => self::extractVersion($versionResponse), 'compatible' => true, 'tables' => []];
$configured = config('search.drivers.manticore.indexes', []);
foreach (ManticoreIndexRegistry::definitions() as $logical => $definition) {
$table = (string) ($configured[$logical] ?? $logical.'_rt');
try {
if (preg_match('/^[a-zA-Z0-9_]+$/', $table) !== 1) {
throw new \RuntimeException('Unsafe configured table name');
}
$actual = $client->table($table)->describe();
$comparison = ManticoreSchemaInspector::compareColumns(is_array($actual) ? $actual : [], $definition['columns']);
$actualSettings = $client->sql("SHOW TABLE {$table} SETTINGS", true);
$missingSettings = ManticoreSchemaInspector::missingSettings($actualSettings, $definition['settings']);
$needsRebuild = $comparison['missing'] !== [] || $comparison['incompatible'] !== [] || $missingSettings !== [];
$report['tables'][$table] = [...$comparison, 'missing_settings' => $missingSettings, 'needs_rebuild' => $needsRebuild];
$report['compatible'] = $report['compatible'] && ! $needsRebuild;
} catch (\Throwable $e) {
$report['tables'][$table] = ['missing_table' => true, 'needs_rebuild' => true, 'error' => $e->getMessage()];
$report['compatible'] = false;
}
}
if ($this->option('json')) {
$this->line((string) json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} else {
$this->info('Manticore version: '.$report['version']);
foreach ($report['tables'] as $table => $status) {
$this->line(sprintf('%-20s %s', $table, $status['needs_rebuild'] ? 'REBUILD REQUIRED' : 'compatible'));
}
if (! $report['compatible']) {
$this->warn('Run manticore:create-indexes --drop during a maintenance window, then repopulate all enabled indexes.');
}
}
return $report['compatible'] ? self::SUCCESS : self::FAILURE;
}
private static function extractVersion(mixed $response): string
{
$encoded = json_encode($response);
if (is_string($encoded) && preg_match('/\d+\.\d+(?:\.\d+)?/', $encoded, $match) === 1) {
return $match[0];
}
return 'unknown';
}
}
@@ -18,6 +18,7 @@ use App\Services\Search\Contracts\SearchDriverInterface;
use App\Services\Search\DTO\ReleaseSearchQuery; use App\Services\Search\DTO\ReleaseSearchQuery;
use App\Services\Search\DTO\SearchPage; use App\Services\Search\DTO\SearchPage;
use App\Services\Search\Support\ManticoreClientFactory; use App\Services\Search\Support\ManticoreClientFactory;
use App\Services\Search\Support\ManticoreIndexRegistry;
use App\Support\ReleaseSearchIndexDocument; use App\Support\ReleaseSearchIndexDocument;
use App\Support\SecondaryIndexDocuments; use App\Support\SecondaryIndexDocuments;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -856,123 +857,17 @@ class ManticoreSearchDriver implements SearchDriverInterface
} }
try { try {
$indices = $this->manticoreSearch->tables(); $logical = ManticoreIndexRegistry::logicalName($index, $this->config['indexes'] ?? []);
$definition = $logical === null ? null : (ManticoreIndexRegistry::definitions()[$logical] ?? null);
if ($index === 'releases_rt') { if ($definition === null) {
$indices->create([ cli()->error("No Manticore schema is registered for {$index}");
'index' => $index,
'body' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => [
'name' => ['type' => 'text'],
'searchname' => ['type' => 'text'],
'fromname' => ['type' => 'text'],
'filename' => ['type' => 'text'],
'categories_id' => ['type' => 'integer'],
'imdbid' => ['type' => 'string'],
'tmdbid' => ['type' => 'integer'],
'traktid' => ['type' => 'integer'],
'tvdb' => ['type' => 'integer'],
'tvmaze' => ['type' => 'integer'],
'tvrage' => ['type' => 'integer'],
'videos_id' => ['type' => 'integer'],
'movieinfo_id' => ['type' => 'integer'],
'size' => ['type' => 'bigint'],
'postdate_ts' => ['type' => 'bigint'],
'adddate_ts' => ['type' => 'bigint'],
'totalpart' => ['type' => 'integer'],
'grabs' => ['type' => 'integer'],
'passwordstatus' => ['type' => 'bigint'],
'groups_id' => ['type' => 'integer'],
'nzbstatus' => ['type' => 'integer'],
'haspreview' => ['type' => 'bigint'],
],
],
]);
cli()->info('Created releases_rt index with external ID fields and infix search support');
} elseif ($index === 'predb_rt') {
$indices->create([
'index' => $index,
'body' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => [
'title' => ['type' => 'text'],
'filename' => ['type' => 'text'],
'source' => ['type' => 'text'],
],
],
]);
cli()->info('Created predb_rt index with infix search support');
} elseif ($index === 'movies_rt') {
$indices->create([
'index' => $index,
'body' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => [
'imdbid' => ['type' => 'string'],
'tmdbid' => ['type' => 'integer'],
'traktid' => ['type' => 'integer'],
'title' => ['type' => 'text'],
'year' => ['type' => 'text'],
'genre' => ['type' => 'text'],
'actors' => ['type' => 'text'],
'director' => ['type' => 'text'],
'rating' => ['type' => 'text'],
'plot' => ['type' => 'text'],
],
],
]);
cli()->info('Created movies_rt index with infix search support');
} elseif ($index === 'tvshows_rt') {
$indices->create([
'index' => $index,
'body' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => [
'title' => ['type' => 'text'],
'tvdb' => ['type' => 'integer'],
'trakt' => ['type' => 'integer'],
'tvmaze' => ['type' => 'integer'],
'tvrage' => ['type' => 'integer'],
'imdb' => ['type' => 'string'],
'tmdb' => ['type' => 'integer'],
'started' => ['type' => 'text'],
'type' => ['type' => 'integer'],
],
],
]);
cli()->info('Created tvshows_rt index with infix search support');
} else {
foreach (SecondarySearchIndex::cases() as $secondary) {
if ($index === $this->getSecondaryIndexName($secondary)) {
$indices->create([
'index' => $index,
'body' => [
'settings' => [
'min_prefix_len' => 0,
'min_infix_len' => 2,
],
'columns' => $secondary->manticoreColumns(),
],
]);
cli()->info("Created {$index} secondary metadata index");
break; return;
}
}
} }
$this->manticoreSearch->tables()->create(['index' => $index, 'body' => $definition]);
cli()->info("Created {$index} with the configured Manticore schema");
} catch (\Throwable $e) { } catch (\Throwable $e) {
if (! str_contains($e->getMessage(), 'already exists')) { if (! str_contains($e->getMessage(), 'already exists')) {
cli()->error('Error creating index '.$index.': '.$e->getMessage()); cli()->error('Error creating index '.$index.': '.$e->getMessage());
@@ -1155,11 +1050,14 @@ class ManticoreSearchDriver implements SearchDriverInterface
} }
$normalizedLimit = $this->normalizeSearchLimit($limit); $normalizedLimit = $this->normalizeSearchLimit($limit);
$logical = ManticoreIndexRegistry::logicalName($index, $this->config['indexes'] ?? []);
$profile = ManticoreIndexRegistry::profile($logical ?? '');
$fuzzyConfig = $this->getFuzzyConfig(); $fuzzyConfig = $this->getFuzzyConfig();
$distance = $fuzzyConfig['max_distance'] ?? 2; $distance = min((int) ($fuzzyConfig['max_distance'] ?? 2), $profile['fuzzy_distance']);
// Create cache key for fuzzy search results // Create cache key for fuzzy search results
$cacheKey = 'manticore:fuzzy:'.md5(serialize([ $cacheKey = 'manticore:fuzzy:'.md5(serialize([
'generation' => config('search.index_generation', '1'),
'index' => $index, 'index' => $index,
'array' => $searchArray, 'array' => $searchArray,
'limit' => $normalizedLimit, 'limit' => $normalizedLimit,
@@ -1215,6 +1113,7 @@ class ManticoreSearchDriver implements SearchDriverInterface
->search($searchExpr) ->search($searchExpr)
->option('fuzzy', true) ->option('fuzzy', true)
->option('distance', $distance) ->option('distance', $distance)
->option('field_weights', $profile['fields'])
->limit($normalizedLimit); ->limit($normalizedLimit);
$results = $query->get(); $results = $query->get();
@@ -1308,6 +1207,8 @@ class ManticoreSearchDriver implements SearchDriverInterface
} }
$normalizedLimit = $this->normalizeSearchLimit($limit); $normalizedLimit = $this->normalizeSearchLimit($limit);
$logical = ManticoreIndexRegistry::logicalName($rt_index, $this->config['indexes'] ?? []);
$profile = ManticoreIndexRegistry::profile($logical ?? '');
if (config('app.debug')) { if (config('app.debug')) {
Log::debug('ManticoreSearch::searchIndexes called', [ Log::debug('ManticoreSearch::searchIndexes called', [
@@ -1321,11 +1222,13 @@ class ManticoreSearchDriver implements SearchDriverInterface
// Create cache key for search results // Create cache key for search results
$cacheKey = md5(serialize([ $cacheKey = md5(serialize([
'generation' => config('search.index_generation', '1'),
'index' => $rt_index, 'index' => $rt_index,
'search' => $searchString, 'search' => $searchString,
'columns' => $column, 'columns' => $column,
'array' => $searchArray, 'array' => $searchArray,
'limit' => $normalizedLimit, 'limit' => $normalizedLimit,
'profile' => $profile,
])); ]));
$cached = Cache::get($cacheKey); $cached = Cache::get($cacheKey);
@@ -1400,7 +1303,8 @@ class ManticoreSearchDriver implements SearchDriverInterface
// Use a fresh Search instance for every query to avoid parameter accumulation across calls // Use a fresh Search instance for every query to avoid parameter accumulation across calls
$query = (new Search($this->manticoreSearch)) $query = (new Search($this->manticoreSearch))
->setTable($rt_index) ->setTable($rt_index)
->option('ranker', 'sph04') ->option('ranker', $profile['ranker'])
->option('field_weights', $profile['fields'])
->maxMatches($normalizedLimit) ->maxMatches($normalizedLimit)
->limit($normalizedLimit) ->limit($normalizedLimit)
->stripBadUtf8(true) ->stripBadUtf8(true)
@@ -1417,7 +1321,8 @@ class ManticoreSearchDriver implements SearchDriverInterface
try { try {
$query = (new Search($this->manticoreSearch)) $query = (new Search($this->manticoreSearch))
->setTable($rt_index) ->setTable($rt_index)
->option('ranker', 'sph04') ->option('ranker', $profile['ranker'])
->option('field_weights', $profile['fields'])
->maxMatches($normalizedLimit) ->maxMatches($normalizedLimit)
->limit($normalizedLimit) ->limit($normalizedLimit)
->stripBadUtf8(true) ->stripBadUtf8(true)
@@ -1516,7 +1421,12 @@ class ManticoreSearchDriver implements SearchDriverInterface
} }
$index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt'); $index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt');
$cacheKey = 'manticore:autocomplete:'.md5($index.$query); $logical = ManticoreIndexRegistry::logicalName($index, $this->config['indexes'] ?? []);
$fields = ManticoreIndexRegistry::autocompleteFields($logical ?? '');
if ($fields === []) {
return [];
}
$cacheKey = 'manticore:autocomplete:'.config('search.index_generation', '1').':'.md5($index.$query);
$cached = Cache::get($cacheKey); $cached = Cache::get($cacheKey);
if ($cached !== null) { if ($cached !== null) {
@@ -1533,8 +1443,7 @@ class ManticoreSearchDriver implements SearchDriverInterface
return []; return [];
} }
// Use relaxed search on searchname field $searchExpr = '@@relaxed @('.implode(',', $fields).') '.$escapedQuery.'*';
$searchExpr = '@@relaxed @searchname '.$escapedQuery;
$search = (new Search($this->manticoreSearch)) $search = (new Search($this->manticoreSearch))
->setTable($index) ->setTable($index)
@@ -1545,10 +1454,16 @@ class ManticoreSearchDriver implements SearchDriverInterface
$results = $search->get(); $results = $search->get();
$seen = []; $ranked = [];
foreach ($results as $doc) { foreach ($results as $doc) {
$data = $doc->getData(); $data = $doc->getData();
$searchname = $data['searchname'] ?? ''; $searchname = '';
foreach ($fields as $field) {
if (! empty($data[$field])) {
$searchname = (string) $data[$field];
break;
}
}
if (empty($searchname)) { if (empty($searchname)) {
continue; continue;
@@ -1556,20 +1471,29 @@ class ManticoreSearchDriver implements SearchDriverInterface
// Create a clean suggestion from the searchname // Create a clean suggestion from the searchname
$suggestion = $this->extractSuggestion($searchname, $query); $suggestion = $this->extractSuggestion($searchname, $query);
if ($suggestion === null || $suggestion === '') {
if (! empty($suggestion) && ! isset($seen[strtolower($suggestion)])) { continue;
$seen[strtolower($suggestion)] = true;
$suggestions[] = [
'suggest' => $suggestion,
'distance' => 0,
'docs' => 1,
];
} }
if (count($suggestions) >= $maxResults) { $normalized = mb_strtolower(trim((string) preg_replace('/\s+/', ' ', $suggestion)));
break; if ($normalized !== '') {
if (! isset($ranked[$normalized])) {
$ranked[$normalized] = [
'suggest' => $suggestion,
'distance' => 0,
'docs' => 0,
'prefix' => str_starts_with($normalized, mb_strtolower($query)) ? 1 : 0,
];
}
$ranked[$normalized]['docs']++;
} }
} }
uasort($ranked, static fn (array $a, array $b): int => [$b['prefix'], $b['docs']] <=> [$a['prefix'], $a['docs']]);
foreach (array_slice($ranked, 0, $maxResults) as $item) {
unset($item['prefix']);
$suggestions[] = $item;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
if (config('app.debug')) { if (config('app.debug')) {
Log::warning('ManticoreSearch autocomplete error: '.$e->getMessage()); Log::warning('ManticoreSearch autocomplete error: '.$e->getMessage());
@@ -1594,13 +1518,13 @@ class ManticoreSearchDriver implements SearchDriverInterface
private function extractSuggestion(string $searchname, string $query): ?string private function extractSuggestion(string $searchname, string $query): ?string
{ {
// Clean up the searchname - remove file extensions, quality tags at the end // Clean up the searchname - remove file extensions, quality tags at the end
$clean = preg_replace('/\.(mkv|avi|mp4|wmv|nfo|nzb|par2|rar|zip|r\d+)$/i', '', $searchname); $clean = (string) preg_replace('/\.(mkv|avi|mp4|wmv|nfo|nzb|par2|rar|zip|r\d+)$/i', '', $searchname);
// Replace dots and underscores with spaces for readability // Replace dots and underscores with spaces for readability
$clean = str_replace(['.', '_'], ' ', $clean); $clean = str_replace(['.', '_'], ' ', $clean);
// Remove multiple spaces // Remove multiple spaces
$clean = preg_replace('/\s+/', ' ', $clean); $clean = (string) preg_replace('/\s+/', ' ', $clean);
$clean = trim($clean); $clean = trim($clean);
if (empty($clean)) { if (empty($clean)) {
@@ -1622,9 +1546,9 @@ class ManticoreSearchDriver implements SearchDriverInterface
// Clean up - don't cut mid-word // Clean up - don't cut mid-word
if ($start > 0) { if ($start > 0) {
$extracted = preg_replace('/^\S*\s/', '', $extracted); $extracted = (string) preg_replace('/^\S*\s/', '', $extracted);
} }
$extracted = preg_replace('/\s\S*$/', '', $extracted); $extracted = (string) preg_replace('/\s\S*$/', '', $extracted);
return trim($extracted); return trim($extracted);
} }
@@ -1664,7 +1588,7 @@ class ManticoreSearchDriver implements SearchDriverInterface
} }
$index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt'); $index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt');
$cacheKey = 'manticore:suggest:'.md5($index.$query); $cacheKey = 'manticore:suggest:'.config('search.index_generation', '1').':'.md5($index.$query);
$cached = Cache::get($cacheKey); $cached = Cache::get($cacheKey);
if ($cached !== null) { if ($cached !== null) {
@@ -1730,8 +1654,12 @@ class ManticoreSearchDriver implements SearchDriverInterface
return []; return [];
} }
// Use relaxed search to find partial matches $logical = ManticoreIndexRegistry::logicalName($index, $this->config['indexes'] ?? []);
$searchExpr = '@@relaxed @searchname '.$escapedQuery; $fields = ManticoreIndexRegistry::autocompleteFields($logical ?? '');
if ($fields === []) {
return [];
}
$searchExpr = '@@relaxed @('.implode(',', $fields).') '.$escapedQuery;
$search = (new Search($this->manticoreSearch)) $search = (new Search($this->manticoreSearch))
->setTable($index) ->setTable($index)
@@ -1745,10 +1673,16 @@ class ManticoreSearchDriver implements SearchDriverInterface
$termCounts = []; $termCounts = [];
foreach ($results as $doc) { foreach ($results as $doc) {
$data = $doc->getData(); $data = $doc->getData();
$searchname = $data['searchname'] ?? ''; $searchname = '';
foreach ($fields as $field) {
if (! empty($data[$field])) {
$searchname = (string) $data[$field];
break;
}
}
// Extract words from searchname // Extract words from searchname
$words = preg_split('/[\s.\-_]+/', strtolower($searchname)); $words = preg_split('/[\s.\-_]+/', strtolower($searchname)) ?: [];
foreach ($words as $word) { foreach ($words as $word) {
if (strlen($word) >= 3 && $word !== strtolower($query)) { if (strlen($word) >= 3 && $word !== strtolower($query)) {
// Check if word is similar to query (within edit distance) // Check if word is similar to query (within edit distance)
@@ -2524,9 +2458,15 @@ class ManticoreSearchDriver implements SearchDriverInterface
if ($searchString === '') { if ($searchString === '') {
return ['ids' => [], 'total' => 0, 'fuzzy' => false]; return ['ids' => [], 'total' => 0, 'fuzzy' => false];
} }
$profile = ManticoreIndexRegistry::profile('releases');
$distance = min(
(int) ($this->getFuzzyConfig()['max_distance'] ?? 2),
$profile['fuzzy_distance']
);
$query->search($searchString) $query->search($searchString)
->option('fuzzy', true) ->option('fuzzy', true)
->option('distance', (int) ($this->getFuzzyConfig()['max_distance'] ?? 2)); ->option('distance', $distance)
->option('field_weights', $profile['fields']);
} else { } else {
$searchArray = $this->phrasesToSearchArray($phrases); $searchArray = $this->phrasesToSearchArray($phrases);
$terms = []; $terms = [];
@@ -2542,7 +2482,10 @@ class ManticoreSearchDriver implements SearchDriverInterface
if ($terms === []) { if ($terms === []) {
return ['ids' => [], 'total' => 0, 'fuzzy' => false]; return ['ids' => [], 'total' => 0, 'fuzzy' => false];
} }
$query->search(implode(' ', $terms))->option('ranker', 'sph04'); $profile = ManticoreIndexRegistry::profile('releases');
$query->search(implode(' ', $terms))
->option('ranker', $profile['ranker'])
->option('field_weights', $profile['fields']);
} }
} }
@@ -2609,6 +2552,7 @@ class ManticoreSearchDriver implements SearchDriverInterface
/** /**
* @param array<string, mixed>|string $phrases * @param array<string, mixed>|string $phrases
* @return array<string, mixed>
*/ */
private function phrasesToSearchArray(array|string $phrases): array private function phrasesToSearchArray(array|string $phrases): array
{ {
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Services\Search\Support;
use App\Enums\SecondarySearchIndex;
final class ManticoreIndexRegistry
{
/**
* @return array<string, array{settings: array<string, int|string>, columns: array<string, array<string, mixed>>}>
*/
public static function definitions(): array
{
$settings = self::fullTextSettings();
return [
'releases' => ['settings' => $settings, 'columns' => [
'name' => ['type' => 'text'], 'searchname' => ['type' => 'text'],
'fromname' => ['type' => 'text'], 'filename' => ['type' => 'text'],
'categories_id' => ['type' => 'integer'], 'imdbid' => ['type' => 'string'],
'tmdbid' => ['type' => 'integer'], 'traktid' => ['type' => 'integer'],
'tvdb' => ['type' => 'integer'], 'tvmaze' => ['type' => 'integer'],
'tvrage' => ['type' => 'integer'], 'videos_id' => ['type' => 'integer'],
'movieinfo_id' => ['type' => 'integer'], 'size' => ['type' => 'bigint'],
'postdate_ts' => ['type' => 'bigint'], 'adddate_ts' => ['type' => 'bigint'],
'totalpart' => ['type' => 'integer'], 'grabs' => ['type' => 'integer'],
'passwordstatus' => ['type' => 'bigint'], 'groups_id' => ['type' => 'integer'],
'nzbstatus' => ['type' => 'integer'], 'haspreview' => ['type' => 'bigint'],
]],
'predb' => ['settings' => $settings, 'columns' => [
'title' => ['type' => 'text', 'attribute' => true],
'filename' => ['type' => 'text', 'attribute' => true],
'source' => ['type' => 'string', 'attribute' => true],
]],
'movies' => ['settings' => $settings, 'columns' => [
'imdbid' => ['type' => 'string'], 'tmdbid' => ['type' => 'integer'],
'traktid' => ['type' => 'integer'], 'title' => ['type' => 'text'],
'year' => ['type' => 'text'], 'genre' => ['type' => 'text'],
'actors' => ['type' => 'text'], 'director' => ['type' => 'text'],
'rating' => ['type' => 'text'], 'plot' => ['type' => 'text'],
]],
'tvshows' => ['settings' => $settings, 'columns' => [
'title' => ['type' => 'text'], 'tvdb' => ['type' => 'integer'],
'trakt' => ['type' => 'integer'], 'tvmaze' => ['type' => 'integer'],
'tvrage' => ['type' => 'integer'], 'imdb' => ['type' => 'string'],
'tmdb' => ['type' => 'integer'], 'started' => ['type' => 'text'],
'type' => ['type' => 'integer'],
]],
...self::secondaryDefinitions($settings),
];
}
/** @return array{ranker: string, fields: array<string, int>, fuzzy_distance: int} */
public static function profile(string $logical): array
{
$fields = match ($logical) {
'releases' => ['searchname' => 12, 'name' => 8, 'filename' => 5, 'fromname' => 1],
'predb' => ['title' => 12, 'filename' => 5],
'movies' => ['title' => 12, 'director' => 5, 'actors' => 3, 'genre' => 2, 'plot' => 1],
'tvshows' => ['title' => 12],
'music' => ['title' => 12, 'artist' => 9],
'books' => ['title' => 12, 'author' => 9],
'console' => ['title' => 12],
'steam' => ['name' => 12],
'games', 'anime' => ['title' => 12],
default => [],
};
return ['ranker' => 'sph04', 'fields' => $fields, 'fuzzy_distance' => 2];
}
/** @return list<string> */
public static function autocompleteFields(string $logical): array
{
return match ($logical) {
'releases' => ['searchname', 'name'],
'predb' => ['title', 'filename'],
'movies', 'tvshows', 'music', 'books', 'games', 'console', 'anime' => ['title'],
'steam' => ['name'],
default => [],
};
}
/** @param array<string, string> $configuredIndexes */
public static function logicalName(string $table, array $configuredIndexes = []): ?string
{
foreach ($configuredIndexes as $logical => $configuredTable) {
if ($configuredTable === $table) {
return (string) $logical;
}
}
return str_ends_with($table, '_rt') ? array_search($table, self::defaultTableNames(), true) ?: null : null;
}
/** @return array<string, string> */
public static function defaultTableNames(): array
{
$names = [];
foreach (array_keys(self::definitions()) as $logical) {
$names[$logical] = $logical.'_rt';
}
return $names;
}
/** @return array<string, int|string> */
private static function fullTextSettings(): array
{
return [
'min_prefix_len' => 0,
'min_infix_len' => 2,
'exact_words' => 1,
'index_field_lengths' => 1,
];
}
/**
* @param array<string, int|string> $settings
* @return array<string, array{settings: array<string, int|string>, columns: array<string, array<string, mixed>>}>
*/
private static function secondaryDefinitions(array $settings): array
{
$definitions = [];
foreach (SecondarySearchIndex::cases() as $index) {
$definitions[$index->value] = ['settings' => $settings, 'columns' => $index->manticoreColumns()];
}
return $definitions;
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Services\Search\Support;
final class ManticoreSchemaInspector
{
/**
* @param array<string, mixed> $expected
* @return list<string>
*/
public static function missingSettings(mixed $actual, array $expected): array
{
$encoded = strtolower((string) json_encode($actual));
return array_values(array_filter(array_keys($expected), static function (string $setting) use ($encoded, $expected): bool {
return ! str_contains($encoded, strtolower($setting))
|| ! str_contains($encoded, strtolower((string) $expected[$setting]));
}));
}
/**
* @param array<int, array<string, mixed>> $actual
* @param array<string, array<string, mixed>> $expected
* @return array{missing: list<string>, incompatible: array<string, array{expected: string, actual: string}>}
*/
public static function compareColumns(array $actual, array $expected): array
{
$types = [];
foreach ($actual as $column) {
$name = (string) ($column['Field'] ?? $column['field'] ?? '');
$type = strtolower((string) ($column['Type'] ?? $column['type'] ?? ''));
if ($name !== '') {
$types[$name] = $type;
}
}
$missing = [];
$incompatible = [];
foreach ($expected as $name => $definition) {
$expectedType = strtolower((string) $definition['type']);
if (! isset($types[$name])) {
$missing[] = $name;
} elseif (! str_contains($types[$name], $expectedType)) {
$incompatible[$name] = ['expected' => $expectedType, 'actual' => $types[$name]];
}
}
return ['missing' => $missing, 'incompatible' => $incompatible];
}
}
+2 -2
View File
@@ -111,7 +111,7 @@ services:
retries: 3 retries: 3
timeout: 5s timeout: 5s
manticore: manticore:
image: manticoresearch/manticore image: manticoresearch/manticore:28.4.4
profiles: profiles:
- manticore - manticore
environment: environment:
@@ -130,7 +130,7 @@ services:
soft: -1 soft: -1
hard: -1 hard: -1
volumes: volumes:
- 'manticore:/var/lib/manticore' - 'manticore:/var/lib/manticore/data'
- ./config/manticore.conf:/etc/manticoresearch/manticore.conf - ./config/manticore.conf:/etc/manticoresearch/manticore.conf
networks: networks:
- nntmux - nntmux
+21
View File
@@ -0,0 +1,21 @@
# ADR 0001: Manticore lexical search and maintenance rebuilds
## Status
Accepted
## Context
NNTmux searches release and metadata tables through Manticore. Search must remain predictable for Newznab clients, tolerate release-name punctuation and misspellings, and remain operable without embedding services. Existing release browsing is newest-first and callers rely on that ordering.
## Decision
- Pin Manticore Search 28.4.4 and keep the official PHP client at 4.0.1.
- Use one table registry for schemas and per-table lexical field weights.
- Retain `sph04`, quoted phrases, exclusions, exact-word indexing, and exact-first/fuzzy-second retrieval.
- Keep release results ordered by the requested attribute, defaulting to `postdate_ts DESC, id DESC`; lexical score controls matching rather than presentation order.
- Rebuild incompatible tables in a maintenance window. Semantic, conversational, vector, and external-embedding search are deferred.
## Consequences
The deployment stays lightweight and backward compatible, and relevance changes can be measured with a stable fixture. Ranking cannot rescue an older result from date-first presentation. Settings that affect indexed tokens require a complete repopulation and a backup-based rollback.
+25
View File
@@ -0,0 +1,25 @@
# Manticore Search 28.4.4 maintenance upgrade
This upgrade rebuilds every configured Manticore table. Do not reuse data files written by 28.4.4 with an older daemon.
## Before maintenance
1. Set `SEARCH_INDEX_GENERATION` to a new value so cached results and cursors cannot cross the cutover.
2. Run `php artisan manticore:inspect --json` and save its output.
3. Stop the processing engine with `php artisan tmux:stop` and pause queue workers that update search documents.
4. Back up `/var/lib/manticore/data` with the container stopped, or use the supported Manticore backup tooling. Record the old image tag with the backup.
## Upgrade and repopulate
1. Pull and start `manticoresearch/manticore:28.4.4`.
2. Confirm HTTP connectivity and optional username/password or bearer-token authentication with `php artisan manticore:inspect`.
3. Run `php artisan manticore:create-indexes --drop`.
4. Run `php artisan nntmux:populate --manticore --all` (or explicitly populate every enabled releases, PreDB, movie, TV, and secondary metadata table).
5. Run `php artisan manticore:inspect`, index reconciliation, and representative searches from `tests/Fixtures/Search/manticore-relevance-v1.json`.
6. Resume queue workers and run `php artisan tmux:start` only after all tables report compatible.
## Rollback
Stop Manticore, restore the pre-upgrade data backup, and start the exact image version recorded with that backup. Do not point the older image at files modified by 28.4.4. Restore the previous `SEARCH_INDEX_GENERATION` only if application code was also rolled back.
Local Docker may remain anonymous. Secured deployments use `MANTICORESEARCH_USERNAME` and `MANTICORESEARCH_PASSWORD`, or `MANTICORESEARCH_TOKEN`; never store credentials in Compose files.
@@ -0,0 +1,24 @@
{
"version": 1,
"thresholds": {
"top_5_precision": 0.8,
"top_10_precision": 0.8,
"max_zero_result_rate": 0.05,
"max_fuzzy_fallback_rate": 0.25,
"max_p95_latency_ms": 250
},
"queries": [
{"index": "releases", "query": "The.Matrix.1999.1080p", "expected_terms": ["matrix", "1999"], "fuzzy": false},
{"index": "releases", "query": "\"breaking bad\" -german", "expected_terms": ["breaking bad"], "excluded_terms": ["german"], "fuzzy": false},
{"index": "releases", "query": "Oppenhiemer 2023", "expected_terms": ["oppenheimer", "2023"], "fuzzy": true},
{"index": "predb", "query": "Ubuntu.24.04-LTS", "expected_terms": ["ubuntu", "24.04"], "fuzzy": false},
{"index": "movies", "query": "Blade Runner 2049", "expected_terms": ["blade runner", "2049"], "fuzzy": false},
{"index": "tvshows", "query": "Better Call Saul", "expected_terms": ["better call saul"], "fuzzy": false},
{"index": "music", "query": "Daft Punk Discovery", "expected_terms": ["daft punk", "discovery"], "fuzzy": false},
{"index": "books", "query": "Frank Herbert Dune", "expected_terms": ["frank herbert", "dune"], "fuzzy": false},
{"index": "games", "query": "Baldur's Gate 3", "expected_terms": ["baldur", "gate"], "fuzzy": false},
{"index": "console", "query": "Zelda Tears of the Kingdom", "expected_terms": ["zelda", "kingdom"], "fuzzy": false},
{"index": "steam", "query": "Cyberpunk 2077", "expected_terms": ["cyberpunk", "2077"], "fuzzy": false},
{"index": "anime", "query": "Fullmetal Alchemist Brotherhood", "expected_terms": ["fullmetal alchemist"], "fuzzy": false}
]
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Services\Search\Support\ManticoreIndexRegistry;
use App\Services\Search\Support\ManticoreSchemaInspector;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ManticoreIndexRegistryTest extends TestCase
{
#[Test]
public function it_defines_every_search_table_with_relevance_settings(): void
{
$definitions = ManticoreIndexRegistry::definitions();
self::assertSame(
['releases', 'predb', 'movies', 'tvshows', 'music', 'books', 'games', 'console', 'steam', 'anime'],
array_keys($definitions)
);
foreach ($definitions as $definition) {
self::assertSame(2, $definition['settings']['min_infix_len']);
self::assertSame(1, $definition['settings']['exact_words']);
self::assertSame(1, $definition['settings']['index_field_lengths']);
}
self::assertSame('bigint', $definitions['releases']['columns']['passwordstatus']['type']);
self::assertSame('bigint', $definitions['releases']['columns']['haspreview']['type']);
}
#[Test]
public function it_provides_title_first_field_weights(): void
{
self::assertGreaterThan(
ManticoreIndexRegistry::profile('movies')['fields']['plot'],
ManticoreIndexRegistry::profile('movies')['fields']['title']
);
self::assertGreaterThan(
ManticoreIndexRegistry::profile('releases')['fields']['fromname'],
ManticoreIndexRegistry::profile('releases')['fields']['searchname']
);
}
#[Test]
public function it_reports_schema_drift_that_requires_a_rebuild(): void
{
$result = ManticoreSchemaInspector::compareColumns(
[['Field' => 'passwordstatus', 'Type' => 'uint']],
['passwordstatus' => ['type' => 'bigint'], 'haspreview' => ['type' => 'bigint']]
);
self::assertSame(['haspreview'], $result['missing']);
self::assertSame('bigint', $result['incompatible']['passwordstatus']['expected']);
}
#[Test]
public function benchmark_fixture_is_versioned_and_covers_every_table(): void
{
$fixture = json_decode((string) file_get_contents(__DIR__.'/../Fixtures/Search/manticore-relevance-v1.json'), true, flags: JSON_THROW_ON_ERROR);
self::assertSame(1, $fixture['version']);
self::assertSame(array_keys(ManticoreIndexRegistry::definitions()), array_values(array_unique(array_column($fixture['queries'], 'index'))));
self::assertArrayHasKey('top_5_precision', $fixture['thresholds']);
}
}