diff --git a/app/Console/Commands/DeleteReleases.php b/app/Console/Commands/DeleteReleases.php index d54f8d7cf..38354faac 100644 --- a/app/Console/Commands/DeleteReleases.php +++ b/app/Console/Commands/DeleteReleases.php @@ -2,8 +2,7 @@ namespace App\Console\Commands; -use App\Services\Search\ElasticSearchService; -use App\Services\Search\ManticoreSearchService; +use App\Facades\Search; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -147,8 +146,7 @@ class DeleteReleases extends Command DB::delete('DELETE FROM releases WHERE id = ?', [$releaseId]); // Delete from search indexes - app(ManticoreSearchService::class)->deleteRelease($releaseId); - app(ElasticSearchService::class)->deleteRelease($releaseId); + Search::deleteRelease($releaseId); $deleted++; diff --git a/app/Console/Commands/NntmuxOffsetPopulate.php b/app/Console/Commands/NntmuxOffsetPopulate.php index 1b13f76ec..d0ab8f274 100644 --- a/app/Console/Commands/NntmuxOffsetPopulate.php +++ b/app/Console/Commands/NntmuxOffsetPopulate.php @@ -2,9 +2,9 @@ namespace App\Console\Commands; +use App\Facades\Search; use App\Models\Predb; use App\Models\Release; -use App\Services\Search\ManticoreSearchService; use Exception; use Illuminate\Console\Command; use Illuminate\Support\Arr; @@ -276,9 +276,8 @@ class NntmuxOffsetPopulate extends Command private function clearIndex(string $engine, string $index): void { if ($engine === 'manticore') { - $manticore = app(ManticoreSearchService::class); $indexName = $index === 'releases' ? 'releases_rt' : 'predb_rt'; - $manticore->truncateRTIndex(Arr::wrap($indexName)); + Search::truncateIndex([$indexName]); $this->info("Truncated ManticoreSearch index: {$indexName}"); } else { // For ElasticSearch, just clear the data instead of recreating the index diff --git a/app/Console/Commands/NntmuxOffsetWorker.php b/app/Console/Commands/NntmuxOffsetWorker.php index 2e7640ed1..6c15f238a 100644 --- a/app/Console/Commands/NntmuxOffsetWorker.php +++ b/app/Console/Commands/NntmuxOffsetWorker.php @@ -2,9 +2,9 @@ namespace App\Console\Commands; +use App\Facades\Search; use App\Models\Predb; use App\Models\Release; -use App\Services\Search\ManticoreSearchService; use Exception; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -92,10 +92,9 @@ class NntmuxOffsetWorker extends Command $startTime = microtime(true); if ($engine === 'manticore') { - $manticore = app(ManticoreSearchService::class); $batchData = []; - $query->chunk($batchSize, function ($items) use ($manticore, $indexName, $transformer, &$processed, &$errors, &$batchData, $batchSize, $workerId) { + $query->chunk($batchSize, function ($items) use ($indexName, $transformer, &$processed, &$errors, &$batchData, $batchSize, $workerId) { $this->info("Worker {$workerId}: Processing chunk of {$items->count()} items"); foreach ($items as $item) { @@ -104,7 +103,7 @@ class NntmuxOffsetWorker extends Command $processed++; if (count($batchData) >= $batchSize) { - $this->processManticoreBatch($manticore, $indexName, $batchData, $workerId); + $this->processSearchBatch($batchData, $workerId); $this->info("Worker {$workerId}: Inserted batch of ".count($batchData).' records'); $batchData = []; } @@ -117,7 +116,7 @@ class NntmuxOffsetWorker extends Command // Process remaining items if (! empty($batchData)) { - $this->processManticoreBatch($manticore, $indexName, $batchData, $workerId); + $this->processSearchBatch($batchData, $workerId); $this->info("Worker {$workerId}: Inserted final batch of ".count($batchData).' records'); } @@ -246,21 +245,21 @@ class NntmuxOffsetWorker extends Command } /** - * Process ManticoreSearch batch + * Process search batch */ - private function processManticoreBatch(ManticoreSearchService $manticore, string $indexName, array $data, int $workerId): void + private function processSearchBatch(array $data, int $workerId): void { $retries = 3; $attempt = 0; while ($attempt < $retries) { try { - $manticore->manticoreSearch->table($indexName)->replaceDocuments($data); + Search::bulkInsertReleases($data); break; } catch (Exception $e) { $attempt++; if ($attempt >= $retries) { - throw new Exception("Worker {$workerId}: Failed to process ManticoreSearch batch after {$retries} attempts: {$e->getMessage()}"); + throw new Exception("Worker {$workerId}: Failed to process search batch after {$retries} attempts: {$e->getMessage()}"); } usleep(200000); // 200ms delay before retry } diff --git a/app/Console/Commands/NntmuxPopulateSearchIndexes.php b/app/Console/Commands/NntmuxPopulateSearchIndexes.php index e1eeded62..bb6f9be74 100644 --- a/app/Console/Commands/NntmuxPopulateSearchIndexes.php +++ b/app/Console/Commands/NntmuxPopulateSearchIndexes.php @@ -2,9 +2,9 @@ namespace App\Console\Commands; +use App\Facades\Search; use App\Models\Predb; use App\Models\Release; -use App\Services\Search\ManticoreSearchService; use Exception; use Illuminate\Console\Command; use Illuminate\Support\Arr; @@ -113,10 +113,10 @@ class NntmuxPopulateSearchIndexes extends Command */ private function handleOptimize(): int { - $this->info('Optimizing ManticoreSearch indexes...'); + $this->info('Optimizing search indexes...'); try { - app(ManticoreSearchService::class)->optimizeRTIndex(); + Search::optimizeIndex(); $this->info('Optimization completed successfully!'); return Command::SUCCESS; @@ -155,10 +155,9 @@ class NntmuxPopulateSearchIndexes extends Command private function manticoreReleases(): int { - $manticore = app(ManticoreSearchService::class); $indexName = 'releases_rt'; - $manticore->truncateRTIndex(Arr::wrap($indexName)); + Search::truncateIndex([$indexName]); $total = Release::count(); if (! $total) { @@ -208,10 +207,9 @@ class NntmuxPopulateSearchIndexes extends Command */ private function manticorePredb(): int { - $manticore = app(ManticoreSearchService::class); $indexName = 'predb_rt'; - $manticore->truncateRTIndex([$indexName]); + Search::truncateIndex([$indexName]); $total = Predb::count(); if (! $total) { @@ -244,7 +242,6 @@ class NntmuxPopulateSearchIndexes extends Command */ private function processManticoreData(string $indexName, int $total, $query, callable $transformer): int { - $manticore = app(ManticoreSearchService::class); $chunkSize = $this->getChunkSize(); $batchSize = $this->getBatchSize(); @@ -252,7 +249,7 @@ class NntmuxPopulateSearchIndexes extends Command $this->setGroupConcatMaxLen(); $this->info(sprintf( - "Populating ManticoreSearch index '%s' with %s rows using chunks of %s and batch size of %s.", + "Populating search index '%s' with %s rows using chunks of %s and batch size of %s.", $indexName, number_format($total), number_format($chunkSize), @@ -268,7 +265,7 @@ class NntmuxPopulateSearchIndexes extends Command $batchData = []; try { - $query->chunk($chunkSize, function ($items) use ($manticore, $indexName, $transformer, $bar, &$processedCount, &$errorCount, $batchSize, &$batchData) { + $query->chunk($chunkSize, function ($items) use ($indexName, $transformer, $bar, &$processedCount, &$errorCount, $batchSize, &$batchData) { foreach ($items as $item) { try { $batchData[] = $transformer($item); @@ -276,7 +273,7 @@ class NntmuxPopulateSearchIndexes extends Command // Process in optimized batch sizes if (count($batchData) >= $batchSize) { - $this->processBatch($manticore, $indexName, $batchData); + $this->processBatch($indexName, $batchData); $batchData = []; } } catch (Exception $e) { @@ -291,7 +288,7 @@ class NntmuxPopulateSearchIndexes extends Command // Process remaining items if (! empty($batchData)) { - $this->processBatch($manticore, $indexName, $batchData); + $this->processBatch($indexName, $batchData); } $bar->finish(); @@ -300,7 +297,7 @@ class NntmuxPopulateSearchIndexes extends Command if ($errorCount > 0) { $this->warn("Completed with {$errorCount} errors out of {$processedCount} processed items."); } else { - $this->info('ManticoreSearch population completed successfully!'); + $this->info('Search index population completed successfully!'); } return Command::SUCCESS; @@ -485,16 +482,16 @@ class NntmuxPopulateSearchIndexes extends Command } /** - * Process ManticoreSearch batch with retry logic + * Process search index batch with retry logic */ - private function processBatch(ManticoreSearchService $manticore, string $indexName, array $data): void + private function processBatch(string $indexName, array $data): void { $retries = 3; $attempt = 0; while ($attempt < $retries) { try { - $manticore->manticoreSearch->table($indexName)->replaceDocuments($data); + Search::bulkInsertReleases($data); break; } catch (Exception $e) { $attempt++; diff --git a/app/Console/Commands/NntmuxRemoveBadReleases.php b/app/Console/Commands/NntmuxRemoveBadReleases.php index 79f80c6b6..1ca06b7d2 100644 --- a/app/Console/Commands/NntmuxRemoveBadReleases.php +++ b/app/Console/Commands/NntmuxRemoveBadReleases.php @@ -2,13 +2,11 @@ namespace App\Console\Commands; +use App\Facades\Search; use App\Models\Release; use App\Models\ReleaseFile; use App\Services\ReleaseImageService; -use App\Services\Search\ManticoreSearchService; use Blacklight\NZB; -use Elasticsearch; -use Elasticsearch\Common\Exceptions\Missing404Exception; use Illuminate\Console\Command; use Illuminate\Support\Facades\File; @@ -52,21 +50,8 @@ class NntmuxRemoveBadReleases extends Command $nzbPath = (new NZB)->getNZBPath($badRelease->guid); File::delete($nzbPath); (new ReleaseImageService)->delete($badRelease->guid); - if (config('nntmux.elasticsearch_enabled') === true) { - $params = [ - 'index' => 'releases', - 'id' => $badRelease->id, - ]; - - try { - Elasticsearch::delete($params); - } catch (Missing404Exception $e) { - // we do nothing here just catch the error, we don't care if release is missing from ES, we are deleting it anyway - } - } else { - // Delete from Manticore - app(ManticoreSearchService::class)->deleteRelease($badRelease->id); - } + // Delete from search index + Search::deleteRelease($badRelease->id); $badRelease->delete(); } Release::query()->where('passwordstatus', '=', -2)->delete(); diff --git a/app/Console/Commands/NntmuxResetDb.php b/app/Console/Commands/NntmuxResetDb.php index 383073072..cbe02e819 100644 --- a/app/Console/Commands/NntmuxResetDb.php +++ b/app/Console/Commands/NntmuxResetDb.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; +use App\Facades\Search; use App\Models\UsenetGroup; -use App\Services\Search\ManticoreSearchService; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; @@ -171,7 +171,7 @@ class NntmuxResetDb extends Command $this->info('All done! ElasticSearch indexes are deleted and recreated.'); } else { - app(ManticoreSearchService::class)->truncateRTIndex(['releases_rt', 'predb_rt']); + Search::truncateIndex(['releases_rt', 'predb_rt']); } $this->info('Deleting nzbfiles subfolders.'); diff --git a/app/Facades/Search.php b/app/Facades/Search.php new file mode 100644 index 000000000..2a9e2fc86 --- /dev/null +++ b/app/Facades/Search.php @@ -0,0 +1,43 @@ +select('predb.*', 'releases.guid') ->orderByDesc('predb.predate'); if (! empty($search)) { - if (config('nntmux.elasticsearch_enabled') === true) { - $ids = app(ElasticSearchService::class)->predbIndexSearch($search); - } else { - $manticore = app(ManticoreSearchService::class); - $ids = Arr::get($manticore->searchIndexes('predb_rt', $search, ['title']), 'id'); - } + $ids = Search::searchPredb($search); $sql->whereIn('predb.id', $ids); } diff --git a/app/Models/Release.php b/app/Models/Release.php index b05ed176d..6ec4f3f51 100644 --- a/app/Models/Release.php +++ b/app/Models/Release.php @@ -2,8 +2,7 @@ namespace App\Models; -use App\Services\Search\ElasticSearchService; -use App\Services\Search\ManticoreSearchService; +use App\Facades\Search; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -135,11 +134,7 @@ class Release extends Model ] ); - if (config('nntmux.elasticsearch_enabled') === true) { - app(ElasticSearchService::class)->insertRelease($parameters); - } else { - app(ManticoreSearchService::class)->insertRelease($parameters); - } + Search::insertRelease($parameters); return $parameters['id']; } @@ -172,11 +167,7 @@ class Release extends Model ] ); - if (config('nntmux.elasticsearch_enabled') === true) { - app(ElasticSearchService::class)->updateRelease($id); - } else { - app(ManticoreSearchService::class)->updateRelease($id); - } + Search::updateRelease($id); } /** @@ -396,14 +387,7 @@ class Release extends Model preg_match('/(^\w+[-_. ].+?\.(\d+p)).+/i', $rel['searchname'], $similar); if (! empty($similar)) { - if (config('nntmux.elasticsearch_enabled') === true) { - $searchResult = app(ElasticSearchService::class)->indexSearch($similar[1], 10); - } else { - $searchResult = app(ManticoreSearchService::class)->searchIndexes('releases_rt', $similar[1]); - if (! empty($searchResult)) { - $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); - } - } + $searchResult = Search::searchReleases($similar[1], 10); if (empty($searchResult)) { return false; diff --git a/app/Models/ReleaseFile.php b/app/Models/ReleaseFile.php index 0256006b3..43c0efe6e 100644 --- a/app/Models/ReleaseFile.php +++ b/app/Models/ReleaseFile.php @@ -2,8 +2,7 @@ namespace App\Models; -use App\Services\Search\ElasticSearchService; -use App\Services\Search\ManticoreSearchService; +use App\Facades\Search; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; @@ -125,11 +124,7 @@ class ReleaseFile extends Model if (\strlen($hash) === 32) { ParHash::insertOrIgnore(['releases_id' => $id, 'hash' => $hash]); } - if (config('nntmux.elasticsearch_enabled') === true) { - app(ElasticSearchService::class)->updateRelease($id); - } else { - app(ManticoreSearchService::class)->updateRelease($id); - } + Search::updateRelease($id); } return $insert ?? 0; diff --git a/app/Providers/SearchServiceProvider.php b/app/Providers/SearchServiceProvider.php index 1c52d2b2d..0084b2bee 100644 --- a/app/Providers/SearchServiceProvider.php +++ b/app/Providers/SearchServiceProvider.php @@ -2,9 +2,12 @@ namespace App\Providers; +use App\Services\Search\Contracts\SearchDriverInterface; use App\Services\Search\Contracts\SearchServiceInterface; -use App\Services\Search\ElasticSearchService; -use App\Services\Search\ManticoreSearchService; +use App\Services\Search\Drivers\ElasticSearchDriver; +use App\Services\Search\Drivers\ManticoreSearchDriver; +use App\Services\Search\SearchService; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; class SearchServiceProvider extends ServiceProvider @@ -14,27 +17,44 @@ class SearchServiceProvider extends ServiceProvider */ public function register(): void { - // Register ManticoreSearchService as singleton - $this->app->singleton(ManticoreSearchService::class, function ($app) { - return new ManticoreSearchService(); + // Merge the search configuration + $this->mergeConfigFrom( + base_path('config/search.php'), + 'search' + ); + + // Register the SearchService as a singleton (Manager pattern) + $this->app->singleton(SearchService::class, function (Application $app) { + return new SearchService($app); }); - // Register ElasticSearchService as singleton - $this->app->singleton(ElasticSearchService::class, function ($app) { - return new ElasticSearchService(); + // Bind the interface to the SearchService + $this->app->singleton(SearchServiceInterface::class, function (Application $app) { + return $app->make(SearchService::class); }); - // Bind the interface to the appropriate implementation based on config - $this->app->singleton(SearchServiceInterface::class, function ($app) { - if (config('nntmux.elasticsearch_enabled') === true) { - return $app->make(ElasticSearchService::class); - } - - return $app->make(ManticoreSearchService::class); + // Bind SearchDriverInterface to current driver + $this->app->bind(SearchDriverInterface::class, function (Application $app) { + return $app->make(SearchService::class)->driver(); }); - // Register alias for ManticoreSearch (for backward compatibility) - $this->app->alias(ManticoreSearchService::class, 'manticore'); + // Register individual drivers as singletons for direct access + $this->app->singleton(ManticoreSearchDriver::class, function (Application $app) { + $config = config('search.drivers.manticore', []); + + return new ManticoreSearchDriver($config); + }); + + $this->app->singleton(ElasticSearchDriver::class, function (Application $app) { + $config = config('search.drivers.elasticsearch', []); + + return new ElasticSearchDriver($config); + }); + + // Register aliases (using prefixed names to avoid conflicts with other packages) + $this->app->alias(SearchService::class, 'search'); + $this->app->alias(ManticoreSearchDriver::class, 'search.manticore'); + $this->app->alias(ElasticSearchDriver::class, 'search.elasticsearch'); } /** diff --git a/app/Services/AdditionalProcessing/MediaExtractionService.php b/app/Services/AdditionalProcessing/MediaExtractionService.php index e3f0a4b9b..a7685dabe 100644 --- a/app/Services/AdditionalProcessing/MediaExtractionService.php +++ b/app/Services/AdditionalProcessing/MediaExtractionService.php @@ -2,14 +2,13 @@ namespace App\Services\AdditionalProcessing; +use App\Facades\Search; use App\Models\Category; use App\Models\Release; use App\Services\AdditionalProcessing\Config\ProcessingConfiguration; use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext; use App\Services\Categorization\CategorizationService; use App\Services\ReleaseImageService; -use App\Services\Search\ElasticSearchService; -use App\Services\Search\ManticoreSearchService; use App\Services\ReleaseExtraService; use FFMpeg\Coordinate\Dimension; use FFMpeg\Coordinate\TimeCode; @@ -31,8 +30,6 @@ class MediaExtractionService private ?FFMpeg $ffmpeg = null; private ?FFProbe $ffprobe = null; private ?MediaInfo $mediaInfo = null; - private ?ManticoreSearchService $manticore = null; - private ?ElasticSearchService $elasticsearch = null; public function __construct( private readonly ProcessingConfiguration $config, @@ -323,11 +320,7 @@ class MediaExtractionService 'proc_pp' => 1, ]); - if ($this->config->elasticsearchEnabled) { - $this->elasticsearch()->updateRelease($context->release->id); - } else { - $this->manticore()->updateRelease($context->release->id); - } + Search::updateRelease($context->release->id); if ($this->config->echoCLI) { NameFixer::echoChangedReleaseName([ diff --git a/app/Services/AdditionalProcessing/ReleaseFileManager.php b/app/Services/AdditionalProcessing/ReleaseFileManager.php index 7b5de7bd4..d5032672d 100644 --- a/app/Services/AdditionalProcessing/ReleaseFileManager.php +++ b/app/Services/AdditionalProcessing/ReleaseFileManager.php @@ -7,8 +7,6 @@ use App\Models\Predb; use App\Models\Release; use App\Models\ReleaseFile; use App\Services\ReleaseImageService; -use App\Services\Search\ElasticSearchService; -use App\Services\Search\ManticoreSearchService; use App\Services\Releases\ReleaseBrowseService; use App\Services\AdditionalProcessing\Config\ProcessingConfiguration; use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext; @@ -29,8 +27,6 @@ use Illuminate\Support\Facades\Log; */ class ReleaseFileManager { - private ?ManticoreSearchService $manticore = null; - private ?ElasticSearchService $elasticsearch = null; public function __construct( private readonly ProcessingConfiguration $config, @@ -145,11 +141,7 @@ class ReleaseFileManager */ public function updateSearchIndex(int $releaseId): void { - if ($this->config->elasticsearchEnabled) { - $this->elasticsearch()->updateRelease($releaseId); - } else { - $this->manticore()->updateRelease($releaseId); - } + \App\Facades\Search::updateRelease($releaseId); } /** @@ -244,11 +236,7 @@ class ReleaseFileManager // Delete from search index try { - if ($this->config->elasticsearchEnabled) { - $this->elasticsearch()->deleteRelease($id); - } else { - $this->manticore()->deleteRelease(['i' => $id, 'g' => $guid]); - } + \App\Facades\Search::deleteRelease($id); } catch (\Throwable) { // Ignore } @@ -590,21 +578,5 @@ class ReleaseFileManager return $hasGroupSuffix || ($hasTV && $hasQuality) || ($hasYear && ($hasQuality || $hasTV)) || $hasXXX; } - - private function manticore(): ManticoreSearchService - { - if ($this->manticore === null) { - $this->manticore = app(ManticoreSearchService::class); - } - return $this->manticore; - } - - private function elasticsearch(): ElasticSearchService - { - if ($this->elasticsearch === null) { - $this->elasticsearch = app(ElasticSearchService::class); - } - return $this->elasticsearch; - } } diff --git a/app/Services/IRCScraper.php b/app/Services/IRCScraper.php index d5bc2adb1..5e6ab2845 100644 --- a/app/Services/IRCScraper.php +++ b/app/Services/IRCScraper.php @@ -2,10 +2,9 @@ namespace App\Services; +use App\Facades\Search; use App\Models\Predb; use App\Models\UsenetGroup; -use App\Services\Search\ElasticSearchService; -use App\Services\Search\ManticoreSearchService; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -51,9 +50,6 @@ class IRCScraper extends IRCClient */ protected string|false $_titleIgnoreRegex = false; - protected ManticoreSearchService $manticoreSearch; - - private ElasticSearchService $elasticsearch; /** * Construct. @@ -108,8 +104,6 @@ class IRCScraper extends IRCClient $this->_titleIgnoreRegex = (string) config('irc_settings.scrape_irc_title_ignore'); } - $this->elasticsearch = app(ElasticSearchService::class); - $this->manticoreSearch = app(ManticoreSearchService::class); $this->_groupList = []; $this->_silent = $silent; @@ -384,11 +378,7 @@ class IRCScraper extends IRCClient 'source' => $this->_curPre['source'] ?? null, ]; - if (config('nntmux.elasticsearch_enabled') === true) { - $this->elasticsearch->insertPreDb($parameters); - } else { - $this->manticoreSearch->insertPredb($parameters); - } + Search::insertPredb($parameters); $this->_doEcho(true); } catch (\Exception $e) { @@ -448,11 +438,7 @@ class IRCScraper extends IRCClient 'source' => $this->_curPre['source'] ?? null, ]; - if (config('nntmux.elasticsearch_enabled') === true) { - $this->elasticsearch->updatePreDb($parameters); - } else { - $this->manticoreSearch->updatePreDb($parameters); - } + Search::updatePreDb($parameters); } $this->_doEcho(false); diff --git a/app/Services/MediaProcessingService.php b/app/Services/MediaProcessingService.php index a160e9067..976474133 100644 --- a/app/Services/MediaProcessingService.php +++ b/app/Services/MediaProcessingService.php @@ -2,11 +2,10 @@ namespace App\Services; +use App\Facades\Search; use App\Models\Category; use App\Models\Release; use App\Services\Categorization\CategorizationService; -use App\Services\Search\ElasticSearchService; -use App\Services\Search\ManticoreSearchService; use FFMpeg\Coordinate\Dimension; use FFMpeg\Coordinate\TimeCode; use FFMpeg\FFMpeg; @@ -26,8 +25,6 @@ class MediaProcessingService private readonly MediaInfo $mediaInfo, private readonly ReleaseImageService $releaseImage, private readonly ReleaseExtraService $releaseExtra, - private readonly ManticoreSearchService $manticore, - private readonly ElasticSearchService $elasticsearch, private readonly CategorizationService $categorize, ) {} @@ -283,11 +280,7 @@ class MediaProcessingService 'isrenamed' => 1, 'proc_pp' => 1, ]); - if (config('nntmux.elasticsearch_enabled') === true) { - $this->elasticsearch->updateRelease($release->id); - } else { - $this->manticore->updateRelease($release->id); - } + Search::updateRelease($release->id); } $this->releaseExtra->addFromXml($release->id, $xmlArray); $retVal = true; diff --git a/app/Services/NameFixing/NameFixingService.php b/app/Services/NameFixing/NameFixingService.php index 0bfadbdbf..e0fab2489 100644 --- a/app/Services/NameFixing/NameFixingService.php +++ b/app/Services/NameFixing/NameFixingService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services\NameFixing; +use App\Facades\Search; use App\Models\Category; use App\Models\Release; use App\Services\NameFixing\Contracts\NameSourceFixerInterface; @@ -11,8 +12,6 @@ use App\Services\NameFixing\DTO\NameFixResult; use App\Services\NameFixing\Extractors\NfoNameExtractor; use App\Services\NameFixing\Extractors\FileNameExtractor; use App\Services\NNTP\NNTPService; -use App\Services\Search\ElasticSearchService; -use App\Services\Search\ManticoreSearchService; use Blacklight\ColorCLI; use Illuminate\Support\Arr; @@ -50,8 +49,6 @@ class NameFixingService protected FileNameExtractor $fileExtractor; protected FileNameCleaner $fileNameCleaner; protected FilePrioritizer $filePrioritizer; - protected ManticoreSearchService $manticore; - protected ElasticSearchService $elasticsearch; protected ColorCLI $colorCLI; protected bool $echoOutput; @@ -70,8 +67,6 @@ class NameFixingService ?FileNameExtractor $fileExtractor = null, ?FileNameCleaner $fileNameCleaner = null, ?FilePrioritizer $filePrioritizer = null, - ?ManticoreSearchService $manticore = null, - ?ElasticSearchService $elasticsearch = null, ?ColorCLI $colorCLI = null ) { $this->updateService = $updateService ?? new ReleaseUpdateService(); @@ -80,8 +75,6 @@ class NameFixingService $this->fileExtractor = $fileExtractor ?? new FileNameExtractor(); $this->fileNameCleaner = $fileNameCleaner ?? new FileNameCleaner(); $this->filePrioritizer = $filePrioritizer ?? new FilePrioritizer(); - $this->manticore = $manticore ?? app(ManticoreSearchService::class); - $this->elasticsearch = $elasticsearch ?? app(ElasticSearchService::class); $this->colorCLI = $colorCLI ?? new ColorCLI(); $this->echoOutput = config('nntmux.echocli'); @@ -758,40 +751,21 @@ class NameFixingService return false; } - if (config('nntmux.elasticsearch_enabled') === true) { - $results = $this->elasticsearch->searchPreDb($fileName); - foreach ($results as $hit) { - if (!empty($hit)) { - $this->updateService->updateRelease( - $release, - $hit['title'], - 'PreDb: Filename match', - $echo, - $type, - $nameStatus, - $show, - $hit['id'] - ); - return true; - } - } - } else { - $predbSearch = Arr::get($this->manticore->searchIndexes('predb_rt', $fileName, ['filename', 'title']), 'data'); - if (!empty($predbSearch)) { - foreach ($predbSearch as $hit) { - if (!empty($hit)) { - $this->updateService->updateRelease( - $release, - $hit['title'], - 'PreDb: Filename match', - $echo, - $type, - $nameStatus, - $show - ); - return true; - } - } + $results = Search::searchPredb($fileName); + foreach ($results as $hit) { + if (!empty($hit)) { + $hitData = is_array($hit) ? $hit : (array)$hit; + $this->updateService->updateRelease( + $release, + $hitData['title'] ?? '', + 'PreDb: Filename match', + $echo, + $type, + $nameStatus, + $show, + $hitData['id'] ?? null + ); + return true; } } @@ -809,40 +783,21 @@ class NameFixingService return false; } - if (config('nntmux.elasticsearch_enabled') === true) { - $results = $this->elasticsearch->searchPreDb($fileName); - foreach ($results as $hit) { - if (!empty($hit)) { - $this->updateService->updateRelease( - $release, - $hit['title'], - 'PreDb: Title match', - $echo, - $type, - $nameStatus, - $show, - $hit['id'] - ); - return true; - } - } - } else { - $results = Arr::get($this->manticore->searchIndexes('predb_rt', $fileName, ['title']), 'data'); - if (!empty($results)) { - foreach ($results as $hit) { - if (!empty($hit)) { - $this->updateService->updateRelease( - $release, - $hit['title'], - 'PreDb: Title match', - $echo, - $type, - $nameStatus, - $show - ); - return true; - } - } + $results = Search::searchPredb($fileName); + foreach ($results as $hit) { + if (!empty($hit)) { + $hitData = is_array($hit) ? $hit : (array)$hit; + $this->updateService->updateRelease( + $release, + $hitData['title'] ?? '', + 'PreDb: Title match', + $echo, + $type, + $nameStatus, + $show, + $hitData['id'] ?? null + ); + return true; } } @@ -1265,21 +1220,18 @@ class NameFixingService $preMatch = $this->preMatch($cleanedFileName); if ($preMatch[0] === true) { - if (config('nntmux.elasticsearch_enabled') === true) { - $results = $this->elasticsearch->searchPreDb($preMatch[1]); - } else { - $results = Arr::get($this->manticore->searchIndexes('predb_rt', $preMatch[1], ['filename', 'title']), 'data'); - } + $results = Search::searchPredb($preMatch[1]); if (!empty($results)) { foreach ($results as $result) { if (!empty($result)) { - $preFtMatch = $this->preMatch($result['filename'] ?? ''); + $resultData = is_array($result) ? $result : (array)$result; + $preFtMatch = $this->preMatch($resultData['filename'] ?? ''); if ($preFtMatch[0] === true) { - if ($result['title'] !== $release->searchname) { - $this->updateService->updateRelease($release, $result['title'], 'file matched source: ' . $result['source'], $echo, 'PreDB file match, ', $nameStatus, $show); + if ($resultData['title'] !== $release->searchname) { + $this->updateService->updateRelease($release, $resultData['title'], 'file matched source: ' . ($resultData['source'] ?? ''), $echo, 'PreDB file match, ', $nameStatus, $show); } else { - $this->updateService->updateSingleColumn('predb_id', $result['id'], $release->releases_id); + $this->updateService->updateSingleColumn('predb_id', $resultData['id'] ?? 0, $release->releases_id); } $matching++; return $matching; diff --git a/app/Services/NameFixing/ReleaseUpdateService.php b/app/Services/NameFixing/ReleaseUpdateService.php index 046ac7f47..9827c362e 100644 --- a/app/Services/NameFixing/ReleaseUpdateService.php +++ b/app/Services/NameFixing/ReleaseUpdateService.php @@ -4,14 +4,13 @@ declare(strict_types=1); namespace App\Services\NameFixing; +use App\Facades\Search; use App\Models\Category; use App\Models\Predb; use App\Models\Release; use App\Models\UsenetGroup; use App\Services\Categorization\CategorizationService; use App\Services\ReleaseCleaningService; -use App\Services\Search\ElasticSearchService; -use App\Services\Search\ManticoreSearchService; use Blacklight\ColorCLI; use Illuminate\Support\Arr; @@ -49,8 +48,6 @@ class ReleaseUpdateService public const IS_RENAMED_DONE = 1; protected CategorizationService $category; - protected ManticoreSearchService $manticore; - protected ElasticSearchService $elasticsearch; protected FileNameCleaner $fileNameCleaner; protected ColorCLI $colorCLI; protected bool $echoOutput; @@ -82,14 +79,10 @@ class ReleaseUpdateService public function __construct( ?CategorizationService $category = null, - ?ManticoreSearchService $manticore = null, - ?ElasticSearchService $elasticsearch = null, ?FileNameCleaner $fileNameCleaner = null, ?ColorCLI $colorCLI = null ) { $this->category = $category ?? new CategorizationService(); - $this->manticore = $manticore ?? app(ManticoreSearchService::class); - $this->elasticsearch = $elasticsearch ?? app(ElasticSearchService::class); $this->fileNameCleaner = $fileNameCleaner ?? new FileNameCleaner(); $this->colorCLI = $colorCLI ?? new ColorCLI(); $this->echoOutput = config('nntmux.echocli'); @@ -298,11 +291,7 @@ class ReleaseUpdateService } // Update search index - if (config('nntmux.elasticsearch_enabled') === true) { - $this->elasticsearch->updateRelease($release->releases_id); - } else { - $this->manticore->updateRelease($release->releases_id); - } + Search::updateRelease($release->releases_id); } /** diff --git a/app/Services/ReleaseRemoverService.php b/app/Services/ReleaseRemoverService.php index 7a15228ea..f110154bc 100644 --- a/app/Services/ReleaseRemoverService.php +++ b/app/Services/ReleaseRemoverService.php @@ -584,13 +584,7 @@ class ReleaseRemoverService */ private function performSearch(string $regexMatch): array|string { - if (config('nntmux.elasticsearch_enabled') === true) { - return app(\App\Services\Search\ElasticSearchService::class)->indexSearch($regexMatch, 100); - } - - $searchResult = app(\App\Services\Search\ManticoreSearchService::class)->searchIndexes('releases_rt', $regexMatch, ['name,searchname']); - - return ! empty($searchResult) ? Arr::wrap(Arr::get($searchResult, 'id')) : ''; + return \App\Facades\Search::searchReleases($regexMatch, 100); } /** diff --git a/app/Services/Releases/ReleaseManagementService.php b/app/Services/Releases/ReleaseManagementService.php index 00d88a300..0c472fbb3 100644 --- a/app/Services/Releases/ReleaseManagementService.php +++ b/app/Services/Releases/ReleaseManagementService.php @@ -2,12 +2,10 @@ namespace App\Services\Releases; +use App\Facades\Search; use App\Models\Release; use App\Services\ReleaseImageService; -use App\Services\Search\ManticoreSearchService; use Blacklight\NZB; -use Elasticsearch; -use Elasticsearch\Common\Exceptions\Missing404Exception; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; @@ -16,12 +14,8 @@ use Illuminate\Support\Facades\File; */ class ReleaseManagementService { - private ManticoreSearchService $manticoreSearch; - - public function __construct( - ManticoreSearchService $manticoreSearch - ) { - $this->manticoreSearch = $manticoreSearch; + public function __construct() + { } /** @@ -58,36 +52,17 @@ class ReleaseManagementService // Delete images. $releaseImage->delete($identifiers['g']); - if (config('nntmux.elasticsearch_enabled') === true) { - if ($identifiers['i'] === false) { - $identifiers['i'] = Release::query()->where('guid', $identifiers['g'])->first(['id']); - if ($identifiers['i'] !== null) { - $identifiers['i'] = $identifiers['i']['id']; - } + // Get release ID if not provided + if ($identifiers['i'] === false) { + $release = Release::query()->where('guid', $identifiers['g'])->first(['id']); + if ($release !== null) { + $identifiers['i'] = $release->id; } - if ($identifiers['i'] !== null) { - $params = [ - 'index' => 'releases', - 'id' => $identifiers['i'], - ]; + } - try { - Elasticsearch::delete($params); - } catch (Missing404Exception $e) { - // we do nothing here just catch the error, we don't care if release is missing from ES, we are deleting it anyway - } - } - } else { - // Delete from Manticore - if ($identifiers['i'] === false) { - $release = Release::query()->where('guid', $identifiers['g'])->first(['id']); - if ($release !== null) { - $identifiers['i'] = $release->id; - } - } - if (!empty($identifiers['i'])) { - $this->manticoreSearch->deleteRelease((int) $identifiers['i']); - } + // Delete from search index + if (!empty($identifiers['i'])) { + Search::deleteRelease((int) $identifiers['i']); } // Delete from DB. diff --git a/app/Services/Releases/ReleaseSearchService.php b/app/Services/Releases/ReleaseSearchService.php index 586c07224..7a3265037 100644 --- a/app/Services/Releases/ReleaseSearchService.php +++ b/app/Services/Releases/ReleaseSearchService.php @@ -2,12 +2,11 @@ namespace App\Services\Releases; +use App\Facades\Search; use App\Models\Category; use App\Models\Release; use App\Models\Settings; use App\Models\UsenetGroup; -use App\Services\Search\ElasticSearchService; -use App\Services\Search\ManticoreSearchService; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; @@ -25,15 +24,8 @@ class ReleaseSearchService public const PASSWD_NONE = 0; public const PASSWD_RAR = 1; - private ManticoreSearchService $manticoreSearch; - private ElasticSearchService $elasticSearch; - - public function __construct( - ManticoreSearchService $manticoreSearch, - ElasticSearchService $elasticSearch - ) { - $this->manticoreSearch = $manticoreSearch; - $this->elasticSearch = $elasticSearch; + public function __construct() + { } /** @@ -149,26 +141,10 @@ class ReleaseSearchService // Early return if searching with no results $searchResult = []; if ($searchName !== -1 && $searchName !== '' && $searchName !== null) { - // Try Elasticsearch first if enabled - if (config('nntmux.elasticsearch_enabled') === true) { - $searchResult = $this->elasticSearch->indexSearchApi($searchName, $limit); - } + // Use the unified Search facade + $searchResult = Search::searchReleases($searchName, $limit); - // Fall back to Manticore if Elasticsearch didn't return results - if (empty($searchResult)) { - $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $searchName, ['searchname']); - if (config('app.debug')) { - Log::debug('apiSearch: Manticore searchIndexes result', [ - 'searchName' => $searchName, - 'result' => $searchResult, - ]); - } - if (! empty($searchResult)) { - $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); - } - } - - // Fall back to MySQL if both Elasticsearch and Manticore failed (only if enabled) + // Fall back to MySQL if search engine returned no results (only if enabled) if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) { if (config('app.debug')) { Log::debug('apiSearch: Falling back to MySQL search'); @@ -588,20 +564,10 @@ class ReleaseSearchService { $searchResult = []; if (! empty($name)) { - // Try Elasticsearch first if enabled - if (config('nntmux.elasticsearch_enabled') === true) { - $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit); - } + // Use the unified Search facade + $searchResult = Search::searchReleases($name, $limit); - // Fall back to Manticore if Elasticsearch didn't return results - if (empty($searchResult)) { - $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']); - if (! empty($searchResult)) { - $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); - } - } - - // Fall back to MySQL if both Elasticsearch and Manticore failed (only if enabled) + // Fall back to MySQL if search engine returned no results (only if enabled) if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) { $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit); } @@ -666,20 +632,10 @@ class ReleaseSearchService // Early return if searching by name yields no results $searchResult = []; if (! empty($name)) { - // Try Elasticsearch first if enabled - if (config('nntmux.elasticsearch_enabled') === true) { - $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit); - } + // Use the unified Search facade + $searchResult = Search::searchReleases($name, $limit); - // Fall back to Manticore if Elasticsearch didn't return results - if (empty($searchResult)) { - $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']); - if (! empty($searchResult)) { - $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); - } - } - - // Fall back to MySQL if both Elasticsearch and Manticore failed (only if enabled) + // Fall back to MySQL if search engine returned no results (only if enabled) if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) { $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit); } @@ -832,49 +788,28 @@ class ReleaseSearchService return []; } - $phrases = array_values($searchFields); - $esEnabled = config('nntmux.elasticsearch_enabled'); if (config('app.debug')) { Log::debug('performIndexSearch: starting search', [ 'elasticsearch_enabled' => $esEnabled, 'elasticsearch_enabled_type' => gettype($esEnabled), 'searchFields' => $searchFields, - 'phrases' => $phrases, 'limit' => $limit, ]); } - // Try Elasticsearch first if enabled - if ($esEnabled === true) { - $result = $this->elasticSearch->indexSearch($phrases, $limit); - if (config('app.debug')) { - Log::debug('performIndexSearch: Elasticsearch result count', ['count' => count($result)]); - } - // If Elasticsearch returned results, use them - if (!empty($result)) { - return $result; - } - // Otherwise fall through to Manticore - if (config('app.debug')) { - Log::debug('performIndexSearch: Elasticsearch returned empty, falling back to Manticore'); - } - } - - // Try Manticore search - $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', '', [], $searchFields); - + // Use the unified Search facade - pass searchFields with keys preserved + $result = Search::searchReleases($searchFields, $limit); if (config('app.debug')) { - Log::debug('performIndexSearch: Manticore result', [ - 'result' => $searchResult, - ]); + Log::debug('performIndexSearch: Search result count', ['count' => count($result)]); } - if (!empty($searchResult) && !empty($searchResult['id'])) { - return Arr::wrap(Arr::get($searchResult, 'id')); + // If search returned results, use them + if (!empty($result)) { + return $result; } - // Fallback to MySQL LIKE search when both Elasticsearch and Manticore are unavailable (only if enabled) + // Fallback to MySQL LIKE search when search engine is unavailable (only if enabled) if (config('nntmux.mysql_search_fallback', false) === true) { if (config('app.debug')) { Log::debug('performIndexSearch: Falling back to MySQL search'); diff --git a/app/Services/Search/Contracts/SearchDriverInterface.php b/app/Services/Search/Contracts/SearchDriverInterface.php new file mode 100644 index 000000000..8fb13b860 --- /dev/null +++ b/app/Services/Search/Contracts/SearchDriverInterface.php @@ -0,0 +1,27 @@ + */ public function suggest(string $query, ?string $index = null): array; + /** + * Truncate/clear an index (remove all documents). + * + * @param array|string $indexes Index name(s) to truncate + */ + public function truncateIndex(array|string $indexes): void; + + /** + * Optimize index for better search performance. + */ + public function optimizeIndex(): void; + + /** + * Bulk insert multiple releases into the index. + * + * @param array $releases Array of release data arrays + * @return array Results with 'success' and 'errors' counts + */ + public function bulkInsertReleases(array $releases): array; + + /** + * Delete a predb record from the index. + * + * @param int $id Predb ID + */ + public function deletePreDb(int $id): void; } diff --git a/app/Services/Search/ElasticSearchService.php b/app/Services/Search/Drivers/ElasticSearchDriver.php similarity index 86% rename from app/Services/Search/ElasticSearchService.php rename to app/Services/Search/Drivers/ElasticSearchDriver.php index d940319ef..86cbb3a0b 100644 --- a/app/Services/Search/ElasticSearchService.php +++ b/app/Services/Search/Drivers/ElasticSearchDriver.php @@ -1,9 +1,9 @@ config = ! empty($config) ? $config : config('search.drivers.elasticsearch'); + } + + /** + * Get the driver name. + */ + public function getDriverName(): string + { + return 'elasticsearch'; + } + + /** + * Check if Elasticsearch is available. + */ + public function isAvailable(): bool + { + return $this->isElasticsearchAvailable(); + } + /** * Get or create an Elasticsearch client with proper cURL configuration. * @@ -66,14 +85,12 @@ class ElasticSearchService implements SearchServiceInterface throw new RuntimeException('cURL extension is not loaded'); } - $config = config('elasticsearch.connections.default'); - - if (empty($config)) { + if (empty($this->config)) { throw new RuntimeException('Elasticsearch configuration not found'); } $clientBuilder = ClientBuilder::create(); - $hosts = $this->buildHostsArray($config['hosts'] ?? []); + $hosts = $this->buildHostsArray($this->config['hosts'] ?? []); if (empty($hosts)) { throw new RuntimeException('No Elasticsearch hosts configured'); @@ -88,12 +105,12 @@ class ElasticSearchService implements SearchServiceInterface $clientBuilder->setHosts($hosts); $clientBuilder->setHandler(new CurlHandler); $clientBuilder->setConnectionParams([ - 'timeout' => config('elasticsearch.connections.default.timeout', self::DEFAULT_TIMEOUT), - 'connect_timeout' => config('elasticsearch.connections.default.connect_timeout', self::DEFAULT_CONNECT_TIMEOUT), + 'timeout' => $this->config['timeout'] ?? self::DEFAULT_TIMEOUT, + 'connect_timeout' => $this->config['connect_timeout'] ?? self::DEFAULT_CONNECT_TIMEOUT, ]); // Enable retries for better resilience - $clientBuilder->setRetries(2); + $clientBuilder->setRetries($this->config['retries'] ?? 2); self::$client = $clientBuilder->build(); @@ -198,7 +215,7 @@ class ElasticSearchService implements SearchServiceInterface */ public function isAutocompleteEnabled(): bool { - return config('elasticsearch.autocomplete.enabled', true) && $this->isElasticsearchAvailable(); + return ($this->config['autocomplete']['enabled'] ?? true) && $this->isElasticsearchAvailable(); } /** @@ -206,7 +223,7 @@ class ElasticSearchService implements SearchServiceInterface */ public function isSuggestEnabled(): bool { - return config('elasticsearch.suggest.enabled', true) && $this->isElasticsearchAvailable(); + return ($this->config['suggest']['enabled'] ?? true) && $this->isElasticsearchAvailable(); } /** @@ -214,7 +231,7 @@ class ElasticSearchService implements SearchServiceInterface */ public function getReleasesIndex(): string { - return self::INDEX_RELEASES; + return $this->config['indexes']['releases'] ?? 'releases'; } /** @@ -222,7 +239,25 @@ class ElasticSearchService implements SearchServiceInterface */ public function getPredbIndex(): string { - return self::INDEX_PREDB; + return $this->config['indexes']['predb'] ?? 'predb'; + } + + /** + * Escape special characters for Elasticsearch. + */ + public static function escapeString(string $string): string + { + if (empty($string) || $string === '*') { + return ''; + } + + // Replace dots with spaces for release name searches + $string = str_replace('.', ' ', $string); + + // Remove multiple consecutive spaces + $string = preg_replace('/\s+/', ' ', trim($string)); + + return $string; } /** @@ -240,12 +275,12 @@ class ElasticSearchService implements SearchServiceInterface } $query = trim($query); - $minLength = (int) config('elasticsearch.autocomplete.min_length', self::AUTOCOMPLETE_MIN_LENGTH); + $minLength = (int) ($this->config['autocomplete']['min_length'] ?? self::AUTOCOMPLETE_MIN_LENGTH); if (strlen($query) < $minLength) { return []; } - $index = $index ?? self::INDEX_RELEASES; + $index = $index ?? $this->getReleasesIndex(); $cacheKey = 'es:autocomplete:'.md5($index.$query); $cached = Cache::get($cacheKey); @@ -254,7 +289,7 @@ class ElasticSearchService implements SearchServiceInterface } $suggestions = []; - $maxResults = (int) config('elasticsearch.autocomplete.max_results', self::AUTOCOMPLETE_MAX_RESULTS); + $maxResults = (int) ($this->config['autocomplete']['max_results'] ?? self::AUTOCOMPLETE_MAX_RESULTS); try { $client = $this->getClient(); @@ -333,7 +368,7 @@ class ElasticSearchService implements SearchServiceInterface } if (! empty($suggestions)) { - $cacheMinutes = (int) config('elasticsearch.autocomplete.cache_minutes', self::AUTOCOMPLETE_CACHE_MINUTES); + $cacheMinutes = (int) ($this->config['autocomplete']['cache_minutes'] ?? self::AUTOCOMPLETE_CACHE_MINUTES); Cache::put($cacheKey, $suggestions, now()->addMinutes($cacheMinutes)); } @@ -358,7 +393,7 @@ class ElasticSearchService implements SearchServiceInterface return []; } - $index = $index ?? self::INDEX_RELEASES; + $index = $index ?? $this->getReleasesIndex(); $cacheKey = 'es:suggest:'.md5($index.$query); $cached = Cache::get($cacheKey); @@ -424,7 +459,7 @@ class ElasticSearchService implements SearchServiceInterface } if (! empty($suggestions)) { - Cache::put($cacheKey, $suggestions, now()->addMinutes(self::CACHE_TTL_MINUTES)); + Cache::put($cacheKey, $suggestions, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES)); } return $suggestions; @@ -552,13 +587,31 @@ class ElasticSearchService implements SearchServiceInterface /** * Search releases index. * - * @param array|string $phrases Search phrases + * @param array|string $phrases Search phrases - can be a string, indexed array of terms, or associative array with field names * @param int $limit Maximum number of results * @return array Array of release IDs */ public function searchReleases(array|string $phrases, int $limit = 1000): array { - $result = $this->indexSearch($phrases, $limit); + // Normalize the input to a search string + if (is_string($phrases)) { + $searchString = $phrases; + } elseif (is_array($phrases)) { + // Check if it's an associative array (has string keys like 'searchname') + $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0; + + if ($isAssociative) { + // Extract values from associative array + $searchString = implode(' ', array_values($phrases)); + } else { + // Indexed array - combine values + $searchString = implode(' ', $phrases); + } + } else { + return []; + } + + $result = $this->indexSearch($searchString, $limit); return is_array($result) ? $result : $result->toArray(); } @@ -621,14 +674,14 @@ class ElasticSearchService implements SearchServiceInterface try { $search = $this->buildSearchQuery( - index: self::INDEX_RELEASES, + index: $this->getReleasesIndex(), keywords: $keywords, fields: ['searchname^2', 'plainsearchname^1.5', 'fromname', 'filename', 'name^1.2'], limit: $limit ); $result = $this->executeSearch($search); - Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL_MINUTES)); + Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES)); return $result; @@ -668,14 +721,14 @@ class ElasticSearchService implements SearchServiceInterface try { $search = $this->buildSearchQuery( - index: self::INDEX_RELEASES, + index: $this->getReleasesIndex(), keywords: $keywords, fields: ['searchname^2', 'plainsearchname^1.5', 'fromname', 'filename', 'name^1.2'], limit: $limit ); $result = $this->executeSearch($search); - Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL_MINUTES)); + Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES)); return $result; @@ -715,7 +768,7 @@ class ElasticSearchService implements SearchServiceInterface try { $search = $this->buildSearchQuery( - index: self::INDEX_RELEASES, + index: $this->getReleasesIndex(), keywords: $keywords, fields: ['searchname^2', 'plainsearchname^1.5'], limit: $limit, @@ -725,7 +778,7 @@ class ElasticSearchService implements SearchServiceInterface ); $result = $this->executeSearch($search); - Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL_MINUTES)); + Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES)); return $result; @@ -764,7 +817,7 @@ class ElasticSearchService implements SearchServiceInterface try { $search = $this->buildSearchQuery( - index: self::INDEX_PREDB, + index: $this->getPredbIndex(), keywords: $keywords, fields: ['title^2', 'filename'], limit: 1000, @@ -775,7 +828,7 @@ class ElasticSearchService implements SearchServiceInterface ); $result = $this->executeSearch($search, fullResults: true); - Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL_MINUTES)); + Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES)); return $result; @@ -840,7 +893,7 @@ class ElasticSearchService implements SearchServiceInterface $params['body'][] = [ 'index' => [ - '_index' => self::INDEX_RELEASES, + '_index' => $this->getReleasesIndex(), '_id' => $release['id'], ], ]; @@ -916,7 +969,7 @@ class ElasticSearchService implements SearchServiceInterface ], 'doc_as_upsert' => true, ], - 'index' => self::INDEX_RELEASES, + 'index' => $this->getReleasesIndex(), 'id' => $release->id, ]; @@ -960,7 +1013,7 @@ class ElasticSearchService implements SearchServiceInterface 'source' => $parameters['source'] ?? '', 'filename' => $parameters['filename'] ?? '', ], - 'index' => self::INDEX_PREDB, + 'index' => $this->getPredbIndex(), 'id' => $parameters['id'], ]; @@ -1006,7 +1059,7 @@ class ElasticSearchService implements SearchServiceInterface ], 'doc_as_upsert' => true, ], - 'index' => self::INDEX_PREDB, + 'index' => $this->getPredbIndex(), 'id' => $parameters['id'], ]; @@ -1042,7 +1095,7 @@ class ElasticSearchService implements SearchServiceInterface try { $client = $this->getClient(); $client->delete([ - 'index' => self::INDEX_RELEASES, + 'index' => $this->getReleasesIndex(), 'id' => $id, ]); @@ -1080,7 +1133,7 @@ class ElasticSearchService implements SearchServiceInterface try { $client = $this->getClient(); $client->delete([ - 'index' => self::INDEX_PREDB, + 'index' => $this->getPredbIndex(), 'id' => $id, ]); @@ -1121,6 +1174,86 @@ class ElasticSearchService implements SearchServiceInterface } } + /** + * Truncate/clear an index (remove all documents). + * Implements SearchServiceInterface::truncateIndex + * + * @param array|string $indexes Index name(s) to truncate + */ + public function truncateIndex(array|string $indexes): void + { + if (! $this->isElasticsearchAvailable()) { + return; + } + + $indexArray = is_array($indexes) ? $indexes : [$indexes]; + + foreach ($indexArray as $index) { + try { + $client = $this->getClient(); + + // Check if index exists + if (! $client->indices()->exists(['index' => $index])) { + Log::info("ElasticSearch truncateIndex: index {$index} does not exist, skipping"); + continue; + } + + // Delete all documents from the index + $client->deleteByQuery([ + 'index' => $index, + 'body' => [ + 'query' => ['match_all' => (object) []], + ], + 'conflicts' => 'proceed', + ]); + + // Force refresh to ensure deletions are visible + $client->indices()->refresh(['index' => $index]); + + Log::info("ElasticSearch: Truncated index {$index}"); + + } catch (\Throwable $e) { + Log::error("ElasticSearch truncateIndex error for {$index}: ".$e->getMessage()); + } + } + } + + /** + * Optimize index for better search performance. + * Implements SearchServiceInterface::optimizeIndex + */ + public function optimizeIndex(): void + { + if (! $this->isElasticsearchAvailable()) { + return; + } + + try { + $client = $this->getClient(); + + // Force merge the releases index + $client->indices()->forcemerge([ + 'index' => $this->getReleasesIndex(), + 'max_num_segments' => 1, + ]); + + // Force merge the predb index + $client->indices()->forcemerge([ + 'index' => $this->getPredbIndex(), + 'max_num_segments' => 1, + ]); + + // Refresh both indexes + $client->indices()->refresh(['index' => $this->getReleasesIndex()]); + $client->indices()->refresh(['index' => $this->getPredbIndex()]); + + Log::info('ElasticSearch: Optimized indexes'); + + } catch (\Throwable $e) { + Log::error('ElasticSearch optimizeIndex error: '.$e->getMessage()); + } + } + /** * Get cluster health information. * @@ -1281,7 +1414,7 @@ class ElasticSearchService implements SearchServiceInterface 'add_date' => now()->format('Y-m-d H:i:s'), 'post_date' => $parameters['postdate'] ?? now()->format('Y-m-d H:i:s'), ], - 'index' => self::INDEX_RELEASES, + 'index' => $this->getReleasesIndex(), 'id' => $parameters['id'], ]; } diff --git a/app/Services/Search/ManticoreSearchService.php b/app/Services/Search/Drivers/ManticoreSearchDriver.php similarity index 85% rename from app/Services/Search/ManticoreSearchService.php rename to app/Services/Search/Drivers/ManticoreSearchDriver.php index 01e06509c..124102672 100644 --- a/app/Services/Search/ManticoreSearchService.php +++ b/app/Services/Search/Drivers/ManticoreSearchDriver.php @@ -1,9 +1,9 @@ config = config('manticoresearch'); + $this->config = ! empty($config) ? $config : config('search.drivers.manticore'); $this->connection = ['host' => $this->config['host'], 'port' => $this->config['port']]; $this->manticoreSearch = new Client($this->connection); $this->search = new Search($this->manticoreSearch); $this->cli = new ColorCLI; } + /** + * Get the driver name. + */ + public function getDriverName(): string + { + return 'manticore'; + } + + /** + * Check if ManticoreSearch is available. + */ + public function isAvailable(): bool + { + try { + $status = $this->manticoreSearch->nodes()->status(); + + return ! empty($status); + } catch (\Throwable $e) { + if (config('app.debug')) { + Log::debug('ManticoreSearch not available: '.$e->getMessage()); + } + + return false; + } + } + /** * Check if autocomplete is enabled. */ @@ -171,6 +197,75 @@ class ManticoreSearchService implements SearchServiceInterface } } + /** + * Delete a predb record from the index. + * + * @param int $id Predb ID + */ + public function deletePreDb(int $id): void + { + if (empty($id)) { + Log::warning('ManticoreSearch: Cannot delete predb without ID'); + + return; + } + + try { + $this->manticoreSearch->table($this->config['indexes']['predb']) + ->deleteDocument($id); + } catch (ResponseException $e) { + Log::error('ManticoreSearch deletePreDb error: '.$e->getMessage(), [ + 'id' => $id, + ]); + } + } + + /** + * Bulk insert multiple releases into the index. + * + * @param array $releases Array of release data arrays + * @return array Results with 'success' and 'errors' counts + */ + public function bulkInsertReleases(array $releases): array + { + if (empty($releases)) { + return ['success' => 0, 'errors' => 0]; + } + + $success = 0; + $errors = 0; + + $documents = []; + foreach ($releases as $release) { + if (empty($release['id'])) { + $errors++; + continue; + } + + $documents[] = [ + 'id' => $release['id'], + 'name' => $release['name'] ?? '', + 'searchname' => $release['searchname'] ?? '', + 'fromname' => $release['fromname'] ?? '', + 'categories_id' => (string) ($release['categories_id'] ?? ''), + 'filename' => $release['filename'] ?? '', + ]; + } + + if (! empty($documents)) { + try { + $this->manticoreSearch->table($this->config['indexes']['releases']) + ->replaceDocuments($documents); + $success = count($documents); + } catch (\Throwable $e) { + Log::error('ManticoreSearch bulkInsertReleases error: '.$e->getMessage()); + $errors += count($documents); + } + } + + return ['success' => $success, 'errors' => $errors]; + } + /** * Delete release from Manticore RT tables by GUID. * @@ -311,6 +406,18 @@ class ManticoreSearchService implements SearchServiceInterface return $success; } + /** + * Truncate/clear an index (remove all documents). + * Implements SearchServiceInterface::truncateIndex + * + * @param array|string $indexes Index name(s) to truncate + */ + public function truncateIndex(array|string $indexes): void + { + $indexArray = is_array($indexes) ? $indexes : [$indexes]; + $this->truncateRTIndex($indexArray); + } + /** * Create index if it doesn't exist */ @@ -363,16 +470,41 @@ class ManticoreSearchService implements SearchServiceInterface return $success; } + /** + * Optimize index for better search performance. + * Implements SearchServiceInterface::optimizeIndex + */ + public function optimizeIndex(): void + { + $this->optimizeRTIndex(); + } + /** * Search releases index. * - * @param array|string $phrases Search phrases + * @param array|string $phrases Search phrases - can be a string, indexed array of terms, or associative array with field names * @param int $limit Maximum number of results * @return array Array of release IDs */ public function searchReleases(array|string $phrases, int $limit = 1000): array { - $searchArray = is_array($phrases) ? $phrases : ['searchname' => $phrases]; + if (is_string($phrases)) { + // Simple string search - search in searchname field + $searchArray = ['searchname' => $phrases]; + } elseif (is_array($phrases)) { + // Check if it's an associative array (has string keys like 'searchname') + $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0; + + if ($isAssociative) { + // Already has field names as keys + $searchArray = $phrases; + } else { + // Indexed array - combine values and search in searchname + $searchArray = ['searchname' => implode(' ', $phrases)]; + } + } else { + return []; + } $result = $this->searchIndexes($this->getReleasesIndex(), '', [], $searchArray); @@ -427,6 +559,7 @@ class ManticoreSearchService implements SearchServiceInterface 'cached_ids_count' => count($cached['id'] ?? []), ]); } + return $cached; } @@ -448,6 +581,7 @@ class ManticoreSearchService implements SearchServiceInterface if (config('app.debug')) { Log::debug('ManticoreSearch::searchIndexes no terms after escaping searchArray'); } + return []; } } elseif (! empty($searchString)) { @@ -456,6 +590,7 @@ class ManticoreSearchService implements SearchServiceInterface if (config('app.debug')) { Log::debug('ManticoreSearch::searchIndexes escapedSearch is empty'); } + return []; } @@ -554,7 +689,7 @@ class ManticoreSearchService implements SearchServiceInterface ]; // Cache results for 5 minutes - Cache::put($cacheKey, $result, now()->addMinutes(5)); + Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? 5)); return $result; } @@ -773,7 +908,7 @@ class ManticoreSearchService implements SearchServiceInterface } if (! empty($suggestions)) { - Cache::put($cacheKey, $suggestions, now()->addMinutes(5)); + Cache::put($cacheKey, $suggestions, now()->addMinutes($this->config['cache_minutes'] ?? 5)); } return $suggestions; diff --git a/app/Services/Search/SearchService.php b/app/Services/Search/SearchService.php new file mode 100644 index 000000000..ae95aef8a --- /dev/null +++ b/app/Services/Search/SearchService.php @@ -0,0 +1,232 @@ +config->get('search.default', 'manticore'); + } + + /** + * Create the ManticoreSearch driver instance. + */ + protected function createManticoreDriver(): SearchDriverInterface + { + $config = $this->config->get('search.drivers.manticore', []); + + return new ManticoreSearchDriver($config); + } + + /** + * Create the Elasticsearch driver instance. + */ + protected function createElasticsearchDriver(): SearchDriverInterface + { + $config = $this->config->get('search.drivers.elasticsearch', []); + + return new ElasticSearchDriver($config); + } + + /** + * Get a driver instance. + * + * @param string|null $driver + * @return SearchDriverInterface + * + * @throws InvalidArgumentException + */ + public function driver($driver = null): SearchDriverInterface + { + return parent::driver($driver); + } + + /** + * Check if the current driver is available. + */ + public function isAvailable(): bool + { + return $this->driver()->isAvailable(); + } + + /** + * Get the current driver name. + */ + public function getCurrentDriver(): string + { + return $this->driver()->getDriverName(); + } + + /** + * Escape a search string using the current driver's escape method. + */ + public function escapeString(string $string): string + { + $driverClass = get_class($this->driver()); + + return $driverClass::escapeString($string); + } + + // Implement SearchServiceInterface methods by delegating to the current driver + + /** + * Check if autocomplete is enabled. + */ + public function isAutocompleteEnabled(): bool + { + return $this->driver()->isAutocompleteEnabled(); + } + + /** + * Check if suggest is enabled. + */ + public function isSuggestEnabled(): bool + { + return $this->driver()->isSuggestEnabled(); + } + + /** + * Get the releases index name. + */ + public function getReleasesIndex(): string + { + return $this->driver()->getReleasesIndex(); + } + + /** + * Get the predb index name. + */ + public function getPredbIndex(): string + { + return $this->driver()->getPredbIndex(); + } + + /** + * Insert a release into the search index. + */ + public function insertRelease(array $parameters): void + { + $this->driver()->insertRelease($parameters); + } + + /** + * Update a release in the search index. + */ + public function updateRelease(int|string $releaseID): void + { + $this->driver()->updateRelease($releaseID); + } + + /** + * Delete a release from the search index. + */ + public function deleteRelease(int $id): void + { + $this->driver()->deleteRelease($id); + } + + /** + * Insert a predb record into the search index. + */ + public function insertPredb(array $parameters): void + { + $this->driver()->insertPredb($parameters); + } + + /** + * Update a predb record in the search index. + */ + public function updatePreDb(array $parameters): void + { + $this->driver()->updatePreDb($parameters); + } + + /** + * Search the releases index. + */ + public function searchReleases(array|string $phrases, int $limit = 1000): array + { + return $this->driver()->searchReleases($phrases, $limit); + } + + /** + * Search the predb index. + */ + public function searchPredb(array|string $searchTerm): array + { + return $this->driver()->searchPredb($searchTerm); + } + + /** + * Get autocomplete suggestions for a search query. + */ + public function autocomplete(string $query, ?string $index = null): array + { + return $this->driver()->autocomplete($query, $index); + } + + /** + * Get spell correction suggestions. + */ + public function suggest(string $query, ?string $index = null): array + { + return $this->driver()->suggest($query, $index); + } + + /** + * Truncate/clear an index (remove all documents). + * + * @param array|string $indexes Index name(s) to truncate + */ + public function truncateIndex(array|string $indexes): void + { + $this->driver()->truncateIndex($indexes); + } + + /** + * Optimize index for better search performance. + */ + public function optimizeIndex(): void + { + $this->driver()->optimizeIndex(); + } + + /** + * Bulk insert multiple releases into the index. + * + * @param array $releases Array of release data arrays + * @return array Results with 'success' and 'errors' counts + */ + public function bulkInsertReleases(array $releases): array + { + return $this->driver()->bulkInsertReleases($releases); + } + + /** + * Delete a predb record from the index. + * + * @param int $id Predb ID + */ + public function deletePreDb(int $id): void + { + $this->driver()->deletePreDb($id); + } +} + diff --git a/config/search.php b/config/search.php new file mode 100644 index 000000000..19aa020ef --- /dev/null +++ b/config/search.php @@ -0,0 +1,83 @@ + env('SEARCH_DRIVER', 'manticore'), + + /* + |-------------------------------------------------------------------------- + | Search Driver Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure each search driver as needed. + | + */ + 'drivers' => [ + + 'manticore' => [ + 'driver' => 'manticore', + 'host' => env('MANTICORESEARCH_HOST', '127.0.0.1'), + 'port' => env('MANTICORESEARCH_PORT', 9308), + 'hosts' => env('MANTICORESEARCH_HOSTS', ''), + 'retries' => env('MANTICORESEARCH_RETRIES', 2), + 'indexes' => [ + 'releases' => env('MANTICORESEARCH_INDEX_RELEASES', 'releases_rt'), + 'predb' => env('MANTICORESEARCH_INDEX_PREDB', 'predb_rt'), + ], + 'max_matches' => env('MANTICORESEARCH_MAX_MATCHES', 10000), + 'cache_minutes' => env('MANTICORESEARCH_CACHE_MINUTES', 5), + 'autocomplete' => [ + 'enabled' => env('MANTICORESEARCH_AUTOCOMPLETE_ENABLED', true), + 'min_length' => env('MANTICORESEARCH_AUTOCOMPLETE_MIN_LENGTH', 2), + 'max_results' => env('MANTICORESEARCH_AUTOCOMPLETE_MAX_RESULTS', 10), + 'cache_minutes' => env('MANTICORESEARCH_AUTOCOMPLETE_CACHE_MINUTES', 10), + ], + 'suggest' => [ + 'enabled' => env('MANTICORESEARCH_SUGGEST_ENABLED', true), + 'max_edits' => env('MANTICORESEARCH_SUGGEST_MAX_EDITS', 4), + ], + ], + + 'elasticsearch' => [ + 'driver' => 'elasticsearch', + 'hosts' => [ + [ + 'host' => env('ELASTICSEARCH_HOST', 'localhost'), + 'port' => env('ELASTICSEARCH_PORT', 9200), + 'scheme' => env('ELASTICSEARCH_SCHEME', null), + 'user' => env('ELASTICSEARCH_USER', null), + 'pass' => env('ELASTICSEARCH_PASS', null), + ], + ], + 'timeout' => env('ELASTICSEARCH_TIMEOUT', 10), + 'connect_timeout' => env('ELASTICSEARCH_CONNECT_TIMEOUT', 5), + 'retries' => env('ELASTICSEARCH_RETRIES', 2), + 'indexes' => [ + 'releases' => 'releases', + 'predb' => 'predb', + ], + 'cache_minutes' => env('ELASTICSEARCH_CACHE_MINUTES', 5), + 'autocomplete' => [ + 'enabled' => env('ELASTICSEARCH_AUTOCOMPLETE_ENABLED', true), + 'min_length' => env('ELASTICSEARCH_AUTOCOMPLETE_MIN_LENGTH', 2), + 'max_results' => env('ELASTICSEARCH_AUTOCOMPLETE_MAX_RESULTS', 10), + 'cache_minutes' => env('ELASTICSEARCH_AUTOCOMPLETE_CACHE_MINUTES', 10), + ], + 'suggest' => [ + 'enabled' => env('ELASTICSEARCH_SUGGEST_ENABLED', true), + ], + ], + + ], + +]; + diff --git a/tests/Unit/MediaProcessingServiceTest.php b/tests/Unit/MediaProcessingServiceTest.php index c43ddb8d9..b984ee985 100644 --- a/tests/Unit/MediaProcessingServiceTest.php +++ b/tests/Unit/MediaProcessingServiceTest.php @@ -5,9 +5,7 @@ namespace Tests\Unit; use App\Models\Release as ReleaseModel; use App\Services\Categorization\CategorizationService; use App\Services\MediaProcessingService; -use App\Services\Search\ElasticSearchService; -use App\Services\Search\ManticoreSearchService; -use Blacklight\ReleaseExtra; +use App\Services\ReleaseExtraService; use App\Services\ReleaseImageService; use FFMpeg\FFMpeg; use FFMpeg\FFProbe; @@ -50,21 +48,17 @@ class MediaProcessingServiceTest extends TestCase ?FFProbe $ffprobe = null, ?MediaInfo $mediaInfo = null, ?ReleaseImageService $releaseImage = null, - ?ReleaseExtra $releaseExtra = null, - ?ManticoreSearchService $manticore = null, - ?ElasticSearchService $elastic = null, + ?ReleaseExtraService $releaseExtra = null, ?CategorizationService $categorize = null ): MediaProcessingService { $ffmpeg ??= Mockery::mock(FFMpeg::class); $ffprobe ??= Mockery::mock(FFProbe::class); $mediaInfo ??= Mockery::mock(MediaInfo::class); $releaseImage ??= Mockery::mock(ReleaseImageService::class); - $releaseExtra ??= Mockery::mock(ReleaseExtra::class); - $manticore ??= Mockery::mock(ManticoreSearchService::class); - $elastic ??= Mockery::mock(ElasticSearchService::class); + $releaseExtra ??= Mockery::mock(ReleaseExtraService::class); $categorize ??= Mockery::mock(CategorizationService::class); - return new MediaProcessingService($ffmpeg, $ffprobe, $mediaInfo, $releaseImage, $releaseExtra, $manticore, $elastic, $categorize); + return new MediaProcessingService($ffmpeg, $ffprobe, $mediaInfo, $releaseImage, $releaseExtra, $categorize); } #[WithoutErrorHandler] @@ -121,7 +115,7 @@ class MediaProcessingServiceTest extends TestCase $ffmpeg = Mockery::mock(FFMpeg::class); $ffmpeg->shouldReceive('open')->andReturn($openMock); - $releaseImage = Mockery::mock(ReleaseImage::class); + $releaseImage = Mockery::mock(ReleaseImageService::class); $releaseImage->imgSavePath = $this->tmpDir; $releaseImage->shouldReceive('saveImage')->andReturn(1);