mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
Move elastic and manticore into services
This commit is contained in:
@@ -4,6 +4,8 @@ namespace Blacklight;
|
||||
|
||||
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;
|
||||
|
||||
@@ -49,9 +51,9 @@ class IRCScraper extends IRCClient
|
||||
*/
|
||||
protected string|false $_titleIgnoreRegex = false;
|
||||
|
||||
protected ManticoreSearch $manticoreSearch;
|
||||
protected ManticoreSearchService $manticoreSearch;
|
||||
|
||||
private ElasticSearchSiteSearch $elasticsearch;
|
||||
private ElasticSearchService $elasticsearch;
|
||||
|
||||
/**
|
||||
* Construct.
|
||||
@@ -106,8 +108,8 @@ class IRCScraper extends IRCClient
|
||||
$this->_titleIgnoreRegex = (string) config('irc_settings.scrape_irc_title_ignore');
|
||||
}
|
||||
|
||||
$this->elasticsearch = new ElasticSearchSiteSearch;
|
||||
$this->manticoreSearch = new ManticoreSearch;
|
||||
$this->elasticsearch = app(ElasticSearchService::class);
|
||||
$this->manticoreSearch = app(ManticoreSearchService::class);
|
||||
|
||||
$this->_groupList = [];
|
||||
$this->_silent = $silent;
|
||||
|
||||
@@ -576,10 +576,10 @@ class ReleaseRemover
|
||||
private function performSearch(string $regexMatch): array|string
|
||||
{
|
||||
if (config('nntmux.elasticsearch_enabled') === true) {
|
||||
return (new ElasticSearchSiteSearch)->indexSearch($regexMatch, 100);
|
||||
return app(\App\Services\Search\ElasticSearchService::class)->indexSearch($regexMatch, 100);
|
||||
}
|
||||
|
||||
$searchResult = (new ManticoreSearch)->searchIndexes('releases_rt', $regexMatch, ['name,searchname']);
|
||||
$searchResult = app(\App\Services\Search\ManticoreSearchService::class)->searchIndexes('releases_rt', $regexMatch, ['name,searchname']);
|
||||
|
||||
return ! empty($searchResult) ? Arr::wrap(Arr::get($searchResult, 'id')) : '';
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ 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 Elasticsearch;
|
||||
use Elasticsearch\Common\Exceptions\Missing404Exception;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
@@ -29,9 +31,9 @@ class Releases extends Release
|
||||
|
||||
public int $passwordStatus;
|
||||
|
||||
private ManticoreSearch $manticoreSearch;
|
||||
private ManticoreSearchService $manticoreSearch;
|
||||
|
||||
private ElasticSearchSiteSearch $elasticSearch;
|
||||
private ElasticSearchService $elasticSearch;
|
||||
|
||||
/**
|
||||
* @var array Class instances.
|
||||
@@ -41,8 +43,8 @@ class Releases extends Release
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->manticoreSearch = new ManticoreSearch;
|
||||
$this->elasticSearch = new ElasticSearchSiteSearch;
|
||||
$this->manticoreSearch = app(ManticoreSearchService::class);
|
||||
$this->elasticSearch = app(ElasticSearchService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use App\Services\Search\ElasticSearchService;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -147,9 +147,8 @@ class DeleteReleases extends Command
|
||||
DB::delete('DELETE FROM releases WHERE id = ?', [$releaseId]);
|
||||
|
||||
// Delete from search indexes
|
||||
$identifiers = ['id' => $releaseId, 'g' => $release->guid];
|
||||
ManticoreSearch::deleteRelease($identifiers);
|
||||
ElasticSearchSiteSearch::deleteRelease($releaseId);
|
||||
app(ManticoreSearchService::class)->deleteRelease($releaseId);
|
||||
app(ElasticSearchService::class)->deleteRelease($releaseId);
|
||||
|
||||
$deleted++;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Predb;
|
||||
use App\Models\Release;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -276,7 +276,7 @@ class NntmuxOffsetPopulate extends Command
|
||||
private function clearIndex(string $engine, string $index): void
|
||||
{
|
||||
if ($engine === 'manticore') {
|
||||
$manticore = new ManticoreSearch;
|
||||
$manticore = app(ManticoreSearchService::class);
|
||||
$indexName = $index === 'releases' ? 'releases_rt' : 'predb_rt';
|
||||
$manticore->truncateRTIndex(Arr::wrap($indexName));
|
||||
$this->info("Truncated ManticoreSearch index: {$indexName}");
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Predb;
|
||||
use App\Models\Release;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -92,7 +92,7 @@ class NntmuxOffsetWorker extends Command
|
||||
$startTime = microtime(true);
|
||||
|
||||
if ($engine === 'manticore') {
|
||||
$manticore = new ManticoreSearch;
|
||||
$manticore = app(ManticoreSearchService::class);
|
||||
$batchData = [];
|
||||
|
||||
$query->chunk($batchSize, function ($items) use ($manticore, $indexName, $transformer, &$processed, &$errors, &$batchData, $batchSize, $workerId) {
|
||||
@@ -248,7 +248,7 @@ class NntmuxOffsetWorker extends Command
|
||||
/**
|
||||
* Process ManticoreSearch batch
|
||||
*/
|
||||
private function processManticoreBatch(ManticoreSearch $manticore, string $indexName, array $data, int $workerId): void
|
||||
private function processManticoreBatch(ManticoreSearchService $manticore, string $indexName, array $data, int $workerId): void
|
||||
{
|
||||
$retries = 3;
|
||||
$attempt = 0;
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Predb;
|
||||
use App\Models\Release;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -116,7 +116,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
$this->info('Optimizing ManticoreSearch indexes...');
|
||||
|
||||
try {
|
||||
(new ManticoreSearch)->optimizeRTIndex();
|
||||
app(ManticoreSearchService::class)->optimizeRTIndex();
|
||||
$this->info('Optimization completed successfully!');
|
||||
|
||||
return Command::SUCCESS;
|
||||
@@ -155,7 +155,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
|
||||
private function manticoreReleases(): int
|
||||
{
|
||||
$manticore = new ManticoreSearch;
|
||||
$manticore = app(ManticoreSearchService::class);
|
||||
$indexName = 'releases_rt';
|
||||
|
||||
$manticore->truncateRTIndex(Arr::wrap($indexName));
|
||||
@@ -208,7 +208,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
*/
|
||||
private function manticorePredb(): int
|
||||
{
|
||||
$manticore = new ManticoreSearch;
|
||||
$manticore = app(ManticoreSearchService::class);
|
||||
$indexName = 'predb_rt';
|
||||
|
||||
$manticore->truncateRTIndex([$indexName]);
|
||||
@@ -244,7 +244,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
*/
|
||||
private function processManticoreData(string $indexName, int $total, $query, callable $transformer): int
|
||||
{
|
||||
$manticore = new ManticoreSearch;
|
||||
$manticore = app(ManticoreSearchService::class);
|
||||
$chunkSize = $this->getChunkSize();
|
||||
$batchSize = $this->getBatchSize();
|
||||
|
||||
@@ -487,7 +487,7 @@ class NntmuxPopulateSearchIndexes extends Command
|
||||
/**
|
||||
* Process ManticoreSearch batch with retry logic
|
||||
*/
|
||||
private function processBatch(ManticoreSearch $manticore, string $indexName, array $data): void
|
||||
private function processBatch(ManticoreSearchService $manticore, string $indexName, array $data): void
|
||||
{
|
||||
$retries = 3;
|
||||
$attempt = 0;
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Release;
|
||||
use App\Models\ReleaseFile;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Blacklight\NZB;
|
||||
use Blacklight\ReleaseImage;
|
||||
use Elasticsearch;
|
||||
@@ -64,12 +64,8 @@ class NntmuxRemoveBadReleases extends Command
|
||||
// we do nothing here just catch the error, we don't care if release is missing from ES, we are deleting it anyway
|
||||
}
|
||||
} else {
|
||||
$identifiers = [
|
||||
'i' => $badRelease->id,
|
||||
'g' => $badRelease->guid,
|
||||
];
|
||||
// Delete from sphinx.
|
||||
(new ManticoreSearch)->deleteRelease($identifiers);
|
||||
// Delete from Manticore
|
||||
app(ManticoreSearchService::class)->deleteRelease($badRelease->id);
|
||||
}
|
||||
$badRelease->delete();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\UsenetGroup;
|
||||
use Blacklight\ManticoreSearch;
|
||||
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 {
|
||||
(new ManticoreSearch)->truncateRTIndex(['releases_rt', 'predb_rt']);
|
||||
app(ManticoreSearchService::class)->truncateRTIndex(['releases_rt', 'predb_rt']);
|
||||
}
|
||||
|
||||
$this->info('Deleting nzbfiles subfolders.');
|
||||
|
||||
@@ -4,18 +4,18 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\UsenetGroup;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use App\Services\Search\Contracts\SearchServiceInterface;
|
||||
use Blacklight\Releases;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchController extends BasePageController
|
||||
{
|
||||
private ManticoreSearch $manticore;
|
||||
private SearchServiceInterface $searchService;
|
||||
|
||||
public function __construct()
|
||||
public function __construct(SearchServiceInterface $searchService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->manticore = new ManticoreSearch;
|
||||
$this->searchService = $searchService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,9 +145,9 @@ class SearchController extends BasePageController
|
||||
// Get spell correction suggestions if we have a search query but few/no results
|
||||
$spellSuggestion = null;
|
||||
$searchQuery = $search ?: ($searchVars['searchadvr'] ?? '');
|
||||
if (! empty($searchQuery) && $this->manticore->isSuggestEnabled()) {
|
||||
// Get suggestions from ManticoreSearch
|
||||
$suggestions = $this->manticore->suggest($searchQuery);
|
||||
if (! empty($searchQuery) && $this->searchService->isSuggestEnabled()) {
|
||||
// Get suggestions from search service
|
||||
$suggestions = $this->searchService->suggest($searchQuery);
|
||||
if (! empty($suggestions)) {
|
||||
// Sort by doc count descending to get best suggestion
|
||||
usort($suggestions, fn ($a, $b) => $b['docs'] - $a['docs']);
|
||||
@@ -214,10 +214,10 @@ class SearchController extends BasePageController
|
||||
'meta_title' => 'Search Nzbs',
|
||||
'meta_keywords' => 'search,nzb,description,details',
|
||||
'meta_description' => 'Search for Nzbs',
|
||||
// ManticoreSearch enhanced features
|
||||
// Search enhanced features
|
||||
'spellSuggestion' => $spellSuggestion,
|
||||
'autocompleteEnabled' => $this->manticore->isAutocompleteEnabled(),
|
||||
'suggestEnabled' => $this->manticore->isSuggestEnabled(),
|
||||
'autocompleteEnabled' => $this->searchService->isAutocompleteEnabled(),
|
||||
'suggestEnabled' => $this->searchService->isSuggestEnabled(),
|
||||
]);
|
||||
|
||||
return view('search.index', $this->viewData);
|
||||
|
||||
@@ -2,24 +2,18 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use App\Services\Search\Contracts\SearchServiceInterface;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class SearchSuggestController extends Controller
|
||||
{
|
||||
private ManticoreSearch|ElasticSearchSiteSearch $searchEngine;
|
||||
private SearchServiceInterface $searchEngine;
|
||||
|
||||
public function __construct()
|
||||
public function __construct(SearchServiceInterface $searchEngine)
|
||||
{
|
||||
// Use Elasticsearch if enabled, otherwise fall back to ManticoreSearch
|
||||
if (config('nntmux.elasticsearch_enabled', false)) {
|
||||
$this->searchEngine = new ElasticSearchSiteSearch;
|
||||
} else {
|
||||
$this->searchEngine = new ManticoreSearch;
|
||||
}
|
||||
$this->searchEngine = $searchEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Search\ElasticSearchService;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Blacklight\ColorCLI;
|
||||
use Blacklight\ConsoleTools;
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -201,9 +201,9 @@ class Predb extends Model
|
||||
->orderByDesc('predb.predate');
|
||||
if (! empty($search)) {
|
||||
if (config('nntmux.elasticsearch_enabled') === true) {
|
||||
$ids = (new ElasticSearchSiteSearch)->predbIndexSearch($search);
|
||||
$ids = app(ElasticSearchService::class)->predbIndexSearch($search);
|
||||
} else {
|
||||
$manticore = new ManticoreSearch;
|
||||
$manticore = app(ManticoreSearchService::class);
|
||||
$ids = Arr::get($manticore->searchIndexes('predb_rt', $search, ['title']), 'id');
|
||||
}
|
||||
$sql->whereIn('predb.id', $ids);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use App\Services\Search\ElasticSearchService;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -136,9 +136,9 @@ class Release extends Model
|
||||
);
|
||||
|
||||
if (config('nntmux.elasticsearch_enabled') === true) {
|
||||
(new ElasticSearchSiteSearch)->insertRelease($parameters);
|
||||
app(ElasticSearchService::class)->insertRelease($parameters);
|
||||
} else {
|
||||
(new ManticoreSearch)->insertRelease($parameters);
|
||||
app(ManticoreSearchService::class)->insertRelease($parameters);
|
||||
}
|
||||
|
||||
return $parameters['id'];
|
||||
@@ -173,9 +173,9 @@ class Release extends Model
|
||||
);
|
||||
|
||||
if (config('nntmux.elasticsearch_enabled') === true) {
|
||||
(new ElasticSearchSiteSearch)->updateRelease($id);
|
||||
app(ElasticSearchService::class)->updateRelease($id);
|
||||
} else {
|
||||
(new ManticoreSearch)->updateRelease($id);
|
||||
app(ManticoreSearchService::class)->updateRelease($id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,9 +397,9 @@ class Release extends Model
|
||||
|
||||
if (! empty($similar)) {
|
||||
if (config('nntmux.elasticsearch_enabled') === true) {
|
||||
$searchResult = (new ElasticSearchSiteSearch)->indexSearch($similar[1], 10);
|
||||
$searchResult = app(ElasticSearchService::class)->indexSearch($similar[1], 10);
|
||||
} else {
|
||||
$searchResult = (new ManticoreSearch)->searchIndexes('releases_rt', $similar[1]);
|
||||
$searchResult = app(ManticoreSearchService::class)->searchIndexes('releases_rt', $similar[1]);
|
||||
if (! empty($searchResult)) {
|
||||
$searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use App\Services\Search\ElasticSearchService;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -126,9 +126,9 @@ class ReleaseFile extends Model
|
||||
ParHash::insertOrIgnore(['releases_id' => $id, 'hash' => $hash]);
|
||||
}
|
||||
if (config('nntmux.elasticsearch_enabled') === true) {
|
||||
(new ElasticSearchSiteSearch)->updateRelease($id);
|
||||
app(ElasticSearchService::class)->updateRelease($id);
|
||||
} else {
|
||||
(new ManticoreSearch)->updateRelease($id);
|
||||
app(ManticoreSearchService::class)->updateRelease($id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Search\Contracts\SearchServiceInterface;
|
||||
use App\Services\Search\ElasticSearchService;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class SearchServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Register ManticoreSearchService as singleton
|
||||
$this->app->singleton(ManticoreSearchService::class, function ($app) {
|
||||
return new ManticoreSearchService();
|
||||
});
|
||||
|
||||
// Register ElasticSearchService as singleton
|
||||
$this->app->singleton(ElasticSearchService::class, function ($app) {
|
||||
return new ElasticSearchService();
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// Register alias for ManticoreSearch (for backward compatibility)
|
||||
$this->app->alias(ManticoreSearchService::class, 'manticore');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@ use App\Models\Release;
|
||||
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
|
||||
use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext;
|
||||
use App\Services\Categorization\CategorizationService;
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use Blacklight\NameFixer;
|
||||
use App\Services\Search\ElasticSearchService;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Blacklight\ReleaseExtra;
|
||||
use Blacklight\ReleaseImage;
|
||||
use FFMpeg\Coordinate\Dimension;
|
||||
@@ -32,8 +31,8 @@ class MediaExtractionService
|
||||
private ?FFMpeg $ffmpeg = null;
|
||||
private ?FFProbe $ffprobe = null;
|
||||
private ?MediaInfo $mediaInfo = null;
|
||||
private ?ManticoreSearch $manticore = null;
|
||||
private ?ElasticSearchSiteSearch $elasticsearch = null;
|
||||
private ?ManticoreSearchService $manticore = null;
|
||||
private ?ElasticSearchService $elasticsearch = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProcessingConfiguration $config,
|
||||
@@ -471,18 +470,18 @@ class MediaExtractionService
|
||||
return $this->mediaInfo;
|
||||
}
|
||||
|
||||
private function manticore(): ManticoreSearch
|
||||
private function manticore(): ManticoreSearchService
|
||||
{
|
||||
if ($this->manticore === null) {
|
||||
$this->manticore = new ManticoreSearch();
|
||||
$this->manticore = app(ManticoreSearchService::class);
|
||||
}
|
||||
return $this->manticore;
|
||||
}
|
||||
|
||||
private function elasticsearch(): ElasticSearchSiteSearch
|
||||
private function elasticsearch(): ElasticSearchService
|
||||
{
|
||||
if ($this->elasticsearch === null) {
|
||||
$this->elasticsearch = new ElasticSearchSiteSearch();
|
||||
$this->elasticsearch = app(ElasticSearchService::class);
|
||||
}
|
||||
return $this->elasticsearch;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ use App\Models\MediaInfo as MediaInfoModel;
|
||||
use App\Models\Predb;
|
||||
use App\Models\Release;
|
||||
use App\Models\ReleaseFile;
|
||||
use App\Services\Search\ElasticSearchService;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Blacklight\Releases;
|
||||
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
|
||||
use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext;
|
||||
use App\Services\NameFixing\NameFixingService;
|
||||
use App\Services\NameFixing\ReleaseUpdateService;
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use Blacklight\Nfo;
|
||||
use Blacklight\NZB;
|
||||
use Blacklight\ReleaseExtra;
|
||||
@@ -28,8 +28,8 @@ use Illuminate\Support\Facades\Log;
|
||||
*/
|
||||
class ReleaseFileManager
|
||||
{
|
||||
private ?ManticoreSearch $manticore = null;
|
||||
private ?ElasticSearchSiteSearch $elasticsearch = null;
|
||||
private ?ManticoreSearchService $manticore = null;
|
||||
private ?ElasticSearchService $elasticsearch = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProcessingConfiguration $config,
|
||||
@@ -590,18 +590,18 @@ class ReleaseFileManager
|
||||
return $hasGroupSuffix || ($hasTV && $hasQuality) || ($hasYear && ($hasQuality || $hasTV)) || $hasXXX;
|
||||
}
|
||||
|
||||
private function manticore(): ManticoreSearch
|
||||
private function manticore(): ManticoreSearchService
|
||||
{
|
||||
if ($this->manticore === null) {
|
||||
$this->manticore = new ManticoreSearch();
|
||||
$this->manticore = app(ManticoreSearchService::class);
|
||||
}
|
||||
return $this->manticore;
|
||||
}
|
||||
|
||||
private function elasticsearch(): ElasticSearchSiteSearch
|
||||
private function elasticsearch(): ElasticSearchService
|
||||
{
|
||||
if ($this->elasticsearch === null) {
|
||||
$this->elasticsearch = new ElasticSearchSiteSearch();
|
||||
$this->elasticsearch = app(ElasticSearchService::class);
|
||||
}
|
||||
return $this->elasticsearch;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace App\Services;
|
||||
use App\Models\Category;
|
||||
use App\Models\Release;
|
||||
use App\Services\Categorization\CategorizationService;
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use App\Services\Search\ElasticSearchService;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Blacklight\ReleaseExtra;
|
||||
use Blacklight\ReleaseImage;
|
||||
use FFMpeg\Coordinate\Dimension;
|
||||
@@ -28,8 +28,8 @@ class MediaProcessingService
|
||||
private readonly MediaInfo $mediaInfo,
|
||||
private readonly ReleaseImage $releaseImage,
|
||||
private readonly ReleaseExtra $releaseExtra,
|
||||
private readonly ManticoreSearch $manticore,
|
||||
private readonly ElasticSearchSiteSearch $elasticsearch,
|
||||
private readonly ManticoreSearchService $manticore,
|
||||
private readonly ElasticSearchService $elasticsearch,
|
||||
private readonly CategorizationService $categorize,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ use App\Services\NameFixing\Contracts\NameSourceFixerInterface;
|
||||
use App\Services\NameFixing\DTO\NameFixResult;
|
||||
use App\Services\NameFixing\Extractors\NfoNameExtractor;
|
||||
use App\Services\NameFixing\Extractors\FileNameExtractor;
|
||||
use App\Services\Search\ElasticSearchService;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Blacklight\ColorCLI;
|
||||
use Blacklight\ConsoleTools;
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
/**
|
||||
@@ -50,8 +50,8 @@ class NameFixingService
|
||||
protected FileNameExtractor $fileExtractor;
|
||||
protected FileNameCleaner $fileNameCleaner;
|
||||
protected FilePrioritizer $filePrioritizer;
|
||||
protected ManticoreSearch $manticore;
|
||||
protected ElasticSearchSiteSearch $elasticsearch;
|
||||
protected ManticoreSearchService $manticore;
|
||||
protected ElasticSearchService $elasticsearch;
|
||||
protected ColorCLI $colorCLI;
|
||||
protected bool $echoOutput;
|
||||
|
||||
@@ -70,8 +70,8 @@ class NameFixingService
|
||||
?FileNameExtractor $fileExtractor = null,
|
||||
?FileNameCleaner $fileNameCleaner = null,
|
||||
?FilePrioritizer $filePrioritizer = null,
|
||||
?ManticoreSearch $manticore = null,
|
||||
?ElasticSearchSiteSearch $elasticsearch = null,
|
||||
?ManticoreSearchService $manticore = null,
|
||||
?ElasticSearchService $elasticsearch = null,
|
||||
?ColorCLI $colorCLI = null
|
||||
) {
|
||||
$this->updateService = $updateService ?? new ReleaseUpdateService();
|
||||
@@ -80,8 +80,8 @@ class NameFixingService
|
||||
$this->fileExtractor = $fileExtractor ?? new FileNameExtractor();
|
||||
$this->fileNameCleaner = $fileNameCleaner ?? new FileNameCleaner();
|
||||
$this->filePrioritizer = $filePrioritizer ?? new FilePrioritizer();
|
||||
$this->manticore = $manticore ?? new ManticoreSearch();
|
||||
$this->elasticsearch = $elasticsearch ?? new ElasticSearchSiteSearch();
|
||||
$this->manticore = $manticore ?? app(ManticoreSearchService::class);
|
||||
$this->elasticsearch = $elasticsearch ?? app(ElasticSearchService::class);
|
||||
$this->colorCLI = $colorCLI ?? new ColorCLI();
|
||||
$this->echoOutput = config('nntmux.echocli');
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ use App\Models\Predb;
|
||||
use App\Models\Release;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Services\Categorization\CategorizationService;
|
||||
use App\Services\Search\ElasticSearchService;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Blacklight\ColorCLI;
|
||||
use Blacklight\ConsoleTools;
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use Blacklight\ReleaseCleaning;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
@@ -50,8 +50,8 @@ class ReleaseUpdateService
|
||||
public const IS_RENAMED_DONE = 1;
|
||||
|
||||
protected CategorizationService $category;
|
||||
protected ManticoreSearch $manticore;
|
||||
protected ElasticSearchSiteSearch $elasticsearch;
|
||||
protected ManticoreSearchService $manticore;
|
||||
protected ElasticSearchService $elasticsearch;
|
||||
protected FileNameCleaner $fileNameCleaner;
|
||||
protected ColorCLI $colorCLI;
|
||||
protected bool $echoOutput;
|
||||
@@ -83,14 +83,14 @@ class ReleaseUpdateService
|
||||
|
||||
public function __construct(
|
||||
?CategorizationService $category = null,
|
||||
?ManticoreSearch $manticore = null,
|
||||
?ElasticSearchSiteSearch $elasticsearch = null,
|
||||
?ManticoreSearchService $manticore = null,
|
||||
?ElasticSearchService $elasticsearch = null,
|
||||
?FileNameCleaner $fileNameCleaner = null,
|
||||
?ColorCLI $colorCLI = null
|
||||
) {
|
||||
$this->category = $category ?? new CategorizationService();
|
||||
$this->manticore = $manticore ?? new ManticoreSearch();
|
||||
$this->elasticsearch = $elasticsearch ?? new ElasticSearchSiteSearch();
|
||||
$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');
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Search\Contracts;
|
||||
|
||||
/**
|
||||
* Interface for full-text search services (ManticoreSearch, Elasticsearch).
|
||||
*
|
||||
* Provides a common API for release and predb search, indexing, and autocomplete.
|
||||
*/
|
||||
interface SearchServiceInterface
|
||||
{
|
||||
/**
|
||||
* Check if autocomplete is enabled.
|
||||
*/
|
||||
public function isAutocompleteEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Check if suggest is enabled.
|
||||
*/
|
||||
public function isSuggestEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Get the releases index name.
|
||||
*/
|
||||
public function getReleasesIndex(): string;
|
||||
|
||||
/**
|
||||
* Get the predb index name.
|
||||
*/
|
||||
public function getPredbIndex(): string;
|
||||
|
||||
/**
|
||||
* Insert a release into the search index.
|
||||
*
|
||||
* @param array $parameters Release data with 'id', 'name', 'searchname', 'fromname', 'categories_id', 'filename'
|
||||
*/
|
||||
public function insertRelease(array $parameters): void;
|
||||
|
||||
/**
|
||||
* Update a release in the search index.
|
||||
*
|
||||
* @param int|string $releaseID Release ID
|
||||
*/
|
||||
public function updateRelease(int|string $releaseID): void;
|
||||
|
||||
/**
|
||||
* Delete a release from the search index.
|
||||
*
|
||||
* @param int $id Release ID
|
||||
*/
|
||||
public function deleteRelease(int $id): void;
|
||||
|
||||
/**
|
||||
* Insert a predb record into the search index.
|
||||
*
|
||||
* @param array $parameters Predb data with 'id', 'title', 'filename', 'source'
|
||||
*/
|
||||
public function insertPredb(array $parameters): void;
|
||||
|
||||
/**
|
||||
* Update a predb record in the search index.
|
||||
*
|
||||
* @param array $parameters Predb data with 'id', 'title', 'filename', 'source'
|
||||
*/
|
||||
public function updatePreDb(array $parameters): void;
|
||||
|
||||
/**
|
||||
* Search the releases index.
|
||||
*
|
||||
* @param array|string $phrases Search phrases
|
||||
* @param int $limit Maximum number of results
|
||||
* @return array Array of release IDs
|
||||
*/
|
||||
public function searchReleases(array|string $phrases, int $limit = 1000): array;
|
||||
|
||||
/**
|
||||
* Search the predb index.
|
||||
*
|
||||
* @param array|string $searchTerm Search term(s)
|
||||
* @return array Array of predb records
|
||||
*/
|
||||
public function searchPredb(array|string $searchTerm): array;
|
||||
|
||||
/**
|
||||
* Get autocomplete suggestions for a search query.
|
||||
*
|
||||
* @param string $query The partial search query
|
||||
* @param string|null $index Index to search (defaults to releases index)
|
||||
* @return array<array{suggest: string, distance: int, docs: int}>
|
||||
*/
|
||||
public function autocomplete(string $query, ?string $index = null): array;
|
||||
|
||||
/**
|
||||
* Get spell correction suggestions ("Did you mean?").
|
||||
*
|
||||
* @param string $query The search query to check
|
||||
* @param string|null $index Index to use for suggestions
|
||||
* @return array<array{suggest: string, distance: int, docs: int}>
|
||||
*/
|
||||
public function suggest(string $query, ?string $index = null): array;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,831 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Search;
|
||||
|
||||
use App\Models\Release;
|
||||
use App\Services\Search\Contracts\SearchServiceInterface;
|
||||
use Blacklight\ColorCLI;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Manticoresearch\Client;
|
||||
use Manticoresearch\Exceptions\ResponseException;
|
||||
use Manticoresearch\Exceptions\RuntimeException;
|
||||
use Manticoresearch\Search;
|
||||
|
||||
/**
|
||||
* ManticoreSearch service for full-text search functionality.
|
||||
*/
|
||||
class ManticoreSearchService implements SearchServiceInterface
|
||||
{
|
||||
protected mixed $config;
|
||||
|
||||
protected array $connection;
|
||||
|
||||
public Client $manticoreSearch;
|
||||
|
||||
public Search $search;
|
||||
|
||||
private ColorCLI $cli;
|
||||
|
||||
/**
|
||||
* Establishes a connection to ManticoreSearch HTTP port.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = config('manticoresearch');
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if autocomplete is enabled.
|
||||
*/
|
||||
public function isAutocompleteEnabled(): bool
|
||||
{
|
||||
return ($this->config['autocomplete']['enabled'] ?? true) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if suggest is enabled.
|
||||
*/
|
||||
public function isSuggestEnabled(): bool
|
||||
{
|
||||
return ($this->config['suggest']['enabled'] ?? true) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the releases index name.
|
||||
*/
|
||||
public function getReleasesIndex(): string
|
||||
{
|
||||
return $this->config['indexes']['releases'] ?? 'releases_rt';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the predb index name.
|
||||
*/
|
||||
public function getPredbIndex(): string
|
||||
{
|
||||
return $this->config['indexes']['predb'] ?? 'predb_rt';
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert release into ManticoreSearch releases_rt realtime index
|
||||
*/
|
||||
public function insertRelease(array $parameters): void
|
||||
{
|
||||
if (empty($parameters['id'])) {
|
||||
Log::warning('ManticoreSearch: Cannot insert release without ID');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$document = [
|
||||
'name' => $parameters['name'] ?? '',
|
||||
'searchname' => $parameters['searchname'] ?? '',
|
||||
'fromname' => $parameters['fromname'] ?? '',
|
||||
'categories_id' => (string) ($parameters['categories_id'] ?? ''),
|
||||
'filename' => $parameters['filename'] ?? '',
|
||||
];
|
||||
|
||||
$this->manticoreSearch->table($this->config['indexes']['releases'])
|
||||
->replaceDocument($document, $parameters['id']);
|
||||
|
||||
} catch (ResponseException $e) {
|
||||
Log::error('ManticoreSearch insertRelease ResponseException: '.$e->getMessage(), [
|
||||
'release_id' => $parameters['id'],
|
||||
'index' => $this->config['indexes']['releases'],
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
Log::error('ManticoreSearch insertRelease RuntimeException: '.$e->getMessage(), [
|
||||
'release_id' => $parameters['id'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ManticoreSearch insertRelease unexpected error: '.$e->getMessage(), [
|
||||
'release_id' => $parameters['id'],
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert release into Manticore RT table.
|
||||
*/
|
||||
public function insertPredb(array $parameters): void
|
||||
{
|
||||
if (empty($parameters['id'])) {
|
||||
Log::warning('ManticoreSearch: Cannot insert predb without ID');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$document = [
|
||||
'title' => $parameters['title'] ?? '',
|
||||
'filename' => $parameters['filename'] ?? '',
|
||||
'source' => $parameters['source'] ?? '',
|
||||
];
|
||||
|
||||
$this->manticoreSearch->table($this->config['indexes']['predb'])
|
||||
->replaceDocument($document, $parameters['id']);
|
||||
|
||||
} catch (ResponseException $e) {
|
||||
Log::error('ManticoreSearch insertPredb ResponseException: '.$e->getMessage(), [
|
||||
'predb_id' => $parameters['id'],
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
Log::error('ManticoreSearch insertPredb RuntimeException: '.$e->getMessage(), [
|
||||
'predb_id' => $parameters['id'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ManticoreSearch insertPredb unexpected error: '.$e->getMessage(), [
|
||||
'predb_id' => $parameters['id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete release from Manticore RT tables.
|
||||
*
|
||||
* @param int $id Release ID
|
||||
*/
|
||||
public function deleteRelease(int $id): void
|
||||
{
|
||||
if (empty($id)) {
|
||||
Log::warning('ManticoreSearch: Cannot delete release without ID');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->manticoreSearch->table($this->config['indexes']['releases'])
|
||||
->deleteDocument($id);
|
||||
} catch (ResponseException $e) {
|
||||
Log::error('ManticoreSearch deleteRelease error: '.$e->getMessage(), [
|
||||
'id' => $id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete release from Manticore RT tables by GUID.
|
||||
*
|
||||
* @param array $identifiers ['g' => Release GUID(mandatory), 'id' => ReleaseID(optional, pass false)]
|
||||
*/
|
||||
public function deleteReleaseByGuid(array $identifiers): void
|
||||
{
|
||||
if (empty($identifiers['g'])) {
|
||||
Log::warning('ManticoreSearch: Cannot delete release without GUID');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($identifiers['i'] === false || empty($identifiers['i'])) {
|
||||
$release = Release::query()->where('guid', $identifiers['g'])->first(['id']);
|
||||
$identifiers['i'] = $release?->id;
|
||||
}
|
||||
|
||||
if (! empty($identifiers['i'])) {
|
||||
$this->manticoreSearch->table($this->config['indexes']['releases'])
|
||||
->deleteDocument($identifiers['i']);
|
||||
} else {
|
||||
Log::warning('ManticoreSearch: Could not find release ID for deletion', [
|
||||
'guid' => $identifiers['g'],
|
||||
]);
|
||||
}
|
||||
} catch (ResponseException $e) {
|
||||
Log::error('ManticoreSearch deleteRelease error: '.$e->getMessage(), [
|
||||
'guid' => $identifiers['g'],
|
||||
'id' => $identifiers['i'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes characters that are treated as special operators by the query language parser.
|
||||
*/
|
||||
public static function escapeString(string $string): string
|
||||
{
|
||||
if ($string === '*' || empty($string)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$from = ['\\', '(', ')', '@', '~', '"', '&', '/', '$', '=', "'", '--', '[', ']', '!', '-'];
|
||||
$to = ['\\\\', '\(', '\)', '\@', '\~', '\"', '\&', '\/', '\$', '\=', "\'", '\--', '\[', '\]', '\!', '\-'];
|
||||
|
||||
$string = str_replace($from, $to, $string);
|
||||
|
||||
// Clean up trailing special characters
|
||||
$string = rtrim($string, '-!');
|
||||
|
||||
return trim($string);
|
||||
}
|
||||
|
||||
public function updateRelease(int|string $releaseID): void
|
||||
{
|
||||
if (empty($releaseID)) {
|
||||
Log::warning('ManticoreSearch: Cannot update release without ID');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$release = Release::query()
|
||||
->where('releases.id', $releaseID)
|
||||
->leftJoin('release_files as rf', 'releases.id', '=', 'rf.releases_id')
|
||||
->select([
|
||||
'releases.id',
|
||||
'releases.name',
|
||||
'releases.searchname',
|
||||
'releases.fromname',
|
||||
'releases.categories_id',
|
||||
DB::raw('IFNULL(GROUP_CONCAT(rf.name SEPARATOR " "),"") filename'),
|
||||
])
|
||||
->groupBy('releases.id')
|
||||
->first();
|
||||
|
||||
if ($release !== null) {
|
||||
$this->insertRelease($release->toArray());
|
||||
} else {
|
||||
Log::warning('ManticoreSearch: Release not found for update', ['id' => $releaseID]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ManticoreSearch updateRelease error: '.$e->getMessage(), [
|
||||
'release_id' => $releaseID,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Manticore Predb index for given predb_id.
|
||||
*/
|
||||
public function updatePreDb(array $parameters): void
|
||||
{
|
||||
if (empty($parameters)) {
|
||||
Log::warning('ManticoreSearch: Cannot update predb with empty parameters');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->insertPredb($parameters);
|
||||
}
|
||||
|
||||
public function truncateRTIndex(array $indexes = []): bool
|
||||
{
|
||||
if (empty($indexes)) {
|
||||
$this->cli->error('You need to provide index name to truncate');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$success = true;
|
||||
foreach ($indexes as $index) {
|
||||
if (! \in_array($index, $this->config['indexes'], true)) {
|
||||
$this->cli->error('Unsupported index: '.$index);
|
||||
$success = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->manticoreSearch->table($index)->truncate();
|
||||
$this->cli->info('Truncating index '.$index.' finished.');
|
||||
} catch (ResponseException $e) {
|
||||
if ($e->getMessage() === 'Invalid index') {
|
||||
$this->createIndexIfNotExists($index);
|
||||
} else {
|
||||
$this->cli->error('Error truncating index '.$index.': '.$e->getMessage());
|
||||
$success = false;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->cli->error('Unexpected error truncating index '.$index.': '.$e->getMessage());
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create index if it doesn't exist
|
||||
*/
|
||||
private function createIndexIfNotExists(string $index): void
|
||||
{
|
||||
try {
|
||||
if ($index === 'releases_rt') {
|
||||
$this->manticoreSearch->table($index)->create([
|
||||
'name' => ['type' => 'string'],
|
||||
'searchname' => ['type' => 'string'],
|
||||
'fromname' => ['type' => 'string'],
|
||||
'filename' => ['type' => 'string'],
|
||||
'categories_id' => ['type' => 'integer'],
|
||||
]);
|
||||
$this->cli->info('Created releases_rt index');
|
||||
} elseif ($index === 'predb_rt') {
|
||||
$this->manticoreSearch->table($index)->create([
|
||||
'title' => ['type' => 'string'],
|
||||
'filename' => ['type' => 'string'],
|
||||
'source' => ['type' => 'string'],
|
||||
]);
|
||||
$this->cli->info('Created predb_rt index');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->cli->error('Error creating index '.$index.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize the RT indices.
|
||||
*/
|
||||
public function optimizeRTIndex(): bool
|
||||
{
|
||||
$success = true;
|
||||
|
||||
foreach ($this->config['indexes'] as $index) {
|
||||
try {
|
||||
$this->manticoreSearch->table($index)->flush();
|
||||
$this->manticoreSearch->table($index)->optimize();
|
||||
Log::info("Successfully optimized index: {$index}");
|
||||
} catch (ResponseException $e) {
|
||||
Log::error('Failed to optimize index '.$index.': '.$e->getMessage());
|
||||
$success = false;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Unexpected error optimizing index '.$index.': '.$e->getMessage());
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search releases index.
|
||||
*
|
||||
* @param array|string $phrases Search phrases
|
||||
* @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];
|
||||
|
||||
$result = $this->searchIndexes($this->getReleasesIndex(), '', [], $searchArray);
|
||||
|
||||
return ! empty($result) ? ($result['id'] ?? []) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search predb index.
|
||||
*
|
||||
* @param array|string $searchTerm Search term(s)
|
||||
* @return array Array of predb records
|
||||
*/
|
||||
public function searchPredb(array|string $searchTerm): array
|
||||
{
|
||||
$searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm;
|
||||
|
||||
$result = $this->searchIndexes($this->getPredbIndex(), $searchString, ['title', 'filename'], []);
|
||||
|
||||
return $result['data'] ?? [];
|
||||
}
|
||||
|
||||
public function searchIndexes(string $rt_index, ?string $searchString, array $column = [], array $searchArray = []): array
|
||||
{
|
||||
if (empty($rt_index)) {
|
||||
Log::warning('ManticoreSearch: Index name is required for search');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create cache key for search results
|
||||
$cacheKey = md5(serialize([
|
||||
'index' => $rt_index,
|
||||
'search' => $searchString,
|
||||
'columns' => $column,
|
||||
'array' => $searchArray,
|
||||
]));
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Build query string once so we can retry if needed
|
||||
$searchExpr = null;
|
||||
if (! empty($searchArray)) {
|
||||
$terms = [];
|
||||
foreach ($searchArray as $key => $value) {
|
||||
if (! empty($value)) {
|
||||
$escapedValue = self::escapeString($value);
|
||||
if (! empty($escapedValue)) {
|
||||
$terms[] = '@@relaxed @'.$key.' '.$escapedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (! empty($terms)) {
|
||||
$searchExpr = implode(' ', $terms);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} elseif (! empty($searchString)) {
|
||||
$escapedSearch = self::escapeString($searchString);
|
||||
if (empty($escapedSearch)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$searchColumns = '';
|
||||
if (! empty($column)) {
|
||||
if (count($column) > 1) {
|
||||
$searchColumns = '@('.implode(',', $column).')';
|
||||
} else {
|
||||
$searchColumns = '@'.$column[0];
|
||||
}
|
||||
}
|
||||
|
||||
$searchExpr = '@@relaxed '.$searchColumns.' '.$escapedSearch;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Avoid explicit sort for predb_rt to prevent Manticore's "too many sort-by attributes" error
|
||||
$avoidSortForIndex = ($rt_index === 'predb_rt');
|
||||
|
||||
try {
|
||||
// Use a fresh Search instance for every query to avoid parameter accumulation across calls
|
||||
$query = (new Search($this->manticoreSearch))
|
||||
->setTable($rt_index)
|
||||
->option('ranker', 'sph04')
|
||||
->maxMatches(10000)
|
||||
->limit(10000)
|
||||
->stripBadUtf8(true)
|
||||
->search($searchExpr);
|
||||
|
||||
if (! $avoidSortForIndex) {
|
||||
$query->sort('id', 'desc');
|
||||
}
|
||||
|
||||
$results = $query->get();
|
||||
} catch (ResponseException $e) {
|
||||
// If we hit Manticore's "too many sort-by attributes" limit, retry once without explicit sorting
|
||||
if (stripos($e->getMessage(), 'too many sort-by attributes') !== false) {
|
||||
try {
|
||||
$query = (new Search($this->manticoreSearch))
|
||||
->setTable($rt_index)
|
||||
->option('ranker', 'sph04')
|
||||
->maxMatches(10000)
|
||||
->limit(10000)
|
||||
->stripBadUtf8(true)
|
||||
->search($searchExpr);
|
||||
|
||||
$results = $query->get();
|
||||
|
||||
Log::warning('ManticoreSearch: Retried search without sorting due to sort-by attributes limit', [
|
||||
'index' => $rt_index,
|
||||
]);
|
||||
} catch (ResponseException $e2) {
|
||||
Log::error('ManticoreSearch searchIndexes ResponseException after retry: '.$e2->getMessage(), [
|
||||
'index' => $rt_index,
|
||||
'search' => $searchString,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
Log::error('ManticoreSearch searchIndexes ResponseException: '.$e->getMessage(), [
|
||||
'index' => $rt_index,
|
||||
'search' => $searchString,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
} catch (RuntimeException $e) {
|
||||
Log::error('ManticoreSearch searchIndexes RuntimeException: '.$e->getMessage(), [
|
||||
'index' => $rt_index,
|
||||
'search' => $searchString,
|
||||
]);
|
||||
|
||||
return [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ManticoreSearch searchIndexes unexpected error: '.$e->getMessage(), [
|
||||
'index' => $rt_index,
|
||||
'search' => $searchString,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parse results and cache
|
||||
$resultIds = [];
|
||||
$resultData = [];
|
||||
foreach ($results as $doc) {
|
||||
$resultIds[] = $doc->getId();
|
||||
$resultData[] = $doc->getData();
|
||||
}
|
||||
|
||||
$result = [
|
||||
'id' => $resultIds,
|
||||
'data' => $resultData,
|
||||
];
|
||||
|
||||
// Cache results for 5 minutes
|
||||
Cache::put($cacheKey, $result, now()->addMinutes(5));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete suggestions for a search query.
|
||||
* Searches the releases index and returns matching searchnames.
|
||||
*
|
||||
* @param string $query The partial search query
|
||||
* @param string|null $index Index to search (defaults to releases index)
|
||||
* @return array<array{suggest: string, distance: int, docs: int}>
|
||||
*/
|
||||
public function autocomplete(string $query, ?string $index = null): array
|
||||
{
|
||||
$autocompleteConfig = $this->config['autocomplete'] ?? [
|
||||
'enabled' => true,
|
||||
'min_length' => 2,
|
||||
'max_results' => 10,
|
||||
'cache_minutes' => 10,
|
||||
];
|
||||
|
||||
if (! ($autocompleteConfig['enabled'] ?? true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = trim($query);
|
||||
$minLength = $autocompleteConfig['min_length'] ?? 2;
|
||||
if (strlen($query) < $minLength) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt');
|
||||
$cacheKey = 'manticore:autocomplete:'.md5($index.$query);
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$suggestions = [];
|
||||
$maxResults = $autocompleteConfig['max_results'] ?? 10;
|
||||
|
||||
try {
|
||||
// Search releases index for matching searchnames
|
||||
$escapedQuery = self::escapeString($query);
|
||||
if (empty($escapedQuery)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use relaxed search on searchname field
|
||||
$searchExpr = '@@relaxed @searchname '.$escapedQuery;
|
||||
|
||||
$search = (new Search($this->manticoreSearch))
|
||||
->setTable($index)
|
||||
->search($searchExpr)
|
||||
->sort('id', 'desc')
|
||||
->limit($maxResults * 3)
|
||||
->stripBadUtf8(true);
|
||||
|
||||
$results = $search->get();
|
||||
|
||||
$seen = [];
|
||||
foreach ($results as $doc) {
|
||||
$data = $doc->getData();
|
||||
$searchname = $data['searchname'] ?? '';
|
||||
|
||||
if (empty($searchname)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a clean suggestion from the searchname
|
||||
$suggestion = $this->extractSuggestion($searchname, $query);
|
||||
|
||||
if (! empty($suggestion) && ! isset($seen[strtolower($suggestion)])) {
|
||||
$seen[strtolower($suggestion)] = true;
|
||||
$suggestions[] = [
|
||||
'suggest' => $suggestion,
|
||||
'distance' => 0,
|
||||
'docs' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
if (count($suggestions) >= $maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if (config('app.debug')) {
|
||||
Log::warning('ManticoreSearch autocomplete error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($suggestions)) {
|
||||
$cacheMinutes = (int) ($autocompleteConfig['cache_minutes'] ?? 10);
|
||||
Cache::put($cacheKey, $suggestions, now()->addMinutes($cacheMinutes));
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a clean suggestion from a searchname.
|
||||
*
|
||||
* @param string $searchname The full searchname
|
||||
* @param string $query The user's query
|
||||
* @return string|null The extracted suggestion
|
||||
*/
|
||||
private function extractSuggestion(string $searchname, string $query): ?string
|
||||
{
|
||||
// Clean up the searchname - remove file extensions, quality tags at the end
|
||||
$clean = preg_replace('/\.(mkv|avi|mp4|wmv|nfo|nzb|par2|rar|zip|r\d+)$/i', '', $searchname);
|
||||
|
||||
// Replace dots and underscores with spaces for readability
|
||||
$clean = str_replace(['.', '_'], ' ', $clean);
|
||||
|
||||
// Remove multiple spaces
|
||||
$clean = preg_replace('/\s+/', ' ', $clean);
|
||||
$clean = trim($clean);
|
||||
|
||||
if (empty($clean)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the clean name is reasonable length, use it
|
||||
if (strlen($clean) <= 80) {
|
||||
return $clean;
|
||||
}
|
||||
|
||||
// For very long names, try to extract the relevant part
|
||||
// Find where the query matches and extract context around it
|
||||
$pos = stripos($clean, $query);
|
||||
if ($pos !== false) {
|
||||
// Get up to 80 chars starting from the match position, or from beginning if match is early
|
||||
$start = max(0, $pos - 10);
|
||||
$extracted = substr($clean, $start, 80);
|
||||
|
||||
// Clean up - don't cut mid-word
|
||||
if ($start > 0) {
|
||||
$extracted = preg_replace('/^\S*\s/', '', $extracted);
|
||||
}
|
||||
$extracted = preg_replace('/\s\S*$/', '', $extracted);
|
||||
|
||||
return trim($extracted);
|
||||
}
|
||||
|
||||
// Fallback: just truncate
|
||||
return substr($clean, 0, 80);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spell correction suggestions ("Did you mean?").
|
||||
*
|
||||
* @param string $query The search query to check
|
||||
* @param string|null $index Index to use for suggestions
|
||||
* @return array<array{suggest: string, distance: int, docs: int}>
|
||||
*/
|
||||
public function suggest(string $query, ?string $index = null): array
|
||||
{
|
||||
$suggestConfig = $this->config['suggest'] ?? [
|
||||
'enabled' => true,
|
||||
'max_edits' => 4,
|
||||
];
|
||||
|
||||
if (! ($suggestConfig['enabled'] ?? true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = trim($query);
|
||||
if (empty($query)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt');
|
||||
$cacheKey = 'manticore:suggest:'.md5($index.$query);
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$suggestions = [];
|
||||
|
||||
try {
|
||||
// Try native CALL SUGGEST first
|
||||
$result = $this->manticoreSearch->suggest([
|
||||
'table' => $index,
|
||||
'body' => [
|
||||
'query' => $query,
|
||||
'options' => [
|
||||
'limit' => 5,
|
||||
'max_edits' => $suggestConfig['max_edits'] ?? 4,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
if (! empty($result) && is_array($result)) {
|
||||
foreach ($result as $item) {
|
||||
if (isset($item['suggest']) && $item['suggest'] !== $query) {
|
||||
$suggestions[] = [
|
||||
'suggest' => $item['suggest'],
|
||||
'distance' => $item['distance'] ?? 0,
|
||||
'docs' => $item['docs'] ?? 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if (config('app.debug')) {
|
||||
Log::debug('ManticoreSearch native suggest failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// If native suggest didn't return results, try a fuzzy search fallback
|
||||
if (empty($suggestions)) {
|
||||
$suggestions = $this->suggestFallback($query, $index);
|
||||
}
|
||||
|
||||
if (! empty($suggestions)) {
|
||||
Cache::put($cacheKey, $suggestions, now()->addMinutes(5));
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback suggest using similar searchname matches.
|
||||
*
|
||||
* @param string $query The search query
|
||||
* @param string $index Index to search
|
||||
* @return array<array{suggest: string, distance: int, docs: int}>
|
||||
*/
|
||||
private function suggestFallback(string $query, string $index): array
|
||||
{
|
||||
try {
|
||||
$escapedQuery = self::escapeString($query);
|
||||
if (empty($escapedQuery)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use relaxed search to find partial matches
|
||||
$searchExpr = '@@relaxed @searchname '.$escapedQuery;
|
||||
|
||||
$search = (new Search($this->manticoreSearch))
|
||||
->setTable($index)
|
||||
->search($searchExpr)
|
||||
->limit(20)
|
||||
->stripBadUtf8(true);
|
||||
|
||||
$results = $search->get();
|
||||
|
||||
// Extract common terms from the results that differ from the query
|
||||
$termCounts = [];
|
||||
foreach ($results as $doc) {
|
||||
$data = $doc->getData();
|
||||
$searchname = $data['searchname'] ?? '';
|
||||
|
||||
// Extract words from searchname
|
||||
$words = preg_split('/[\s.\-_]+/', strtolower($searchname));
|
||||
foreach ($words as $word) {
|
||||
if (strlen($word) >= 3 && $word !== strtolower($query)) {
|
||||
// Check if word is similar to query (within edit distance)
|
||||
$distance = levenshtein(strtolower($query), $word);
|
||||
if ($distance > 0 && $distance <= 3) {
|
||||
if (! isset($termCounts[$word])) {
|
||||
$termCounts[$word] = ['count' => 0, 'distance' => $distance];
|
||||
}
|
||||
$termCounts[$word]['count']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by count (most common first)
|
||||
uasort($termCounts, fn ($a, $b) => $b['count'] - $a['count']);
|
||||
|
||||
$suggestions = [];
|
||||
foreach (array_slice($termCounts, 0, 5, true) as $term => $data) {
|
||||
$suggestions[] = [
|
||||
'suggest' => $term,
|
||||
'distance' => $data['distance'],
|
||||
'docs' => $data['count'],
|
||||
];
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
} catch (\Throwable $e) {
|
||||
if (config('app.debug')) {
|
||||
Log::debug('ManticoreSearch suggest fallback error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ return [
|
||||
App\Providers\TvProcessingServiceProvider::class,
|
||||
App\Providers\UserServiceProvider::class,
|
||||
App\Providers\VoltServiceProvider::class,
|
||||
App\Providers\SearchServiceProvider::class,
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ require_once dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bootstrap/autoload.php';
|
||||
|
||||
use App\Models\Release;
|
||||
use App\Models\UsenetGroup;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Blacklight\ReleaseCleaning;
|
||||
|
||||
$message =
|
||||
@@ -52,7 +52,7 @@ if (\count($releases) === 0) {
|
||||
}
|
||||
|
||||
$releaseCleaner = new ReleaseCleaning;
|
||||
$manticore = new ManticoreSearch;
|
||||
$manticore = app(ManticoreSearchService::class);
|
||||
|
||||
foreach ($releases as $release) {
|
||||
echo '.';
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace Tests\Unit;
|
||||
use App\Models\Release as ReleaseModel;
|
||||
use App\Services\Categorization\CategorizationService;
|
||||
use App\Services\MediaProcessingService;
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use App\Services\Search\ElasticSearchService;
|
||||
use App\Services\Search\ManticoreSearchService;
|
||||
use Blacklight\ReleaseExtra;
|
||||
use Blacklight\ReleaseImage;
|
||||
use FFMpeg\FFMpeg;
|
||||
@@ -51,8 +51,8 @@ class MediaProcessingServiceTest extends TestCase
|
||||
?MediaInfo $mediaInfo = null,
|
||||
?ReleaseImage $releaseImage = null,
|
||||
?ReleaseExtra $releaseExtra = null,
|
||||
?ManticoreSearch $manticore = null,
|
||||
?ElasticSearchSiteSearch $elastic = null,
|
||||
?ManticoreSearchService $manticore = null,
|
||||
?ElasticSearchService $elastic = null,
|
||||
?CategorizationService $categorize = null
|
||||
): MediaProcessingService {
|
||||
$ffmpeg ??= Mockery::mock(FFMpeg::class);
|
||||
@@ -60,8 +60,8 @@ class MediaProcessingServiceTest extends TestCase
|
||||
$mediaInfo ??= Mockery::mock(MediaInfo::class);
|
||||
$releaseImage ??= Mockery::mock(ReleaseImage::class);
|
||||
$releaseExtra ??= Mockery::mock(ReleaseExtra::class);
|
||||
$manticore ??= Mockery::mock(ManticoreSearch::class);
|
||||
$elastic ??= Mockery::mock(ElasticSearchSiteSearch::class);
|
||||
$manticore ??= Mockery::mock(ManticoreSearchService::class);
|
||||
$elastic ??= Mockery::mock(ElasticSearchService::class);
|
||||
$categorize ??= Mockery::mock(CategorizationService::class);
|
||||
|
||||
return new MediaProcessingService($ffmpeg, $ffprobe, $mediaInfo, $releaseImage, $releaseExtra, $manticore, $elastic, $categorize);
|
||||
|
||||
Reference in New Issue
Block a user