mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update manticore search usage
This commit is contained in:
@@ -30,6 +30,8 @@ MANTICORESEARCH_FUZZY_ENABLED=false
|
||||
# For high availability, set multiple hosts (comma-separated): "host1:9308,host2:9308"
|
||||
MANTICORESEARCH_HOSTS=
|
||||
MANTICORESEARCH_RETRIES=2
|
||||
MANTICORESEARCH_RETRY_ATTEMPTS=2
|
||||
MANTICORESEARCH_RETRY_DELAY_MS=100
|
||||
# Autocomplete settings
|
||||
MANTICORESEARCH_AUTOCOMPLETE_ENABLED=true
|
||||
MANTICORESEARCH_AUTOCOMPLETE_MIN_LENGTH=2
|
||||
|
||||
@@ -12,7 +12,9 @@ use Manticoresearch\Exceptions\ResponseException;
|
||||
|
||||
class CreateManticoreIndexes extends Command
|
||||
{
|
||||
protected $signature = 'manticore:create-indexes {--drop : Drop existing indexes before creating}';
|
||||
protected $signature = 'manticore:create-indexes
|
||||
{--drop : Drop selected indexes before creating}
|
||||
{--index=* : Logical index name(s) to create; defaults to all indexes}';
|
||||
|
||||
protected $description = 'Create Manticore Search indexes based on configuration';
|
||||
|
||||
@@ -33,8 +35,19 @@ class CreateManticoreIndexes extends Command
|
||||
}
|
||||
|
||||
$configuredNames = config('search.drivers.manticore.indexes', []);
|
||||
$selected = array_values(array_filter(array_map('strval', (array) $this->option('index'))));
|
||||
$definitions = ManticoreIndexRegistry::definitions();
|
||||
if ($selected !== []) {
|
||||
$unknown = array_diff($selected, array_keys($definitions));
|
||||
if ($unknown !== []) {
|
||||
$this->error('Unknown logical index: '.implode(', ', $unknown));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$definitions = array_intersect_key($definitions, array_flip($selected));
|
||||
}
|
||||
$hasErrors = false;
|
||||
foreach (ManticoreIndexRegistry::definitions() as $logical => $schema) {
|
||||
foreach ($definitions as $logical => $schema) {
|
||||
$indexName = (string) ($configuredNames[$logical] ?? $logical.'_rt');
|
||||
if (! $this->createIndex($indexName, $schema, (bool) $this->option('drop'))) {
|
||||
$hasErrors = true;
|
||||
|
||||
@@ -56,7 +56,7 @@ final class InspectManticore extends Command
|
||||
$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.');
|
||||
$this->warn('Run manticore:create-indexes --drop --index=releases during a maintenance window for release-only schema changes, then repopulate the affected index.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Facades\Elasticsearch;
|
||||
use App\Facades\Search;
|
||||
use App\Models\Predb;
|
||||
use App\Models\Release;
|
||||
use App\Services\Search\Drivers\ManticoreSearchDriver;
|
||||
use App\Services\Search\Support\ElasticsearchResponseHelper;
|
||||
use Elastic\Elasticsearch\Client as ElasticsearchClient;
|
||||
use Exception;
|
||||
@@ -115,7 +116,9 @@ class NntmuxOffsetPopulate extends Command
|
||||
$this->monitorProcesses($processes); // @phpstan-ignore argument.type
|
||||
|
||||
// Verify final count
|
||||
$this->verifyIndexPopulation($engine, $index, $total);
|
||||
if (! $this->verifyIndexPopulation($engine, $index, $total)) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('All parallel processes completed successfully!');
|
||||
|
||||
@@ -252,7 +255,7 @@ class NntmuxOffsetPopulate extends Command
|
||||
/**
|
||||
* Verify index population
|
||||
*/
|
||||
private function verifyIndexPopulation(string $engine, string $index, int $expectedTotal): void
|
||||
private function verifyIndexPopulation(string $engine, string $index, int $expectedTotal): bool
|
||||
{
|
||||
$this->info('Verifying index population...');
|
||||
|
||||
@@ -271,12 +274,34 @@ class NntmuxOffsetPopulate extends Command
|
||||
$this->info('✓ Index population successful!');
|
||||
} else {
|
||||
$this->warn('⚠ Index population may be incomplete');
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->warn("Could not verify index population: {$e->getMessage()}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
$this->info('ManticoreSearch index verification not implemented yet');
|
||||
$indexName = (string) config("search.drivers.manticore.indexes.{$index}", $index.'_rt');
|
||||
try {
|
||||
$actualCount = app(ManticoreSearchDriver::class)->countIndexDocuments($indexName);
|
||||
$this->info('Expected: '.number_format($expectedTotal));
|
||||
$this->info('Actual: '.number_format($actualCount));
|
||||
if ($actualCount < $expectedTotal) {
|
||||
$this->warn('Manticore index population is incomplete.');
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->warn("Could not verify Manticore population: {$e->getMessage()}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +311,7 @@ class NntmuxOffsetPopulate extends Command
|
||||
private function clearIndex(string $engine, string $index): void
|
||||
{
|
||||
if ($engine === 'manticore') {
|
||||
$indexName = $index === 'releases' ? 'releases_rt' : 'predb_rt';
|
||||
$indexName = (string) config("search.drivers.manticore.indexes.{$index}", $index.'_rt');
|
||||
Search::truncateIndex([$indexName]);
|
||||
$this->info("Truncated ManticoreSearch index: {$indexName}");
|
||||
} else {
|
||||
|
||||
@@ -7,7 +7,8 @@ namespace App\Console\Commands;
|
||||
use App\Facades\Elasticsearch;
|
||||
use App\Facades\Search;
|
||||
use App\Models\Predb;
|
||||
use App\Models\Release;
|
||||
use App\Services\Search\Support\ReleaseIndexProjection;
|
||||
use App\Support\ReleaseSearchIndexDocument;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -97,7 +98,7 @@ class NntmuxOffsetWorker extends Command
|
||||
if ($engine === 'manticore') {
|
||||
$batchData = [];
|
||||
|
||||
$query->chunk($batchSize, function ($items) use ($transformer, &$processed, &$errors, &$batchData, $batchSize, $workerId) {
|
||||
$query->chunk($batchSize, function ($items) use ($transformer, $index, &$processed, &$errors, &$batchData, $batchSize, $workerId) {
|
||||
$this->info("Worker {$workerId}: Processing chunk of {$items->count()} items");
|
||||
|
||||
foreach ($items as $item) {
|
||||
@@ -106,7 +107,7 @@ class NntmuxOffsetWorker extends Command
|
||||
$processed++;
|
||||
|
||||
if (count($batchData) >= $batchSize) {
|
||||
$this->processSearchBatch($batchData, $workerId); // @phpstan-ignore argument.type
|
||||
$this->processSearchBatch($batchData, $index, $workerId); // @phpstan-ignore argument.type
|
||||
$this->info("Worker {$workerId}: Inserted batch of ".count($batchData).' records');
|
||||
$batchData = [];
|
||||
}
|
||||
@@ -119,7 +120,7 @@ class NntmuxOffsetWorker extends Command
|
||||
|
||||
// Process remaining items
|
||||
if (! empty($batchData)) {
|
||||
$this->processSearchBatch($batchData, $workerId); // @phpstan-ignore argument.type
|
||||
$this->processSearchBatch($batchData, $index, $workerId); // @phpstan-ignore argument.type
|
||||
$this->info("Worker {$workerId}: Inserted final batch of ".count($batchData).' records');
|
||||
}
|
||||
|
||||
@@ -166,17 +167,8 @@ class NntmuxOffsetWorker extends Command
|
||||
private function buildOffsetQuery(string $index, int $offset, int $limit): mixed
|
||||
{
|
||||
if ($index === 'releases') {
|
||||
return Release::query()
|
||||
->select([
|
||||
'releases.id',
|
||||
'releases.name',
|
||||
'releases.searchname',
|
||||
'releases.fromname',
|
||||
'releases.categories_id',
|
||||
'releases.postdate',
|
||||
])
|
||||
->selectRaw('(SELECT GROUP_CONCAT(rf.name SEPARATOR " ") FROM release_files rf WHERE rf.releases_id = releases.id) AS filename')
|
||||
->orderBy('releases.id')
|
||||
return ReleaseIndexProjection::query()
|
||||
->orderBy('r.id')
|
||||
->offset($offset)
|
||||
->limit($limit);
|
||||
} else {
|
||||
@@ -194,34 +186,15 @@ class NntmuxOffsetWorker extends Command
|
||||
private function getTransformer(string $engine, string $index): callable
|
||||
{
|
||||
if ($index === 'releases') {
|
||||
if ($engine === 'manticore') {
|
||||
return function ($item) {
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'name' => (string) ($item->name ?: ''),
|
||||
'searchname' => (string) ($item->searchname ?: ''),
|
||||
'fromname' => (string) ($item->fromname ?: ''),
|
||||
'categories_id' => (string) ($item->categories_id ?: '0'),
|
||||
'filename' => (string) ($item->filename ?: ''),
|
||||
'dummy' => '1',
|
||||
];
|
||||
};
|
||||
} else {
|
||||
return function ($item) {
|
||||
$searchName = str_replace(['.', '-'], ' ', $item->searchname ?? '');
|
||||
return function ($item) use ($engine): array {
|
||||
$row = (array) $item;
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'searchname' => $item->searchname,
|
||||
'plainsearchname' => $searchName,
|
||||
'fromname' => $item->fromname,
|
||||
'categories_id' => $item->categories_id,
|
||||
'filename' => $item->filename ?? '',
|
||||
'postdate' => $item->postdate,
|
||||
];
|
||||
};
|
||||
}
|
||||
return $engine === 'manticore'
|
||||
? ReleaseSearchIndexDocument::normalize($row)
|
||||
: array_merge($row, [
|
||||
'plainsearchname' => str_replace(['.', '-'], ' ', (string) ($item->searchname ?? '')),
|
||||
]);
|
||||
};
|
||||
} else { // predb
|
||||
return function ($item) {
|
||||
return [
|
||||
@@ -229,7 +202,6 @@ class NntmuxOffsetWorker extends Command
|
||||
'title' => (string) ($item->title ?? ''),
|
||||
'filename' => (string) ($item->filename ?? ''),
|
||||
'source' => (string) ($item->source ?? ''),
|
||||
'dummy' => 1,
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -241,7 +213,7 @@ class NntmuxOffsetWorker extends Command
|
||||
private function getIndexName(string $engine, string $index): string
|
||||
{
|
||||
if ($engine === 'manticore') {
|
||||
return $index === 'releases' ? 'releases_rt' : 'predb_rt';
|
||||
return (string) config("search.drivers.manticore.indexes.{$index}", $index.'_rt');
|
||||
} else {
|
||||
return $index;
|
||||
}
|
||||
@@ -252,14 +224,22 @@ class NntmuxOffsetWorker extends Command
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function processSearchBatch(array $data, int $workerId): void
|
||||
private function processSearchBatch(array $data, string $index, int $workerId): void
|
||||
{
|
||||
$retries = 3;
|
||||
$attempt = 0;
|
||||
|
||||
while ($attempt < $retries) {
|
||||
try {
|
||||
Search::bulkInsertReleases($data);
|
||||
$result = [];
|
||||
if ($index === 'releases') {
|
||||
$result = Search::bulkInsertReleases($data);
|
||||
} else {
|
||||
$result = Search::bulkInsertPredb($data);
|
||||
}
|
||||
if (($result['errors'] ?? 0) > 0) {
|
||||
throw new Exception('Bulk operation reported '.(int) $result['errors'].' error(s).');
|
||||
}
|
||||
break;
|
||||
} catch (Exception $e) {
|
||||
$attempt++;
|
||||
|
||||
@@ -16,6 +16,8 @@ use App\Models\Predb;
|
||||
use App\Models\Release;
|
||||
use App\Models\SteamApp;
|
||||
use App\Models\Video;
|
||||
use App\Services\Search\Drivers\ManticoreSearchDriver;
|
||||
use App\Services\Search\Support\ReleaseIndexProjection;
|
||||
use App\Support\ReleaseSearchIndexDocument;
|
||||
use App\Support\SecondaryIndexDocuments;
|
||||
use Exception;
|
||||
@@ -194,7 +196,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
|
||||
private function manticoreReleases(): int
|
||||
{
|
||||
$indexName = 'releases_rt';
|
||||
$indexName = (string) config('search.drivers.manticore.indexes.releases', 'releases_rt');
|
||||
|
||||
Search::truncateIndex([$indexName]);
|
||||
|
||||
@@ -205,39 +207,14 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Optimized query: avoid GROUP_CONCAT and complex joins for faster population
|
||||
// External media IDs can be populated separately if needed
|
||||
$query = Release::query()
|
||||
->orderByDesc('releases.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.videos_id',
|
||||
'releases.movieinfo_id',
|
||||
'releases.imdbid',
|
||||
]);
|
||||
$query = ReleaseIndexProjection::query()->orderBy('r.id');
|
||||
|
||||
return $this->processManticoreData(
|
||||
$indexName,
|
||||
$total,
|
||||
$query,
|
||||
function ($item) {
|
||||
return ReleaseSearchIndexDocument::normalize(array_merge(
|
||||
(array) $item->getAttributes(),
|
||||
['filename' => '']
|
||||
));
|
||||
return ReleaseSearchIndexDocument::normalize((array) $item);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -247,7 +224,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
*/
|
||||
private function manticorePredb(): int
|
||||
{
|
||||
$indexName = 'predb_rt';
|
||||
$indexName = (string) config('search.drivers.manticore.indexes.predb', 'predb_rt');
|
||||
|
||||
Search::truncateIndex([$indexName]);
|
||||
|
||||
@@ -282,7 +259,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
*/
|
||||
private function manticoreMovies(): int
|
||||
{
|
||||
$indexName = 'movies_rt';
|
||||
$indexName = (string) config('search.drivers.manticore.indexes.movies', 'movies_rt');
|
||||
|
||||
Search::truncateIndex([$indexName]);
|
||||
|
||||
@@ -336,7 +313,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
*/
|
||||
private function manticoreTvshows(): int
|
||||
{
|
||||
$indexName = 'tvshows_rt';
|
||||
$indexName = (string) config('search.drivers.manticore.indexes.tvshows', 'tvshows_rt');
|
||||
|
||||
Search::truncateIndex([$indexName]);
|
||||
|
||||
@@ -385,7 +362,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
|
||||
private function manticoreMusic(): int
|
||||
{
|
||||
$indexName = 'music_rt';
|
||||
$indexName = (string) config('search.drivers.manticore.indexes.music', 'music_rt');
|
||||
Search::truncateIndex([$indexName]);
|
||||
$total = MusicInfo::count();
|
||||
if (! $total) {
|
||||
@@ -405,7 +382,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
|
||||
private function manticoreBooks(): int
|
||||
{
|
||||
$indexName = 'books_rt';
|
||||
$indexName = (string) config('search.drivers.manticore.indexes.books', 'books_rt');
|
||||
Search::truncateIndex([$indexName]);
|
||||
$total = BookInfo::count();
|
||||
if (! $total) {
|
||||
@@ -425,7 +402,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
|
||||
private function manticoreGames(): int
|
||||
{
|
||||
$indexName = 'games_rt';
|
||||
$indexName = (string) config('search.drivers.manticore.indexes.games', 'games_rt');
|
||||
Search::truncateIndex([$indexName]);
|
||||
$total = GamesInfo::count();
|
||||
if (! $total) {
|
||||
@@ -445,7 +422,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
|
||||
private function manticoreConsole(): int
|
||||
{
|
||||
$indexName = 'console_rt';
|
||||
$indexName = (string) config('search.drivers.manticore.indexes.console', 'console_rt');
|
||||
Search::truncateIndex([$indexName]);
|
||||
$total = ConsoleInfo::count();
|
||||
if (! $total) {
|
||||
@@ -465,7 +442,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
|
||||
private function manticoreSteam(): int
|
||||
{
|
||||
$indexName = 'steam_rt';
|
||||
$indexName = (string) config('search.drivers.manticore.indexes.steam', 'steam_rt');
|
||||
Search::truncateIndex([$indexName]);
|
||||
$total = SteamApp::count();
|
||||
if (! $total) {
|
||||
@@ -485,7 +462,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
|
||||
private function manticoreAnime(): int
|
||||
{
|
||||
$indexName = 'anime_rt';
|
||||
$indexName = (string) config('search.drivers.manticore.indexes.anime', 'anime_rt');
|
||||
Search::truncateIndex([$indexName]);
|
||||
$total = (int) DB::table('anidb_titles')->count();
|
||||
if (! $total) {
|
||||
@@ -555,7 +532,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
$batchData = [];
|
||||
|
||||
try {
|
||||
$query->chunk($chunkSize, function ($items) use ($indexName, $transformer, $bar, &$processedCount, &$errorCount, $batchSize, &$batchData) {
|
||||
$processItems = function ($items) use ($indexName, $transformer, $bar, &$processedCount, &$errorCount, $batchSize, &$batchData): void {
|
||||
foreach ($items as $item) {
|
||||
try {
|
||||
$batchData[] = $transformer($item);
|
||||
@@ -574,7 +551,13 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
}
|
||||
$bar->advance();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if ($indexName === (string) config('search.drivers.manticore.indexes.releases', 'releases_rt')) {
|
||||
$query->chunkById($chunkSize, $processItems, 'r.id', 'id');
|
||||
} else {
|
||||
$query->chunk($chunkSize, $processItems);
|
||||
}
|
||||
|
||||
// Process remaining items
|
||||
if (! empty($batchData)) {
|
||||
@@ -586,8 +569,20 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
|
||||
if ($errorCount > 0) {
|
||||
$this->warn("Completed with {$errorCount} errors out of {$processedCount} processed items.");
|
||||
} else {
|
||||
$this->info('Search index population completed successfully!');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$actualCount = app(ManticoreSearchDriver::class)->countIndexDocuments($indexName);
|
||||
if ($actualCount < $total) {
|
||||
$this->error(sprintf('Manticore verification failed for %s: expected %d, found %d.', $indexName, $total, $actualCount));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$this->info(sprintf('Search index population completed successfully (%d documents verified).', $actualCount));
|
||||
} catch (Exception $e) {
|
||||
$this->warn('Manticore population completed, but verification failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
@@ -1143,25 +1138,29 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
while ($attempt < $retries) {
|
||||
try {
|
||||
// Use the correct bulk insert method based on index name
|
||||
if ($indexName === 'releases_rt') {
|
||||
Search::bulkInsertReleases($data);
|
||||
} elseif ($indexName === 'predb_rt') {
|
||||
Search::bulkInsertPredb($data);
|
||||
} elseif ($indexName === 'music_rt') {
|
||||
Search::bulkInsertSecondary(SecondarySearchIndex::Music, $data);
|
||||
} elseif ($indexName === 'books_rt') {
|
||||
Search::bulkInsertSecondary(SecondarySearchIndex::Books, $data);
|
||||
} elseif ($indexName === 'games_rt') {
|
||||
Search::bulkInsertSecondary(SecondarySearchIndex::Games, $data);
|
||||
} elseif ($indexName === 'console_rt') {
|
||||
Search::bulkInsertSecondary(SecondarySearchIndex::Console, $data);
|
||||
} elseif ($indexName === 'steam_rt') {
|
||||
Search::bulkInsertSecondary(SecondarySearchIndex::Steam, $data);
|
||||
} elseif ($indexName === 'anime_rt') {
|
||||
Search::bulkInsertSecondary(SecondarySearchIndex::Anime, $data);
|
||||
$result = [];
|
||||
if ($indexName === (string) config('search.drivers.manticore.indexes.releases', 'releases_rt')) {
|
||||
$result = Search::bulkInsertReleases($data);
|
||||
} elseif ($indexName === (string) config('search.drivers.manticore.indexes.predb', 'predb_rt')) {
|
||||
$result = Search::bulkInsertPredb($data);
|
||||
} elseif ($indexName === (string) config('search.drivers.manticore.indexes.music', 'music_rt')) {
|
||||
$result = Search::bulkInsertSecondary(SecondarySearchIndex::Music, $data);
|
||||
} elseif ($indexName === (string) config('search.drivers.manticore.indexes.books', 'books_rt')) {
|
||||
$result = Search::bulkInsertSecondary(SecondarySearchIndex::Books, $data);
|
||||
} elseif ($indexName === (string) config('search.drivers.manticore.indexes.games', 'games_rt')) {
|
||||
$result = Search::bulkInsertSecondary(SecondarySearchIndex::Games, $data);
|
||||
} elseif ($indexName === (string) config('search.drivers.manticore.indexes.console', 'console_rt')) {
|
||||
$result = Search::bulkInsertSecondary(SecondarySearchIndex::Console, $data);
|
||||
} elseif ($indexName === (string) config('search.drivers.manticore.indexes.steam', 'steam_rt')) {
|
||||
$result = Search::bulkInsertSecondary(SecondarySearchIndex::Steam, $data);
|
||||
} elseif ($indexName === (string) config('search.drivers.manticore.indexes.anime', 'anime_rt')) {
|
||||
$result = Search::bulkInsertSecondary(SecondarySearchIndex::Anime, $data);
|
||||
} else {
|
||||
throw new Exception("Unknown index: {$indexName}");
|
||||
}
|
||||
if (($result['errors'] ?? 0) > 0) {
|
||||
throw new Exception('Bulk operation reported '.(int) $result['errors'].' error(s).');
|
||||
}
|
||||
break;
|
||||
} catch (Exception $e) {
|
||||
$attempt++;
|
||||
|
||||
@@ -16,8 +16,10 @@ class NntmuxSearchReconcile extends Command
|
||||
{
|
||||
protected $signature = 'nntmux:search-reconcile
|
||||
{--since=24h : Only releases with adddate after this window (e.g. 24h, 7d)}
|
||||
{--all : Scan every release row instead of applying --since}
|
||||
{--chunk=1000 : MySQL chunk size when scanning releases}
|
||||
{--in-batch=500 : Max ids per Manticore IN() query}
|
||||
{--orphans : Also remove indexed ids that no longer exist in MySQL}
|
||||
{--reindex : Call Search::updateRelease for each missing id}
|
||||
{--dry-run : Report only; do not write to the index}';
|
||||
|
||||
@@ -39,10 +41,12 @@ class NntmuxSearchReconcile extends Command
|
||||
|
||||
$since = (string) $this->option('since');
|
||||
$cutoff = $this->parseSinceCutoff($since);
|
||||
$scanAll = (bool) $this->option('all');
|
||||
$chunk = max(50, min(5000, (int) $this->option('chunk')));
|
||||
$inBatch = max(50, min(1000, (int) $this->option('in-batch')));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$reindex = (bool) $this->option('reindex');
|
||||
$checkOrphans = (bool) $this->option('orphans');
|
||||
|
||||
if ($reindex && $dryRun) {
|
||||
$this->warn('--reindex with --dry-run: no writes will be performed.');
|
||||
@@ -56,8 +60,11 @@ class NntmuxSearchReconcile extends Command
|
||||
$bar = $this->output->createProgressBar();
|
||||
$bar->start();
|
||||
|
||||
Release::query()
|
||||
->where('adddate', '>=', $cutoff)
|
||||
$releaseQuery = Release::query();
|
||||
if (! $scanAll) {
|
||||
$releaseQuery->where('adddate', '>=', $cutoff);
|
||||
}
|
||||
$releaseQuery
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($releases) use ($manticore, $index, $inBatch, &$missingAll, &$scanned, $bar): void {
|
||||
$ids = $releases->pluck('id')->map(static fn ($id): int => (int) $id)->all();
|
||||
@@ -75,7 +82,8 @@ class NntmuxSearchReconcile extends Command
|
||||
$this->newLine(2);
|
||||
|
||||
$missingTotal = \count($missingAll);
|
||||
$this->info("Scanned {$scanned} release row(s); missing in Manticore index: {$missingTotal} (adddate >= {$cutoff->toDateTimeString()})");
|
||||
$scope = $scanAll ? 'all release rows' : "adddate >= {$cutoff->toDateTimeString()}";
|
||||
$this->info("Scanned {$scanned} release row(s); missing in Manticore index: {$missingTotal} ({$scope})");
|
||||
if ($missingTotal > 0) {
|
||||
$sample = \array_slice($missingAll, 0, 20);
|
||||
$this->line('Sample ids: '.implode(', ', $sample));
|
||||
@@ -89,12 +97,18 @@ class NntmuxSearchReconcile extends Command
|
||||
$this->probeAndWarnAllMissing($manticore, $index, \array_slice($missingAll, 0, 5));
|
||||
}
|
||||
|
||||
if ($missingTotal === 0) {
|
||||
$orphans = $checkOrphans ? $this->findOrphanedIds($manticore, $chunk) : [];
|
||||
if ($orphans !== []) {
|
||||
$this->info('Indexed documents with no MySQL release row: '.count($orphans));
|
||||
$this->line('Orphan sample ids: '.implode(', ', array_slice($orphans, 0, 20)));
|
||||
}
|
||||
|
||||
if ($missingTotal === 0 && $orphans === []) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $reindex) {
|
||||
$this->comment('Run with --reindex to push Search::updateRelease() for each missing id.');
|
||||
$this->comment('Run with --reindex to repair missing documents and remove orphaned ids.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
@@ -110,11 +124,44 @@ class NntmuxSearchReconcile extends Command
|
||||
}
|
||||
}
|
||||
|
||||
if ($orphans !== []) {
|
||||
try {
|
||||
Search::deleteReleases($orphans);
|
||||
$this->info('Removed '.count($orphans).' orphaned document(s).');
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Failed removing orphaned documents: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Reindexed {$done} release(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function findOrphanedIds(ManticoreSearchDriver $manticore, int $pageSize): array
|
||||
{
|
||||
$orphans = [];
|
||||
$offset = 0;
|
||||
|
||||
do {
|
||||
$indexed = $manticore->indexedReleaseIds($pageSize, $offset);
|
||||
if ($indexed === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
$existing = Release::query()->whereIn('id', $indexed)->pluck('id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->all();
|
||||
$orphans = [...$orphans, ...array_values(array_diff($indexed, $existing))];
|
||||
$offset += count($indexed);
|
||||
} while (count($indexed) === $pageSize);
|
||||
|
||||
return $orphans;
|
||||
}
|
||||
|
||||
private function parseSinceCutoff(string $since): Carbon
|
||||
{
|
||||
$since = trim($since);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Facades\Search;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class NntmuxSearchRepair extends Command
|
||||
{
|
||||
protected $signature = 'nntmux:search-repair
|
||||
{--limit=100 : Maximum failed releases to retry}
|
||||
{--dry-run : Report due failures without writing to Manticore}';
|
||||
|
||||
protected $description = 'Retry failed release search-index updates';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = max(1, min(5000, (int) $this->option('limit')));
|
||||
$query = DB::table('search_index_failures')
|
||||
->whereNull('resolved_at')
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('next_attempt_at')->orWhere('next_attempt_at', '<=', now());
|
||||
})
|
||||
->orderBy('id')
|
||||
->limit($limit);
|
||||
|
||||
$rows = $query->get(['release_id', 'operation']);
|
||||
if ($rows->isEmpty()) {
|
||||
$this->info('No failed release index updates are due for repair.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$releaseId = (int) $row->release_id;
|
||||
if ((bool) $this->option('dry-run')) {
|
||||
$this->line((string) $releaseId);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($row->operation === 'delete') {
|
||||
Search::deleteRelease($releaseId);
|
||||
} else {
|
||||
Search::updateRelease($releaseId);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf('%s release index failure(s) %s.', $rows->count(), $this->option('dry-run') ? 'reported' : 'processed'));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use Illuminate\Support\Facades\Facade;
|
||||
* @method static void insertRelease(array $parameters)
|
||||
* @method static void updateRelease(int|string $releaseID)
|
||||
* @method static void deleteRelease(int $id)
|
||||
* @method static void deleteReleases(iterable $ids)
|
||||
* @method static void deleteReleases(iterable<int|string> $ids)
|
||||
* @method static void insertPredb(array $parameters)
|
||||
* @method static void updatePreDb(array $parameters)
|
||||
* @method static void deletePreDb(int $id)
|
||||
|
||||
+16
-5
@@ -216,7 +216,7 @@ class Release extends Model
|
||||
]
|
||||
);
|
||||
|
||||
Search::updateRelease($parameters['id']);
|
||||
self::syncSearchIndexAfterCommit((int) $parameters['id']);
|
||||
|
||||
return $parameters['id'];
|
||||
}
|
||||
@@ -249,7 +249,7 @@ class Release extends Model
|
||||
]
|
||||
);
|
||||
|
||||
Search::updateRelease($id);
|
||||
self::syncSearchIndexAfterCommit((int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,7 +262,7 @@ class Release extends Model
|
||||
$id = self::whereGuid($guid)->value('id');
|
||||
self::whereGuid($guid)->increment('grabs');
|
||||
if ($id !== null) {
|
||||
Search::updateRelease((int) $id);
|
||||
self::syncSearchIndexAfterCommit((int) $id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,7 +284,7 @@ class Release extends Model
|
||||
$ids = self::query()->whereIn('guid', $guids)->pluck('id');
|
||||
self::query()->whereIn('guid', $guids)->increment('grabs');
|
||||
foreach ($ids as $id) {
|
||||
Search::updateRelease((int) $id);
|
||||
self::syncSearchIndexAfterCommit((int) $id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,7 +302,7 @@ class Release extends Model
|
||||
$ids = self::whereVideosId($videoId)->pluck('id');
|
||||
$updated = self::whereVideosId($videoId)->update(['videos_id' => 0, 'tv_episodes_id' => 0]);
|
||||
foreach ($ids as $id) {
|
||||
Search::updateRelease((int) $id);
|
||||
self::syncSearchIndexAfterCommit((int) $id);
|
||||
}
|
||||
|
||||
return $updated;
|
||||
@@ -313,6 +313,17 @@ class Release extends Model
|
||||
return self::whereAnidbid($anidbID)->update(['anidbid' => -1]);
|
||||
}
|
||||
|
||||
private static function syncSearchIndexAfterCommit(int $releaseId): void
|
||||
{
|
||||
if ($releaseId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::afterCommit(function () use ($releaseId): void {
|
||||
Search::updateRelease($releaseId);
|
||||
});
|
||||
}
|
||||
|
||||
public static function getTopDownloads(): mixed
|
||||
{
|
||||
$expiresAt = now()->addMinutes(config('nntmux.cache_expiry_long'));
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Facades\Search;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -146,7 +147,9 @@ class ReleaseFile extends Model
|
||||
if (\strlen($hash) === 32) {
|
||||
ParHash::insertOrIgnore(['releases_id' => $id, 'hash' => $hash]);
|
||||
}
|
||||
Search::updateRelease($id);
|
||||
DB::afterCommit(function () use ($id): void {
|
||||
Search::updateRelease($id);
|
||||
});
|
||||
}
|
||||
|
||||
return $insert ?? 0;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Support\ReleaseSearchIndexSync;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CategoryObserver
|
||||
{
|
||||
public function updated(Category $category): void
|
||||
{
|
||||
if (! $category->isDirty(['title', 'parentid', 'root_categories_id'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::afterCommit(fn (): bool => $this->reindex($category));
|
||||
}
|
||||
|
||||
private function reindex(Category $category): bool
|
||||
{
|
||||
ReleaseSearchIndexSync::forCategory((int) $category->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ namespace App\Observers;
|
||||
|
||||
use App\Facades\Search;
|
||||
use App\Models\MovieInfo;
|
||||
use App\Support\ReleaseSearchIndexSync;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MovieInfoObserver
|
||||
@@ -24,6 +26,7 @@ class MovieInfoObserver
|
||||
public function updated(MovieInfo $movie): void
|
||||
{
|
||||
$this->syncToSearchIndex($movie);
|
||||
DB::afterCommit(fn (): bool => $this->syncReleases($movie));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +42,7 @@ class MovieInfoObserver
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
DB::afterCommit(fn (): bool => $this->syncReleases($movie));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,4 +71,11 @@ class MovieInfoObserver
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function syncReleases(MovieInfo $movie): bool
|
||||
{
|
||||
ReleaseSearchIndexSync::forMovieInfo((int) $movie->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Facades\Search;
|
||||
use App\Models\Release;
|
||||
use App\Services\Nzb\NzbService;
|
||||
use App\Services\ReleaseImageService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,7 @@ class ReleaseObserver
|
||||
*/
|
||||
public function created(Release $release): void
|
||||
{
|
||||
$this->syncToSearchIndex($release);
|
||||
$this->syncAfterCommit($release);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,19 +39,25 @@ class ReleaseObserver
|
||||
{
|
||||
$indexedFields = [
|
||||
'name',
|
||||
'guid',
|
||||
'searchname',
|
||||
'fromname',
|
||||
'categories_id',
|
||||
'imdbid',
|
||||
'anidbid',
|
||||
'movieinfo_id',
|
||||
'videos_id',
|
||||
'tv_episodes_id',
|
||||
'size',
|
||||
'totalpart',
|
||||
'grabs',
|
||||
'passwordstatus',
|
||||
'groups_id',
|
||||
'nzbstatus',
|
||||
'nfostatus',
|
||||
'haspreview',
|
||||
'jpgstatus',
|
||||
'comments',
|
||||
'postdate',
|
||||
'adddate',
|
||||
];
|
||||
@@ -64,7 +71,7 @@ class ReleaseObserver
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$this->syncToSearchIndex($release);
|
||||
$this->syncAfterCommit($release);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,20 +113,27 @@ class ReleaseObserver
|
||||
*/
|
||||
public function deleted(Release $release): void
|
||||
{
|
||||
try {
|
||||
Search::deleteRelease($release->id);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ReleaseObserver: Failed to delete release from search index', [
|
||||
'release_id' => $release->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
DB::afterCommit(function () use ($release): void {
|
||||
try {
|
||||
Search::deleteRelease($release->id);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ReleaseObserver: Failed to delete release from search index', [
|
||||
'release_id' => $release->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function syncAfterCommit(Release $release): void
|
||||
{
|
||||
DB::afterCommit(fn (): bool => $this->syncToSearchIndex($release));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the release to the search index (full document from DB + joins).
|
||||
*/
|
||||
private function syncToSearchIndex(Release $release): void
|
||||
private function syncToSearchIndex(Release $release): bool
|
||||
{
|
||||
try {
|
||||
Search::updateRelease($release->id);
|
||||
@@ -129,11 +143,15 @@ class ReleaseObserver
|
||||
'release_id' => $release->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ReleaseObserver: Failed to sync release to search index', [
|
||||
'release_id' => $release->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\RootCategory;
|
||||
use App\Support\ReleaseSearchIndexSync;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class RootCategoryObserver
|
||||
{
|
||||
public function updated(RootCategory $category): void
|
||||
{
|
||||
if (! $category->isDirty('title')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::afterCommit(fn (): bool => $this->reindex($category));
|
||||
}
|
||||
|
||||
private function reindex(RootCategory $category): bool
|
||||
{
|
||||
ReleaseSearchIndexSync::forRootCategory((int) $category->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Support\ReleaseSearchIndexSync;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class UsenetGroupObserver
|
||||
{
|
||||
public function updated(UsenetGroup $group): void
|
||||
{
|
||||
if (! $group->isDirty('name')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::afterCommit(fn (): bool => $this->reindex($group));
|
||||
}
|
||||
|
||||
private function reindex(UsenetGroup $group): bool
|
||||
{
|
||||
ReleaseSearchIndexSync::forQueryGroup((int) $group->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ namespace App\Observers;
|
||||
|
||||
use App\Facades\Search;
|
||||
use App\Models\Video;
|
||||
use App\Support\ReleaseSearchIndexSync;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class VideoObserver
|
||||
@@ -24,6 +26,7 @@ class VideoObserver
|
||||
public function updated(Video $video): void
|
||||
{
|
||||
$this->syncToSearchIndex($video);
|
||||
DB::afterCommit(fn (): bool => $this->syncReleases($video));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +42,7 @@ class VideoObserver
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
DB::afterCommit(fn (): bool => $this->syncReleases($video));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,4 +70,11 @@ class VideoObserver
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function syncReleases(Video $video): bool
|
||||
{
|
||||
ReleaseSearchIndexSync::forVideo((int) $video->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,25 +9,31 @@ use App\Listeners\RecategorizeReleaseAfterNameFix;
|
||||
use App\Models\AnidbInfo;
|
||||
use App\Models\AnidbTitle;
|
||||
use App\Models\BookInfo;
|
||||
use App\Models\Category;
|
||||
use App\Models\ConsoleInfo;
|
||||
use App\Models\GamesInfo;
|
||||
use App\Models\MovieInfo;
|
||||
use App\Models\MusicInfo;
|
||||
use App\Models\Release;
|
||||
use App\Models\RolePromotion;
|
||||
use App\Models\RootCategory;
|
||||
use App\Models\SteamApp;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Observers\AnidbInfoObserver;
|
||||
use App\Observers\AnidbTitleObserver;
|
||||
use App\Observers\BookInfoObserver;
|
||||
use App\Observers\CategoryObserver;
|
||||
use App\Observers\ConsoleInfoObserver;
|
||||
use App\Observers\GamesInfoObserver;
|
||||
use App\Observers\MovieInfoObserver;
|
||||
use App\Observers\MusicInfoObserver;
|
||||
use App\Observers\ReleaseObserver;
|
||||
use App\Observers\RolePromotionObserver;
|
||||
use App\Observers\RootCategoryObserver;
|
||||
use App\Observers\SteamAppObserver;
|
||||
use App\Observers\UsenetGroupObserver;
|
||||
use App\Observers\VideoObserver;
|
||||
use App\View\Composers\AdminDataComposer;
|
||||
use App\View\Composers\GlobalDataComposer;
|
||||
@@ -74,6 +80,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
MovieInfo::observe(MovieInfoObserver::class);
|
||||
Video::observe(VideoObserver::class);
|
||||
Release::observe(ReleaseObserver::class);
|
||||
Category::observe(CategoryObserver::class);
|
||||
RootCategory::observe(RootCategoryObserver::class);
|
||||
UsenetGroup::observe(UsenetGroupObserver::class);
|
||||
MusicInfo::observe(MusicInfoObserver::class);
|
||||
BookInfo::observe(BookInfoObserver::class);
|
||||
GamesInfo::observe(GamesInfoObserver::class);
|
||||
|
||||
@@ -632,7 +632,7 @@ class ConsoleService
|
||||
}
|
||||
|
||||
$result['release'] = $releaseName;
|
||||
array_map('trim', $result);
|
||||
$result = array_map('trim', $result);
|
||||
|
||||
return (isset($result['title'], $result['platform']) && ! empty($result['title'])) ? $result : false;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Category;
|
||||
use App\Models\Release;
|
||||
use App\Models\Settings;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Support\ReleaseSearchIndexDocument;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -375,6 +376,7 @@ class ReleaseBrowseService
|
||||
'sort_field' => $indexSortField,
|
||||
'sort_dir' => $orderBy[1] ?? 'desc',
|
||||
'try_fuzzy' => false,
|
||||
'include_documents' => true,
|
||||
];
|
||||
|
||||
$filtered = Search::searchReleasesFiltered($criteria, (int) $num, (int) $start);
|
||||
@@ -382,6 +384,24 @@ class ReleaseBrowseService
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! empty($filtered['documents'])) {
|
||||
$documents = array_map(static function (array $document): object {
|
||||
$row = ReleaseSearchIndexDocument::toReleaseRow($document);
|
||||
$row['title'] = $row['episode_title'];
|
||||
$row['firstaired'] = $row['firstaired_ts'] > 0
|
||||
? date('Y-m-d H:i:s', $row['firstaired_ts'])
|
||||
: null;
|
||||
|
||||
return (object) $row;
|
||||
}, $filtered['documents']);
|
||||
|
||||
if ($documents !== []) {
|
||||
$documents[0]->_totalcount = $documents[0]->_totalrows = (int) ($filtered['total'] ?? count($documents));
|
||||
}
|
||||
|
||||
return $documents;
|
||||
}
|
||||
|
||||
$ids = array_map(static fn (int|string $id): int => (int) $id, $filtered['ids']);
|
||||
$idList = implode(',', $ids);
|
||||
$fieldOrder = implode(',', $ids);
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\Settings;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Services\Search\DTO\ReleaseSearchQuery;
|
||||
use App\Services\Search\DTO\SearchCursor;
|
||||
use App\Support\ReleaseSearchIndexDocument;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -259,11 +260,31 @@ class ReleaseSearchService
|
||||
'sort_field' => $this->browseOrderToIndexSortField($orderField),
|
||||
'sort_dir' => $orderDir,
|
||||
'try_fuzzy' => true,
|
||||
'include_documents' => true,
|
||||
];
|
||||
|
||||
$criteria['track_total'] = $cursor === null;
|
||||
$searchPage = Search::searchReleasePage(ReleaseSearchQuery::fromCriteria($criteria, $limit, $offset, $cursor));
|
||||
$filtered = $searchPage->legacy();
|
||||
|
||||
if ($searchPage->documents !== []) {
|
||||
$releases = collect(array_map(function (array $document): object {
|
||||
$row = ReleaseSearchIndexDocument::toReleaseRow($document);
|
||||
$row['title'] = $row['episode_title'];
|
||||
$row['firstaired'] = $row['firstaired_ts'] > 0
|
||||
? date('Y-m-d H:i:s', $row['firstaired_ts'])
|
||||
: null;
|
||||
|
||||
return (object) $row;
|
||||
}, $searchPage->documents));
|
||||
if ($releases->isNotEmpty()) {
|
||||
$releases[0]->_totalrows = (int) $filtered['total'];
|
||||
$releases[0]->_search_last_sort = $searchPage->lastSortValues;
|
||||
$releases[0]->_search_has_more = $searchPage->hasMore;
|
||||
}
|
||||
|
||||
return $releases;
|
||||
}
|
||||
if ($cursor !== null && $filtered['total'] === 0) {
|
||||
$filtered['total'] = $cursor->total;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ final readonly class ReleaseSearchQuery
|
||||
public int $offset = 0,
|
||||
public ?SearchCursor $cursor = null,
|
||||
public bool $trackTotal = true,
|
||||
public bool $includeDocuments = false,
|
||||
) {}
|
||||
|
||||
/** @param array<string, mixed> $criteria */
|
||||
@@ -57,6 +58,7 @@ final readonly class ReleaseSearchQuery
|
||||
offset: max(0, $offset),
|
||||
cursor: $cursor,
|
||||
trackTotal: (bool) ($criteria['track_total'] ?? true),
|
||||
includeDocuments: (bool) ($criteria['include_documents'] ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,6 +83,7 @@ final readonly class ReleaseSearchQuery
|
||||
'try_fuzzy' => $this->tryFuzzy,
|
||||
'cursor_sort' => $this->cursor?->sortValues,
|
||||
'track_total' => $this->trackTotal,
|
||||
'include_documents' => $this->includeDocuments,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ final readonly class SearchPage
|
||||
/**
|
||||
* @param list<int> $ids
|
||||
* @param list<int|float|string> $lastSortValues
|
||||
* @param list<array<string, mixed>> $documents
|
||||
*/
|
||||
public function __construct(
|
||||
public array $ids,
|
||||
@@ -19,6 +20,7 @@ final readonly class SearchPage
|
||||
public float $durationMs = 0.0,
|
||||
public array $lastSortValues = [],
|
||||
public bool $hasMore = false,
|
||||
public array $documents = [],
|
||||
) {}
|
||||
|
||||
/** @return array{ids: list<int>, total: int, fuzzy: bool} */
|
||||
|
||||
@@ -109,6 +109,7 @@ class ElasticSearchDriver implements SearchDriverInterface
|
||||
durationMs: (hrtime(true) - $startedAt) / 1_000_000,
|
||||
lastSortValues: $result['last_sort'] ?? [],
|
||||
hasMore: (bool) ($result['has_more'] ?? (($query->offset + count($result['ids'])) < (int) $result['total'])),
|
||||
documents: $result['documents'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Services\Search\DTO\ReleaseSearchQuery;
|
||||
use App\Services\Search\DTO\SearchPage;
|
||||
use App\Services\Search\Support\ManticoreClientFactory;
|
||||
use App\Services\Search\Support\ManticoreIndexRegistry;
|
||||
use App\Services\Search\Support\ReleaseIndexProjection;
|
||||
use App\Support\ReleaseSearchIndexDocument;
|
||||
use App\Support\SecondaryIndexDocuments;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -121,9 +122,87 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
durationMs: (hrtime(true) - $startedAt) / 1_000_000,
|
||||
lastSortValues: $result['last_sort'] ?? [],
|
||||
hasMore: (bool) ($result['has_more'] ?? (($query->offset + count($result['ids'])) < (int) $result['total'])),
|
||||
documents: $result['documents'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current document count for an RT table.
|
||||
*/
|
||||
public function countIndexDocuments(string $index): int
|
||||
{
|
||||
if (preg_match('/^[a-zA-Z0-9_]+$/', $index) !== 1) {
|
||||
throw new \InvalidArgumentException('Unsafe Manticore index name.');
|
||||
}
|
||||
|
||||
$response = $this->manticoreSearch->sql("SELECT COUNT(*) AS total FROM `{$index}`", true);
|
||||
if (! is_array($response)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($response as $key => $value) {
|
||||
if (is_array($value) && isset($value['total'])) {
|
||||
return (int) $value['total'];
|
||||
}
|
||||
if ($key === 'total' && is_numeric($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a stable page of release document ids for drift/orphan checks.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function indexedReleaseIds(int $limit = 1000, int $offset = 0): array
|
||||
{
|
||||
$limit = max(1, min(10000, $limit));
|
||||
$offset = max(0, $offset);
|
||||
$index = $this->getReleasesIndex();
|
||||
|
||||
if (preg_match('/^[a-zA-Z0-9_]+$/', $index) !== 1) {
|
||||
throw new \InvalidArgumentException('Unsafe Manticore index name.');
|
||||
}
|
||||
|
||||
$sql = "SELECT id FROM {$index} ORDER BY id ASC LIMIT {$offset},{$limit} OPTION max_matches=".($offset + $limit);
|
||||
$response = $this->manticoreSearch->sql($sql, true);
|
||||
if (! is_array($response)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($response as $key => $value) {
|
||||
if (ctype_digit((string) $key)) {
|
||||
$ids[(int) $key] = true;
|
||||
}
|
||||
if (is_array($value) && isset($value['id'])) {
|
||||
$ids[(int) $value['id']] = true;
|
||||
}
|
||||
if (
|
||||
($key === 'id' || is_int($key) || ctype_digit((string) $key))
|
||||
&& (is_int($value) || (is_string($value) && ctype_digit($value)))
|
||||
) {
|
||||
$ids[(int) $value] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($response['data']) && is_array($response['data'])) {
|
||||
foreach ($response['data'] as $row) {
|
||||
if (is_array($row) && isset($row['id'])) {
|
||||
$ids[(int) $row['id']] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map('intval', array_keys($ids)), static fn (int $id): bool => $id > 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if autocomplete is enabled.
|
||||
*/
|
||||
@@ -209,7 +288,7 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
|
||||
$releaseId = (int) $parameters['id'];
|
||||
if (! $this->replaceReleaseDocumentWithRetry($parameters)) {
|
||||
$this->recordReleaseIndexFailure($releaseId, 'insertRelease');
|
||||
$this->recordReleaseIndexFailure($releaseId, 'insertRelease', 'upsert');
|
||||
try {
|
||||
ReindexReleaseJob::dispatch($releaseId)->delay(now()->addSeconds(2));
|
||||
} catch (\Throwable $e) {
|
||||
@@ -226,24 +305,34 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
*/
|
||||
private function replaceReleaseDocumentWithRetry(array $parameters): bool
|
||||
{
|
||||
$document = ReleaseSearchIndexDocument::normalize($parameters);
|
||||
// updateRelease() supplies the already-normalized canonical projection.
|
||||
// Preserve its *_ts fields instead of normalizing a second time and
|
||||
// replacing post/add timestamps with zero.
|
||||
$document = ReleaseSearchIndexDocument::normalizeForBulk($parameters);
|
||||
unset($document['id']);
|
||||
|
||||
$indexName = $this->config['indexes']['releases'];
|
||||
$releaseId = (int) $parameters['id'];
|
||||
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
$attempts = max(1, (int) ($this->config['retry_attempts'] ?? config('search.drivers.manticore.retry_attempts', 3)));
|
||||
$delayMs = max(0, (int) ($this->config['retry_delay_ms'] ?? config('search.drivers.manticore.retry_delay_ms', 100)));
|
||||
|
||||
for ($attempt = 0; $attempt < $attempts; $attempt++) {
|
||||
try {
|
||||
$this->manticoreSearch->table($indexName)
|
||||
->replaceDocument($document, $releaseId);
|
||||
|
||||
$this->resolveReleaseIndexFailure($releaseId);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$isResponse = $e instanceof ResponseException;
|
||||
$isManticoreRuntime = $e instanceof RuntimeException;
|
||||
|
||||
if (($isResponse || $isManticoreRuntime) && $attempt === 0) {
|
||||
usleep(100_000);
|
||||
if (($isResponse || $isManticoreRuntime) && $attempt < $attempts - 1) {
|
||||
if ($delayMs > 0) {
|
||||
usleep($delayMs * 1000 * ($attempt + 1));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -277,9 +366,33 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
return false;
|
||||
}
|
||||
|
||||
private function recordReleaseIndexFailure(int $releaseId, string $phase): void
|
||||
private function recordReleaseIndexFailure(int $releaseId, string $phase, string $operation = 'upsert'): void
|
||||
{
|
||||
Cache::increment('search:index:failures:releases');
|
||||
try {
|
||||
$existing = DB::table('search_index_failures')
|
||||
->where('release_id', $releaseId)
|
||||
->first(['attempts']);
|
||||
$attempts = ((int) ($existing->attempts ?? 0)) + 1;
|
||||
DB::table('search_index_failures')->updateOrInsert(
|
||||
['release_id' => $releaseId],
|
||||
[
|
||||
'operation' => $operation,
|
||||
'attempts' => $attempts,
|
||||
'last_error' => $phase,
|
||||
'next_attempt_at' => now()->addSeconds(min(3600, 2 ** min($attempts, 10))),
|
||||
'resolved_at' => null,
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ManticoreSearch: unable to persist release index failure', [
|
||||
'release_id' => $releaseId,
|
||||
'phase' => $phase,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
if (Cache::add('search:index:release_index_warn_lock', true, 60)) {
|
||||
Log::warning('ManticoreSearch: release indexing failure', [
|
||||
'release_id_sample' => $releaseId,
|
||||
@@ -289,6 +402,28 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveReleaseIndexFailure(int $releaseId): void
|
||||
{
|
||||
if ($releaseId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::table('search_index_failures')
|
||||
->where('release_id', $releaseId)
|
||||
->update([
|
||||
'resolved_at' => now(),
|
||||
'next_attempt_at' => null,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('ManticoreSearch: unable to mark release index failure resolved', [
|
||||
'release_id' => $releaseId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function recordReleaseNotFoundForIndex(int|string $releaseID): void
|
||||
{
|
||||
Cache::increment('search:index:failures:release_not_found');
|
||||
@@ -361,13 +496,34 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->manticoreSearch->table($this->getReleasesIndex())
|
||||
->deleteDocumentsByIds($ids);
|
||||
} catch (ResponseException $e) {
|
||||
Log::error('ManticoreSearch deleteReleases error: '.$e->getMessage(), [
|
||||
'ids' => $ids,
|
||||
]);
|
||||
$attempts = max(1, (int) ($this->config['retry_attempts'] ?? config('search.drivers.manticore.retry_attempts', 2)));
|
||||
$delayMs = max(0, (int) ($this->config['retry_delay_ms'] ?? config('search.drivers.manticore.retry_delay_ms', 100)));
|
||||
|
||||
for ($attempt = 0; $attempt < $attempts; $attempt++) {
|
||||
try {
|
||||
$this->manticoreSearch->table($this->getReleasesIndex())
|
||||
->deleteDocumentsByIds($ids);
|
||||
foreach ($ids as $id) {
|
||||
$this->resolveReleaseIndexFailure((int) $id);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
if ($attempt < $attempts - 1) {
|
||||
if ($delayMs > 0) {
|
||||
usleep($delayMs * 1000 * ($attempt + 1));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
Log::error('ManticoreSearch deleteReleases error: '.$e->getMessage(), [
|
||||
'ids' => $ids,
|
||||
]);
|
||||
foreach ($ids as $id) {
|
||||
$this->recordReleaseIndexFailure((int) $id, 'deleteReleases: '.$e->getMessage(), 'delete');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,6 +821,42 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
return $fieldSelector.' '.$scopedQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a release-name query that also handles punctuation used as token
|
||||
* separators in Usenet subjects (for example, WEB-DL.x265).
|
||||
*
|
||||
* The prepared query deliberately escapes mid-word hyphens so user search
|
||||
* operators remain safe. Manticore tokenizes those separators instead of
|
||||
* indexing them as part of a word, though, so an ordinary release-name
|
||||
* query needs a normalized-token alternative as well.
|
||||
*/
|
||||
private static function scopeReleaseSearchQuery(string $rawQuery, string $preparedQuery, string $fieldSelector = ''): string
|
||||
{
|
||||
$primary = self::scopePreparedQueryToField($preparedQuery, $fieldSelector);
|
||||
|
||||
if ($fieldSelector !== '@searchname' || self::queryHasNegation($rawQuery)) {
|
||||
return $primary;
|
||||
}
|
||||
|
||||
// Preserve advanced query syntax and only add the fallback for plain
|
||||
// release-name input whose punctuation is a token separator.
|
||||
if (preg_match('/[^\p{L}\p{N}\s._-]/u', $rawQuery) === 1 || ! preg_match('/[._-]/', $rawQuery)) {
|
||||
return $primary;
|
||||
}
|
||||
|
||||
$normalized = preg_replace('/[._-]+/u', ' ', trim($rawQuery));
|
||||
$normalized = is_string($normalized) ? trim(preg_replace('/\s+/', ' ', $normalized) ?? '') : '';
|
||||
$normalizedPrepared = self::prepareUserSearchQuery($normalized);
|
||||
|
||||
if ($normalizedPrepared === '' || $normalizedPrepared === $preparedQuery) {
|
||||
return $primary;
|
||||
}
|
||||
|
||||
$normalizedQuery = self::scopePreparedQueryToField($normalizedPrepared, $fieldSelector);
|
||||
|
||||
return '('.$primary.' | '.$normalizedQuery.')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a search query contains negation operators (! or - prefix on words).
|
||||
*
|
||||
@@ -706,64 +898,10 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
}
|
||||
|
||||
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();
|
||||
$release = ReleaseIndexProjection::forId((int) $releaseID);
|
||||
|
||||
if ($release !== null) {
|
||||
$this->insertRelease($release->toArray());
|
||||
$this->insertRelease($release);
|
||||
} else {
|
||||
Log::warning('ManticoreSearch: Release not found for update, removing from index', ['id' => $releaseID]);
|
||||
$this->recordReleaseNotFoundForIndex($releaseID);
|
||||
@@ -1260,9 +1398,10 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
$terms = [];
|
||||
foreach ($searchArray as $key => $value) {
|
||||
if (! empty($value)) {
|
||||
$preparedValue = self::prepareUserSearchQuery($value);
|
||||
$rawValue = (string) $value;
|
||||
$preparedValue = self::prepareUserSearchQuery($rawValue);
|
||||
if (! empty($preparedValue)) {
|
||||
$terms[] = '@@relaxed '.self::scopePreparedQueryToField($preparedValue, '@'.$key);
|
||||
$terms[] = '@@relaxed '.self::scopeReleaseSearchQuery($rawValue, $preparedValue, '@'.$key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1294,7 +1433,7 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
}
|
||||
}
|
||||
|
||||
$searchExpr = '@@relaxed '.self::scopePreparedQueryToField($preparedSearch, $searchColumns);
|
||||
$searchExpr = '@@relaxed '.self::scopeReleaseSearchQuery($searchString, $preparedSearch, $searchColumns);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
@@ -2401,7 +2540,7 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
return $this->searchReleasesByCategory($categoryIds, $limit);
|
||||
}
|
||||
|
||||
$searchExpr = '@@relaxed '.self::scopePreparedQueryToField($preparedSearch, '@searchname');
|
||||
$searchExpr = '@@relaxed '.self::scopeReleaseSearchQuery($searchTerm, $preparedSearch, '@searchname');
|
||||
|
||||
$query = (new Search($this->manticoreSearch))
|
||||
->setTable($this->getReleasesIndex())
|
||||
@@ -2486,7 +2625,7 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
}
|
||||
$prepared = self::prepareUserSearchQuery((string) $value);
|
||||
if ($prepared !== '') {
|
||||
$terms[] = '@@relaxed '.self::scopePreparedQueryToField($prepared, '@'.$key);
|
||||
$terms[] = '@@relaxed '.self::scopeReleaseSearchQuery((string) $value, $prepared, '@'.$key);
|
||||
}
|
||||
}
|
||||
if ($terms === []) {
|
||||
@@ -2532,8 +2671,15 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
$documents = [];
|
||||
foreach ($results as $doc) {
|
||||
$ids[] = $doc->getId();
|
||||
$id = (int) $doc->getId();
|
||||
$ids[] = $id;
|
||||
if ((bool) ($criteria['include_documents'] ?? false)) {
|
||||
$document = $doc->getData();
|
||||
$document['id'] = $id;
|
||||
$documents[] = $document;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -2545,6 +2691,7 @@ class ManticoreSearchDriver implements SearchDriverInterface
|
||||
// supports a compound search-after predicate without raw SQL.
|
||||
'last_sort' => [],
|
||||
'has_more' => $offset + count($ids) < (int) $results->getTotal(),
|
||||
'documents' => $documents,
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -17,15 +17,27 @@ final class ManticoreIndexRegistry
|
||||
|
||||
return [
|
||||
'releases' => ['settings' => $settings, 'columns' => [
|
||||
'name' => ['type' => 'text'], 'searchname' => ['type' => 'text'],
|
||||
'guid' => ['type' => 'string'], 'name' => ['type' => 'text'],
|
||||
'searchname' => ['type' => 'text'], 'plainsearchname' => ['type' => 'text'],
|
||||
'fromname' => ['type' => 'text'], 'filename' => ['type' => 'text'],
|
||||
'category_name' => ['type' => 'string'], 'parent_category' => ['type' => 'string'],
|
||||
'sub_category' => ['type' => 'string'], 'group_name' => ['type' => 'string'],
|
||||
'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'],
|
||||
'tvrage' => ['type' => 'integer'], 'trakt' => ['type' => 'integer'],
|
||||
'imdb' => ['type' => 'string'], 'tmdb' => ['type' => 'integer'],
|
||||
'videos_id' => ['type' => 'integer'],
|
||||
'tv_episodes_id' => ['type' => 'integer'], 'movieinfo_id' => ['type' => 'integer'],
|
||||
'anidbid' => ['type' => 'integer'], 'parentid' => ['type' => 'integer'],
|
||||
'episode_title' => ['type' => 'text'], 'series' => ['type' => 'integer'],
|
||||
'episode' => ['type' => 'integer'], 'firstaired_ts' => ['type' => 'bigint'],
|
||||
'size' => ['type' => 'bigint'],
|
||||
'postdate_ts' => ['type' => 'bigint'], 'adddate_ts' => ['type' => 'bigint'],
|
||||
'totalpart' => ['type' => 'integer'], 'grabs' => ['type' => 'integer'],
|
||||
'comments' => ['type' => 'integer'], 'nfostatus' => ['type' => 'integer'],
|
||||
'jpgstatus' => ['type' => 'integer'], 'nfoid' => ['type' => 'integer'],
|
||||
'reid' => ['type' => 'integer'],
|
||||
'passwordstatus' => ['type' => 'bigint'], 'groups_id' => ['type' => 'integer'],
|
||||
'nzbstatus' => ['type' => 'integer'], 'haspreview' => ['type' => 'bigint'],
|
||||
]],
|
||||
@@ -56,7 +68,7 @@ final class ManticoreIndexRegistry
|
||||
public static function profile(string $logical): array
|
||||
{
|
||||
$fields = match ($logical) {
|
||||
'releases' => ['searchname' => 12, 'name' => 8, 'filename' => 5, 'fromname' => 1],
|
||||
'releases' => ['searchname' => 12, 'plainsearchname' => 10, '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],
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Search\Support;
|
||||
|
||||
use App\Support\ReleaseSearchIndexDocument;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Single database projection used to build complete release search documents.
|
||||
*/
|
||||
final class ReleaseIndexProjection
|
||||
{
|
||||
public static function query(): Builder
|
||||
{
|
||||
$groupConcat = DB::connection()->getDriverName() === 'sqlite'
|
||||
? "GROUP_CONCAT(rf.name, ' ')"
|
||||
: "GROUP_CONCAT(rf.name SEPARATOR ' ')";
|
||||
$categoryName = DB::connection()->getDriverName() === 'sqlite'
|
||||
? "cp.title || ' > ' || c.title"
|
||||
: "CONCAT(cp.title, ' > ', c.title)";
|
||||
|
||||
return DB::table('releases as r')
|
||||
->leftJoin('usenet_groups as g', 'g.id', '=', 'r.groups_id')
|
||||
->leftJoin('categories as c', 'c.id', '=', 'r.categories_id')
|
||||
->leftJoin('root_categories as cp', 'cp.id', '=', 'c.root_categories_id')
|
||||
->leftJoin('movieinfo as mi', function ($join): void {
|
||||
$join->on('mi.id', '=', 'r.movieinfo_id')
|
||||
->where('r.movieinfo_id', '>', 0);
|
||||
})
|
||||
->leftJoin('videos as v', function ($join): void {
|
||||
$join->on('v.id', '=', 'r.videos_id')
|
||||
->where('r.videos_id', '>', 0);
|
||||
})
|
||||
->leftJoin('tv_episodes as tve', function ($join): void {
|
||||
$join->on('tve.id', '=', 'r.tv_episodes_id')
|
||||
->where('r.tv_episodes_id', '>', 0);
|
||||
})
|
||||
->leftJoin('release_files as rf', 'rf.releases_id', '=', 'r.id')
|
||||
->leftJoin('release_nfos as rn', 'rn.releases_id', '=', 'r.id')
|
||||
->leftJoin('video_data as vd', 'vd.releases_id', '=', 'r.id')
|
||||
->select([
|
||||
'r.id', 'r.guid', 'r.name', 'r.searchname', 'r.fromname', 'r.categories_id',
|
||||
'r.groups_id', 'r.size', 'r.postdate', 'r.adddate', 'r.totalpart', 'r.grabs',
|
||||
'r.comments', 'r.passwordstatus', 'r.nzbstatus', 'r.nfostatus', 'r.haspreview',
|
||||
'r.jpgstatus', 'r.videos_id', 'r.tv_episodes_id', 'r.movieinfo_id', 'r.imdbid',
|
||||
'r.anidbid', 'g.name as group_name', 'c.root_categories_id as parentid',
|
||||
'c.title as sub_category', 'tve.title as episode_title', 'tve.series',
|
||||
'tve.episode', 'tve.firstaired',
|
||||
'cp.title as parent_category', DB::raw("{$categoryName} AS category_name"),
|
||||
DB::raw("COALESCE({$groupConcat}, '') AS filename"),
|
||||
DB::raw('COALESCE(mi.tmdbid, 0) AS tmdbid'),
|
||||
DB::raw('COALESCE(mi.traktid, 0) AS traktid'),
|
||||
DB::raw('COALESCE(v.tvdb, 0) AS tvdb'),
|
||||
DB::raw('COALESCE(v.tvmaze, 0) AS tvmaze'),
|
||||
DB::raw('COALESCE(v.tvrage, 0) AS tvrage'),
|
||||
DB::raw('COALESCE(v.trakt, 0) AS trakt'),
|
||||
DB::raw("COALESCE(v.imdb, '') AS imdb"),
|
||||
DB::raw('COALESCE(v.tmdb, 0) AS tmdb'),
|
||||
DB::raw('COALESCE(rn.releases_id, 0) AS nfoid'),
|
||||
DB::raw('COALESCE(vd.releases_id, 0) AS reid'),
|
||||
])
|
||||
->groupBy([
|
||||
'r.id', 'r.guid', 'r.name', 'r.searchname', 'r.fromname', 'r.categories_id',
|
||||
'r.groups_id', 'r.size', 'r.postdate', 'r.adddate', 'r.totalpart', 'r.grabs',
|
||||
'r.comments', 'r.passwordstatus', 'r.nzbstatus', 'r.nfostatus', 'r.haspreview',
|
||||
'r.jpgstatus', 'r.videos_id', 'r.tv_episodes_id', 'r.movieinfo_id', 'r.imdbid',
|
||||
'r.anidbid', 'g.name', 'c.root_categories_id', 'c.title', 'tve.title',
|
||||
'tve.series', 'tve.episode', 'tve.firstaired', 'cp.title', 'mi.tmdbid',
|
||||
'mi.traktid', 'v.tvdb', 'v.tvmaze', 'v.tvrage', 'v.trakt', 'v.imdb', 'v.tmdb',
|
||||
'rn.releases_id', 'vd.releases_id',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function forId(int $releaseId): ?array
|
||||
{
|
||||
if ($releaseId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = self::query()->where('r.id', $releaseId)->first();
|
||||
|
||||
return $row === null ? null : ReleaseSearchIndexDocument::normalize((array) $row);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,26 @@ namespace App\Support;
|
||||
*/
|
||||
final class ReleaseSearchIndexDocument
|
||||
{
|
||||
/**
|
||||
* Fields that may be returned to browse/search consumers.
|
||||
* Sensitive release data must never be added to this list.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function fields(): array
|
||||
{
|
||||
return [
|
||||
'id', 'guid', 'name', 'searchname', 'plainsearchname', 'fromname', 'filename',
|
||||
'categories_id', 'category_name', 'parent_category', 'sub_category',
|
||||
'groups_id', 'group_name', 'imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze',
|
||||
'tvrage', 'trakt', 'imdb', 'tmdb', 'videos_id', 'tv_episodes_id', 'movieinfo_id', 'anidbid',
|
||||
'parentid', 'episode_title', 'series', 'episode', 'firstaired_ts',
|
||||
'size', 'postdate_ts', 'adddate_ts', 'totalpart', 'grabs', 'comments',
|
||||
'passwordstatus', 'nzbstatus', 'nfostatus', 'haspreview', 'jpgstatus',
|
||||
'nfoid', 'reid',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a release row for bulk indexing. Rows already produced by {@see normalize()}
|
||||
* (e.g. from release search populate) lack `postdate` / `adddate` keys;
|
||||
@@ -40,10 +60,16 @@ final class ReleaseSearchIndexDocument
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'guid' => (string) ($row['guid'] ?? ''),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'searchname' => (string) ($row['searchname'] ?? ''),
|
||||
'plainsearchname' => (string) ($row['plainsearchname'] ?? self::plainSearchName($row['searchname'] ?? '')),
|
||||
'fromname' => (string) ($row['fromname'] ?? ''),
|
||||
'categories_id' => (int) ($row['categories_id'] ?? 0),
|
||||
'category_name' => (string) ($row['category_name'] ?? ''),
|
||||
'parent_category' => (string) ($row['parent_category'] ?? ''),
|
||||
'sub_category' => (string) ($row['sub_category'] ?? ''),
|
||||
'group_name' => (string) ($row['group_name'] ?? ''),
|
||||
'filename' => (string) ($row['filename'] ?? ''),
|
||||
'imdbid' => (string) ($row['imdbid'] ?? ''),
|
||||
'tmdbid' => (int) ($row['tmdbid'] ?? 0),
|
||||
@@ -51,20 +77,50 @@ final class ReleaseSearchIndexDocument
|
||||
'tvdb' => (int) ($row['tvdb'] ?? 0),
|
||||
'tvmaze' => (int) ($row['tvmaze'] ?? 0),
|
||||
'tvrage' => (int) ($row['tvrage'] ?? 0),
|
||||
'trakt' => (int) ($row['trakt'] ?? 0),
|
||||
'imdb' => (string) ($row['imdb'] ?? ''),
|
||||
'tmdb' => (int) ($row['tmdb'] ?? 0),
|
||||
'videos_id' => (int) ($row['videos_id'] ?? 0),
|
||||
'tv_episodes_id' => (int) ($row['tv_episodes_id'] ?? 0),
|
||||
'movieinfo_id' => (int) ($row['movieinfo_id'] ?? 0),
|
||||
'anidbid' => (int) ($row['anidbid'] ?? 0),
|
||||
'parentid' => (int) ($row['parentid'] ?? 0),
|
||||
'episode_title' => (string) ($row['episode_title'] ?? ''),
|
||||
'series' => (int) ($row['series'] ?? 0),
|
||||
'episode' => (int) ($row['episode'] ?? 0),
|
||||
'firstaired_ts' => self::datetimeToUnix($row['firstaired'] ?? ($row['firstaired_ts'] ?? null)),
|
||||
'size' => (int) ($row['size'] ?? 0),
|
||||
'postdate_ts' => $postdateTs,
|
||||
'adddate_ts' => $adddateTs,
|
||||
'totalpart' => (int) ($row['totalpart'] ?? 0),
|
||||
'grabs' => (int) ($row['grabs'] ?? 0),
|
||||
'comments' => (int) ($row['comments'] ?? 0),
|
||||
'passwordstatus' => (int) ($row['passwordstatus'] ?? 0),
|
||||
'groups_id' => (int) ($row['groups_id'] ?? 0),
|
||||
'nzbstatus' => (int) ($row['nzbstatus'] ?? 0),
|
||||
'nfostatus' => (int) ($row['nfostatus'] ?? 0),
|
||||
'haspreview' => (int) ($row['haspreview'] ?? 0),
|
||||
'jpgstatus' => (int) ($row['jpgstatus'] ?? 0),
|
||||
'nfoid' => (int) ($row['nfoid'] ?? 0),
|
||||
'reid' => (int) ($row['reid'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an indexed document into the date/attribute names expected by the views.
|
||||
*
|
||||
* @param array<string, mixed> $document
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function toReleaseRow(array $document): array
|
||||
{
|
||||
$row = self::normalizeForBulk($document);
|
||||
$row['postdate'] = self::unixToDatetime((int) $row['postdate_ts']);
|
||||
$row['adddate'] = self::unixToDatetime((int) $row['adddate_ts']);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function datetimeToUnix(mixed $value): int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
@@ -77,4 +133,14 @@ final class ReleaseSearchIndexDocument
|
||||
|
||||
return $ts !== false ? $ts : 0;
|
||||
}
|
||||
|
||||
private static function plainSearchName(mixed $value): string
|
||||
{
|
||||
return trim((string) preg_replace('/[._-]+/', ' ', (string) $value));
|
||||
}
|
||||
|
||||
private static function unixToDatetime(int $timestamp): ?string
|
||||
{
|
||||
return $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Support;
|
||||
|
||||
use App\Facades\Search;
|
||||
use App\Models\Category;
|
||||
use App\Models\Release;
|
||||
use App\Observers\ReleaseObserver;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Re-sync releases_rt / ES release documents after query-builder or raw SQL updates
|
||||
@@ -27,6 +29,61 @@ final class ReleaseSearchIndexSync
|
||||
}
|
||||
}
|
||||
|
||||
public static function forMovieInfo(int $movieInfoId): void
|
||||
{
|
||||
if ($movieInfoId > 0) {
|
||||
self::forQuery(Release::query()->where('movieinfo_id', $movieInfoId));
|
||||
}
|
||||
}
|
||||
|
||||
public static function forVideo(int $videoId): void
|
||||
{
|
||||
if ($videoId > 0) {
|
||||
self::forQuery(Release::query()->where('videos_id', $videoId));
|
||||
}
|
||||
}
|
||||
|
||||
public static function forCategory(int $categoryId): void
|
||||
{
|
||||
if ($categoryId > 0) {
|
||||
self::forQuery(Release::query()->where('categories_id', $categoryId));
|
||||
}
|
||||
}
|
||||
|
||||
public static function forRootCategory(int $rootCategoryId): void
|
||||
{
|
||||
if ($rootCategoryId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$categoryIds = Category::query()
|
||||
->where('root_categories_id', $rootCategoryId)
|
||||
->pluck('id');
|
||||
|
||||
if ($categoryIds->isNotEmpty()) {
|
||||
self::forQuery(Release::query()->whereIn('categories_id', $categoryIds));
|
||||
}
|
||||
}
|
||||
|
||||
public static function forQueryGroup(int $groupId): void
|
||||
{
|
||||
if ($groupId > 0) {
|
||||
self::forQuery(Release::query()->where('groups_id', $groupId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Release> $query
|
||||
*/
|
||||
private static function forQuery($query): void
|
||||
{
|
||||
$query->select('releases.id')->orderBy('releases.id')->chunkById(500, function ($releases): bool {
|
||||
self::forIds($releases->pluck('id'));
|
||||
|
||||
return true;
|
||||
}, 'releases.id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reindex every release (chunked). Use after mass UPDATEs that match an optional raw WHERE suffix.
|
||||
*
|
||||
|
||||
@@ -50,6 +50,8 @@ return [
|
||||
'anime' => env('MANTICORESEARCH_INDEX_ANIME', 'anime_rt'),
|
||||
],
|
||||
'max_matches' => env('MANTICORESEARCH_MAX_MATCHES', 10000),
|
||||
'retry_attempts' => (int) env('MANTICORESEARCH_RETRY_ATTEMPTS', 2),
|
||||
'retry_delay_ms' => (int) env('MANTICORESEARCH_RETRY_DELAY_MS', 100),
|
||||
'cache_minutes' => env('MANTICORESEARCH_CACHE_MINUTES', 5),
|
||||
'autocomplete' => [
|
||||
'enabled' => env('MANTICORESEARCH_AUTOCOMPLETE_ENABLED', true),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('search_index_failures')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('search_index_failures', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('release_id')->unique();
|
||||
$table->string('operation', 32)->default('upsert');
|
||||
$table->unsignedInteger('attempts')->default(0);
|
||||
$table->text('last_error')->nullable();
|
||||
$table->timestamp('next_attempt_at')->nullable()->index();
|
||||
$table->timestamp('resolved_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('search_index_failures');
|
||||
}
|
||||
};
|
||||
@@ -928,7 +928,7 @@ parameters:
|
||||
-
|
||||
message: '#^Parameter \#1 \$params of method Elastic\\Elasticsearch\\Client\:\:delete\(\) expects array\{id\: string, index\: string, wait_for_active_shards\?\: string, refresh\?\: string, routing\?\: string, timeout\?\: int\|string, if_seq_no\?\: int, if_primary_term\?\: int, \.\.\.\}\|null, array\{index\: string, id\: int\<min, \-1\>\|int\<1, max\>\} given\.$#'
|
||||
identifier: argument.type
|
||||
count: 4
|
||||
count: 3
|
||||
path: app/Services/Search/Drivers/ElasticSearchDriver.php
|
||||
|
||||
-
|
||||
@@ -937,12 +937,6 @@ parameters:
|
||||
count: 2
|
||||
path: app/Services/Search/Drivers/ElasticSearchDriver.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Services\\Search\\Drivers\\ManticoreSearchDriver\:\:phrasesToSearchArray\(\) return type has no value type specified in iterable type array\.$#'
|
||||
identifier: missingType.iterableValue
|
||||
count: 1
|
||||
path: app/Services/Search/Drivers/ManticoreSearchDriver.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Services\\Search\\Drivers\\ManticoreSearchDriver\:\:searchReleasesByMultipleExternalIds\(\) should return array\<string, mixed\> but returns list\<int\>\.$#'
|
||||
identifier: return.type
|
||||
|
||||
@@ -61,6 +61,7 @@ Schedule::call(function () {
|
||||
// Check tmux health and auto-restart if monitor pane is dead
|
||||
Schedule::command('tmux:health-check --auto-restart')->everyThirtyMinutes()->withoutOverlapping();
|
||||
Schedule::command('nntmux:check-service-health')->everyMinute()->withoutOverlapping();
|
||||
Schedule::command('nntmux:search-repair --limit=100')->everyMinute()->withoutOverlapping();
|
||||
// Keep the admin dashboard snapshot (Cache::flexible) hot so admins never pay
|
||||
// the cold-cache cost when opening /admin/index.
|
||||
Schedule::command('admin:warm-dashboard')->everyMinute()->withoutOverlapping();
|
||||
|
||||
@@ -18,6 +18,7 @@ class ManticoreSearchQueryTest extends TestCase
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function negationQueriesProvider(): array
|
||||
{
|
||||
return [
|
||||
@@ -37,6 +38,7 @@ class ManticoreSearchQueryTest extends TestCase
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function phraseQueriesProvider(): array
|
||||
{
|
||||
return [
|
||||
@@ -55,6 +57,7 @@ class ManticoreSearchQueryTest extends TestCase
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function orQueriesProvider(): array
|
||||
{
|
||||
return [
|
||||
@@ -71,6 +74,7 @@ class ManticoreSearchQueryTest extends TestCase
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function wildcardQueriesProvider(): array
|
||||
{
|
||||
return [
|
||||
@@ -88,6 +92,7 @@ class ManticoreSearchQueryTest extends TestCase
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function groupingQueriesProvider(): array
|
||||
{
|
||||
return [
|
||||
@@ -104,6 +109,7 @@ class ManticoreSearchQueryTest extends TestCase
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function escapingQueriesProvider(): array
|
||||
{
|
||||
return [
|
||||
@@ -143,6 +149,24 @@ class ManticoreSearchQueryTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_adds_a_normalized_release_name_query_for_punctuation_separators(): void
|
||||
{
|
||||
$reflection = new ReflectionClass(ManticoreSearchDriver::class);
|
||||
$method = $reflection->getMethod('scopeReleaseSearchQuery');
|
||||
|
||||
$query = $method->invoke(
|
||||
null,
|
||||
'Love.Is.A.Dogs.Best.Friend.2025.1080p.WEB-DL.HEVC.x265-BONE',
|
||||
ManticoreSearchDriver::prepareUserSearchQuery('Love.Is.A.Dogs.Best.Friend.2025.1080p.WEB-DL.HEVC.x265-BONE'),
|
||||
'@searchname'
|
||||
);
|
||||
|
||||
$this->assertStringContainsString('@searchname (Love.Is.A.Dogs.Best.Friend.2025.1080p.WEB\\-DL.HEVC.x265\\-BONE)', $query);
|
||||
$this->assertStringContainsString('@searchname (Love Is A Dogs Best Friend 2025 1080p WEB DL HEVC x265 BONE)', $query);
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function edgeCaseQueriesProvider(): array
|
||||
{
|
||||
return [
|
||||
@@ -163,6 +187,7 @@ class ManticoreSearchQueryTest extends TestCase
|
||||
$this->assertStringContainsString('circus', $result);
|
||||
}
|
||||
|
||||
/** @param array<string, string>|string $input */
|
||||
#[Test]
|
||||
#[DataProvider('negationDetectionProvider')]
|
||||
public function it_detects_negation_operators(array|string $input, bool $expected): void
|
||||
@@ -171,6 +196,7 @@ class ManticoreSearchQueryTest extends TestCase
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
/** @return array<string, array{array<string, string>|string, bool}> */
|
||||
public static function negationDetectionProvider(): array
|
||||
{
|
||||
return [
|
||||
@@ -244,7 +270,7 @@ class ManticoreSearchQueryTest extends TestCase
|
||||
|
||||
$this->assertIsString($driverSource);
|
||||
$this->assertStringContainsString(
|
||||
"\$terms[] = '@@relaxed '.self::scopePreparedQueryToField(\$prepared, '@'.\$key);",
|
||||
"\$terms[] = '@@relaxed '.self::scopeReleaseSearchQuery((string) \$value, \$prepared, '@'.\$key);",
|
||||
$driverSource
|
||||
);
|
||||
$this->assertStringNotContainsString(
|
||||
@@ -260,7 +286,7 @@ class ManticoreSearchQueryTest extends TestCase
|
||||
|
||||
$this->assertIsString($driverSource);
|
||||
$this->assertStringContainsString(
|
||||
"\$searchExpr = '@@relaxed '.self::scopePreparedQueryToField(\$preparedSearch, '@searchname');",
|
||||
"\$searchExpr = '@@relaxed '.self::scopeReleaseSearchQuery(\$searchTerm, \$preparedSearch, '@searchname');",
|
||||
$driverSource
|
||||
);
|
||||
$this->assertStringNotContainsString(
|
||||
|
||||
@@ -121,7 +121,7 @@ class NntmuxSearchReconcileFetchIndexedIdsTest extends TestCase
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function sql(...$params): mixed
|
||||
public function sql(mixed ...$params): mixed
|
||||
{
|
||||
$this->capturedSql = $params[0] ?? null;
|
||||
|
||||
@@ -143,6 +143,9 @@ class NntmuxSearchReconcileFetchIndexedIdsTest extends TestCase
|
||||
$this->assertNotNull($client->capturedSql);
|
||||
$this->assertStringContainsString('LIMIT 500', $client->capturedSql);
|
||||
$this->assertStringContainsString('OPTION max_matches=500', $client->capturedSql);
|
||||
$driverSource = file_get_contents(__DIR__.'/../../app/Services/Search/Drivers/ManticoreSearchDriver.php');
|
||||
self::assertIsString($driverSource);
|
||||
self::assertStringContainsString('ORDER BY id ASC', $driverSource);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,7 +162,7 @@ class NntmuxSearchReconcileFetchIndexedIdsTest extends TestCase
|
||||
// Bypass parent ctor: we never hit the network in this test.
|
||||
}
|
||||
|
||||
public function sql(...$params): mixed
|
||||
public function sql(mixed ...$params): mixed
|
||||
{
|
||||
return $this->stubResponse;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,61 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ReleaseSearchIndexDocumentTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function normalize_builds_the_complete_public_projection_without_sensitive_fields(): void
|
||||
{
|
||||
$document = ReleaseSearchIndexDocument::normalize([
|
||||
'id' => '42',
|
||||
'guid' => 'guid-42',
|
||||
'name' => 'Release.Name',
|
||||
'searchname' => 'Release.Name',
|
||||
'fromname' => 'poster',
|
||||
'filename' => 'file.nzb',
|
||||
'categories_id' => '1',
|
||||
'category_name' => 'TV',
|
||||
'parent_category' => 'Video',
|
||||
'sub_category' => 'TV',
|
||||
'groups_id' => '2',
|
||||
'group_name' => 'alt.example',
|
||||
'tmdbid' => '123',
|
||||
'tv_episodes_id' => '456',
|
||||
'passwordstatus' => '-1',
|
||||
'comments' => '3',
|
||||
'postdate' => '2026-01-02 03:04:05',
|
||||
'adddate' => '2026-01-03 04:05:06',
|
||||
'nzb_password' => 'must-not-be-indexed',
|
||||
]);
|
||||
|
||||
self::assertSame(42, $document['id']);
|
||||
self::assertSame('Release Name', $document['plainsearchname']);
|
||||
self::assertSame(123, $document['tmdbid']);
|
||||
self::assertSame(456, $document['tv_episodes_id']);
|
||||
self::assertSame(-1, $document['passwordstatus']);
|
||||
self::assertGreaterThan(0, $document['postdate_ts']);
|
||||
self::assertGreaterThan(0, $document['adddate_ts']);
|
||||
self::assertArrayNotHasKey('nzb_password', $document);
|
||||
$documentFields = array_keys($document);
|
||||
$declaredFields = ReleaseSearchIndexDocument::fields();
|
||||
sort($documentFields);
|
||||
sort($declaredFields);
|
||||
self::assertSame($declaredFields, $documentFields);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function indexed_documents_can_be_hydrated_for_legacy_release_consumers(): void
|
||||
{
|
||||
$row = ReleaseSearchIndexDocument::toReleaseRow([
|
||||
'id' => 42,
|
||||
'postdate_ts' => 1767323045,
|
||||
'adddate_ts' => 1767413106,
|
||||
'name' => 'Release',
|
||||
]);
|
||||
|
||||
self::assertSame('2026-01-02 03:04:05', $row['postdate']);
|
||||
self::assertSame('2026-01-03 04:05:06', $row['adddate']);
|
||||
self::assertSame(42, $row['id']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function normalize_for_bulk_preserves_timestamps_when_row_is_already_normalized(): void
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Services\Search;
|
||||
|
||||
use App\Services\Search\Drivers\ManticoreSearchDriver;
|
||||
use Manticoresearch\Client;
|
||||
use Manticoresearch\Table;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ManticoreDeleteReleasesTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function delete_releases_uses_document_api_not_sql(): void
|
||||
{
|
||||
$config = [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 9308,
|
||||
'indexes' => [
|
||||
'releases' => 'releases_rt',
|
||||
'predb' => 'predb_rt',
|
||||
],
|
||||
];
|
||||
|
||||
$table = $this->createMock(Table::class);
|
||||
$table->expects($this->once())
|
||||
->method('deleteDocumentsByIds')
|
||||
->with([149331415, 42]);
|
||||
|
||||
$client = $this->createMock(Client::class);
|
||||
$client->expects($this->once())
|
||||
->method('table')
|
||||
->with('releases_rt')
|
||||
->willReturn($table);
|
||||
$client->expects($this->never())->method('sql');
|
||||
|
||||
$driver = new ManticoreSearchDriver($config);
|
||||
$prop = new \ReflectionProperty(ManticoreSearchDriver::class, 'manticoreSearch');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($driver, $client);
|
||||
|
||||
$driver->deleteReleases([149331415, 42, 0, 149331415]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function delete_releases_skips_empty_id_list(): void
|
||||
{
|
||||
$config = [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 9308,
|
||||
'indexes' => [
|
||||
'releases' => 'releases_rt',
|
||||
'predb' => 'predb_rt',
|
||||
],
|
||||
];
|
||||
|
||||
$client = $this->createMock(Client::class);
|
||||
$client->expects($this->never())->method('table');
|
||||
$client->expects($this->never())->method('sql');
|
||||
|
||||
$driver = new ManticoreSearchDriver($config);
|
||||
$prop = new \ReflectionProperty(ManticoreSearchDriver::class, 'manticoreSearch');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($driver, $client);
|
||||
|
||||
$driver->deleteReleases([0, -1]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Services\Search;
|
||||
|
||||
use App\Services\Search\Drivers\ManticoreSearchDriver;
|
||||
use App\Support\ReleaseSearchIndexDocument;
|
||||
use Manticoresearch\Client;
|
||||
use Manticoresearch\Exceptions\ResponseException;
|
||||
use Manticoresearch\Request;
|
||||
@@ -130,4 +131,40 @@ final class ManticoreInsertRetryTest extends TestCase
|
||||
$ok = $refP->invoke($driver, $this->releaseRow(7));
|
||||
$this->assertFalse($ok);
|
||||
}
|
||||
|
||||
public function test_replace_release_document_preserves_pre_normalized_timestamps(): void
|
||||
{
|
||||
$config = [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 9308,
|
||||
'retry_attempts' => 1,
|
||||
'indexes' => ['releases' => 'releases_rt', 'predb' => 'predb_rt'],
|
||||
];
|
||||
$document = ReleaseSearchIndexDocument::normalize($this->releaseRow(42));
|
||||
|
||||
$table = $this->createMock(Table::class);
|
||||
$table->expects($this->once())
|
||||
->method('replaceDocument')
|
||||
->with(
|
||||
$this->callback(static fn (array $indexed): bool =>
|
||||
$indexed['postdate_ts'] === $document['postdate_ts']
|
||||
&& $indexed['adddate_ts'] === $document['adddate_ts']
|
||||
),
|
||||
42
|
||||
);
|
||||
|
||||
$client = $this->createMock(Client::class);
|
||||
$client->expects($this->once())
|
||||
->method('table')
|
||||
->with('releases_rt')
|
||||
->willReturn($table);
|
||||
|
||||
$driver = new ManticoreSearchDriver($config);
|
||||
$prop = new \ReflectionProperty(ManticoreSearchDriver::class, 'manticoreSearch');
|
||||
$prop->setValue($driver, $client);
|
||||
|
||||
$method = new ReflectionMethod(ManticoreSearchDriver::class, 'replaceReleaseDocumentWithRetry');
|
||||
|
||||
$this->assertTrue($method->invoke($driver, $document));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user