diff --git a/.env.dist b/.env.dist index 274530c1d..44c857ea4 100644 --- a/.env.dist +++ b/.env.dist @@ -117,9 +117,6 @@ NOCAPTCHA_ENABLED=${NOCAPTCHA_ENABLED} NOCAPTCHA_SITEKEY=${NOCAPTCHA_SITEKEY} NOCAPTCHA_SECRET=${NOCAPTCHA_SECRET} -SCOUT_DRIVER=${SCOUT_DRIVER} -SCOUT_QUEUE=${SCOUT_QUEUE} - ITEMS_PER_PAGE=${ITEMS_PER_PAGE} ITEMS_PER_COVER_PAGE=${ITEMS_PER_COVER_PAGE} MAX_PAGER_RESULTS=${MAX_PAGER_RESULTS} diff --git a/.env.example b/.env.example index 52ae6035c..d31934453 100644 --- a/.env.example +++ b/.env.example @@ -153,9 +153,6 @@ TURNSTILE_SECRET= TURNSTILE_SITEKEY= TURNSTILE_ENABLED=false -SCOUT_DRIVER=database -SCOUT_QUEUE=true - ITEMS_PER_PAGE=50 ITEMS_PER_COVER_PAGE=25 MAX_PAGER_RESULTS=125000 diff --git a/app/Console/Commands/CreateManticoreIndexes.php b/app/Console/Commands/CreateManticoreIndexes.php index 9fb897149..2fd5e7e1e 100644 --- a/app/Console/Commands/CreateManticoreIndexes.php +++ b/app/Console/Commands/CreateManticoreIndexes.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Console\Commands; +use App\Enums\SecondarySearchIndex; use Illuminate\Console\Command; use Manticoresearch\Client; use Manticoresearch\Exceptions\ResponseException; @@ -84,6 +85,15 @@ class CreateManticoreIndexes extends Command 'tvrage' => ['type' => 'integer'], 'videos_id' => ['type' => 'integer'], 'movieinfo_id' => ['type' => 'integer'], + 'size' => ['type' => 'bigint'], + 'postdate_ts' => ['type' => 'bigint'], + 'adddate_ts' => ['type' => 'bigint'], + 'totalpart' => ['type' => 'integer'], + 'grabs' => ['type' => 'integer'], + 'passwordstatus' => ['type' => 'integer'], + 'groups_id' => ['type' => 'integer'], + 'nzbstatus' => ['type' => 'integer'], + 'haspreview' => ['type' => 'integer'], ], ], 'predb_rt' => [ @@ -132,6 +142,48 @@ class CreateManticoreIndexes extends Command 'type' => ['type' => 'integer'], ], ], + 'music_rt' => [ + 'settings' => [ + 'min_prefix_len' => 0, + 'min_infix_len' => 2, + ], + 'columns' => SecondarySearchIndex::Music->manticoreColumns(), + ], + 'books_rt' => [ + 'settings' => [ + 'min_prefix_len' => 0, + 'min_infix_len' => 2, + ], + 'columns' => SecondarySearchIndex::Books->manticoreColumns(), + ], + 'games_rt' => [ + 'settings' => [ + 'min_prefix_len' => 0, + 'min_infix_len' => 2, + ], + 'columns' => SecondarySearchIndex::Games->manticoreColumns(), + ], + 'console_rt' => [ + 'settings' => [ + 'min_prefix_len' => 0, + 'min_infix_len' => 2, + ], + 'columns' => SecondarySearchIndex::Console->manticoreColumns(), + ], + 'steam_rt' => [ + 'settings' => [ + 'min_prefix_len' => 0, + 'min_infix_len' => 2, + ], + 'columns' => SecondarySearchIndex::Steam->manticoreColumns(), + ], + 'anime_rt' => [ + 'settings' => [ + 'min_prefix_len' => 0, + 'min_infix_len' => 2, + ], + 'columns' => SecondarySearchIndex::Anime->manticoreColumns(), + ], ]; $hasErrors = false; diff --git a/app/Console/Commands/NntmuxCheckIndex.php b/app/Console/Commands/NntmuxCheckIndex.php index 4d161826b..23e68dfb4 100644 --- a/app/Console/Commands/NntmuxCheckIndex.php +++ b/app/Console/Commands/NntmuxCheckIndex.php @@ -23,7 +23,15 @@ class NntmuxCheckIndex extends Command {--manticore : Check ManticoreSearch} {--elastic : Check ElasticSearch} {--releases : Check the releases index} - {--predb : Check the predb index}'; + {--predb : Check the predb index} + {--movies : Check the movies index} + {--tvshows : Check the TV shows index} + {--music : Check the music index} + {--books : Check the books index} + {--games : Check the games index} + {--console : Check the console index} + {--steam : Check the steam index} + {--anime : Check the anime index}'; /** * The console command description. @@ -41,7 +49,7 @@ class NntmuxCheckIndex extends Command $index = $this->getSelectedIndex(); if (! $engine || ! $index) { - $this->error('You must specify both an engine (--manticore or --elastic) and an index (--releases or --predb).'); + $this->error('You must specify both an engine (--manticore or --elastic) and an index option (e.g. --releases, --predb, --movies, --music, ...).'); return Command::FAILURE; } @@ -68,6 +76,8 @@ class NntmuxCheckIndex extends Command */ private function checkElasticIndex(string $index): void { + $index = $this->elasticIndexName($index); + try { // Check if index exists /** @var ElasticsearchClient $client */ @@ -117,7 +127,19 @@ class NntmuxCheckIndex extends Command private function checkManticoreIndex(string $index): void { try { - $indexName = $index === 'releases' ? 'releases_rt' : 'predb_rt'; + $indexName = match ($index) { + 'releases' => 'releases_rt', + 'predb' => 'predb_rt', + 'movies' => 'movies_rt', + 'tvshows' => 'tvshows_rt', + 'music' => 'music_rt', + 'books' => 'books_rt', + 'games' => 'games_rt', + 'console' => 'console_rt', + 'steam' => 'steam_rt', + 'anime' => 'anime_rt', + default => 'releases_rt', + }; $host = config('nntmux.manticore.host', '127.0.0.1'); $port = config('nntmux.manticore.port', 9308); @@ -273,6 +295,25 @@ class NntmuxCheckIndex extends Command */ private function getSelectedIndex(): ?string { - return $this->option('releases') ? 'releases' : ($this->option('predb') ? 'predb' : null); + $options = [ + 'releases', 'predb', 'movies', 'tvshows', 'music', 'books', 'games', 'console', 'steam', 'anime', + ]; + foreach ($options as $opt) { + if ($this->option($opt)) { + return $opt; + } + } + + return null; + } + + /** + * Resolve ElasticSearch index name for logical keys. + */ + private function elasticIndexName(string $index): string + { + $configured = config('search.drivers.elasticsearch.indexes.'.$index); + + return is_string($configured) && $configured !== '' ? $configured : $index; } } diff --git a/app/Console/Commands/NntmuxCreateESIndexes.php b/app/Console/Commands/NntmuxCreateESIndexes.php index cfdb52925..60e3c35a0 100644 --- a/app/Console/Commands/NntmuxCreateESIndexes.php +++ b/app/Console/Commands/NntmuxCreateESIndexes.php @@ -23,7 +23,7 @@ class NntmuxCreateESIndexes extends Command * * @var string */ - protected $description = 'Create ElasticSearch Indexes'; + protected $description = 'Create Elasticsearch indexes (releases, predb, movies, tvshows, and metadata indexes)'; /** * Execute the console command. @@ -33,124 +33,275 @@ class NntmuxCreateESIndexes extends Command /** @var Client $client */ $client = app('elasticsearch'); - if (ElasticsearchResponseHelper::boolResponse($client, fn (Client $elasticClient) => $elasticClient->indices()->exists(['index' => 'releases']))) { - Elasticsearch::indices()->delete(['index' => 'releases']); - } - $releases_index = [ - 'index' => 'releases', - 'body' => [ - 'settings' => [ - 'number_of_shards' => 2, - 'number_of_replicas' => 0, - 'analysis' => [ - 'analyzer' => [ - 'release_analyzer' => [ - 'type' => 'custom', - 'tokenizer' => 'standard', - 'filter' => ['lowercase', 'asciifolding'], - ], - ], + $names = config('search.drivers.elasticsearch.indexes', []); + $releasesIndex = (string) ($names['releases'] ?? 'releases'); + $predbIndex = (string) ($names['predb'] ?? 'predb'); + + $defaultSettings = [ + 'number_of_shards' => 2, + 'number_of_replicas' => 0, + 'analysis' => [ + 'analyzer' => [ + 'release_analyzer' => [ + 'type' => 'custom', + 'tokenizer' => 'standard', + 'filter' => ['lowercase', 'asciifolding'], ], - ], - 'mappings' => [ - 'properties' => [ - 'id' => [ - 'type' => 'long', - 'index' => false, - ], - 'name' => [ - 'type' => 'text', - 'analyzer' => 'release_analyzer', - ], - 'searchname' => [ - 'type' => 'text', - 'analyzer' => 'release_analyzer', - 'fields' => [ - 'keyword' => [ - 'type' => 'keyword', - 'ignore_above' => 256, - ], - 'sort' => [ - 'type' => 'keyword', - ], - ], - ], - 'plainsearchname' => [ - 'type' => 'text', - 'analyzer' => 'release_analyzer', - 'fields' => [ - 'keyword' => [ - 'type' => 'keyword', - 'ignore_above' => 256, - ], - 'sort' => [ - 'type' => 'keyword', - ], - ], - ], - 'categories_id' => [ - 'type' => 'integer', - ], - 'fromname' => [ - 'type' => 'text', - 'analyzer' => 'release_analyzer', - ], - 'filename' => [ - 'type' => 'text', - 'analyzer' => 'release_analyzer', - ], - 'add_date' => [ - 'type' => 'date', - 'format' => 'yyyy-MM-dd HH:mm:ss', - ], - 'post_date' => [ - 'type' => 'date', - 'format' => 'yyyy-MM-dd HH:mm:ss', - ], + 'metadata_analyzer' => [ + 'type' => 'custom', + 'tokenizer' => 'standard', + 'filter' => ['lowercase', 'asciifolding'], ], ], ], ]; - $response = Elasticsearch::indices()->create($releases_index); - - $this->info('Index releases created successfully'); - if (ElasticsearchResponseHelper::boolResponse($client, fn (Client $elasticClient) => $elasticClient->indices()->exists(['index' => 'predb']))) { - Elasticsearch::indices()->delete(['index' => 'predb']); - } - $predb_index = [ - 'index' => 'predb', - 'body' => [ - 'settings' => [ - 'number_of_shards' => 2, - 'number_of_replicas' => 0, - ], - 'mappings' => [ - 'properties' => [ - 'id' => [ - 'type' => 'long', - 'index' => false, - ], - 'title' => [ - 'type' => 'text', - 'fields' => [ - 'sort' => [ - 'type' => 'keyword', - ], + $this->recreateIndex($client, $releasesIndex, [ + 'settings' => $defaultSettings, + 'mappings' => [ + 'properties' => [ + 'id' => [ + 'type' => 'long', + 'index' => false, + ], + 'name' => [ + 'type' => 'text', + 'analyzer' => 'release_analyzer', + ], + 'searchname' => [ + 'type' => 'text', + 'analyzer' => 'release_analyzer', + 'fields' => [ + 'keyword' => [ + 'type' => 'keyword', + 'ignore_above' => 256, + ], + 'sort' => [ + 'type' => 'keyword', ], ], - 'filename' => ['type' => 'text'], - 'source' => ['type' => 'text'], ], + 'plainsearchname' => [ + 'type' => 'text', + 'analyzer' => 'release_analyzer', + 'fields' => [ + 'keyword' => [ + 'type' => 'keyword', + 'ignore_above' => 256, + ], + 'sort' => [ + 'type' => 'keyword', + ], + ], + ], + 'categories_id' => [ + 'type' => 'integer', + ], + 'fromname' => [ + 'type' => 'text', + 'analyzer' => 'release_analyzer', + ], + 'filename' => [ + 'type' => 'text', + 'analyzer' => 'release_analyzer', + ], + 'imdbid' => [ + 'type' => 'keyword', + 'ignore_above' => 32, + ], + 'add_date' => [ + 'type' => 'date', + 'format' => 'yyyy-MM-dd HH:mm:ss', + ], + 'post_date' => [ + 'type' => 'date', + 'format' => 'yyyy-MM-dd HH:mm:ss', + ], + 'postdate_ts' => ['type' => 'long'], + 'adddate_ts' => ['type' => 'long'], + 'size' => ['type' => 'long'], + 'totalpart' => ['type' => 'integer'], + 'grabs' => ['type' => 'integer'], + 'passwordstatus' => ['type' => 'integer'], + 'groups_id' => ['type' => 'integer'], + 'nzbstatus' => ['type' => 'integer'], + 'haspreview' => ['type' => 'integer'], + 'tmdbid' => ['type' => 'integer'], + 'traktid' => ['type' => 'integer'], + 'tvdb' => ['type' => 'integer'], + 'tvmaze' => ['type' => 'integer'], + 'tvrage' => ['type' => 'integer'], + 'videos_id' => ['type' => 'integer'], + 'movieinfo_id' => ['type' => 'integer'], ], ], + ]); - ]; + $this->recreateIndex($client, $predbIndex, [ + 'settings' => [ + 'number_of_shards' => 2, + 'number_of_replicas' => 0, + ], + 'mappings' => [ + 'properties' => [ + 'id' => [ + 'type' => 'long', + 'index' => false, + ], + 'title' => [ + 'type' => 'text', + 'fields' => [ + 'sort' => [ + 'type' => 'keyword', + ], + ], + ], + 'filename' => ['type' => 'text'], + 'source' => ['type' => 'text'], + ], + ], + ]); - $response = Elasticsearch::indices()->create($predb_index); + $metaSettings = $defaultSettings; - $this->info('Index predb created successfully'); + $moviesIndex = (string) ($names['movies'] ?? 'movies'); + $this->recreateIndex($client, $moviesIndex, [ + 'settings' => $metaSettings, + 'mappings' => [ + 'properties' => [ + 'id' => ['type' => 'long', 'index' => false], + 'imdbid' => ['type' => 'keyword', 'ignore_above' => 32], + 'tmdbid' => ['type' => 'long'], + 'traktid' => ['type' => 'long'], + 'title' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'year' => ['type' => 'keyword', 'ignore_above' => 16], + 'genre' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'actors' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'director' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'rating' => ['type' => 'keyword', 'ignore_above' => 32], + 'plot' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + ], + ], + ]); - $this->info('All done! ElasticSearch indexes are created now.'); + $tvshowsIndex = (string) ($names['tvshows'] ?? 'tvshows'); + $this->recreateIndex($client, $tvshowsIndex, [ + 'settings' => $metaSettings, + 'mappings' => [ + 'properties' => [ + 'id' => ['type' => 'long', 'index' => false], + 'title' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'tvdb' => ['type' => 'integer'], + 'trakt' => ['type' => 'integer'], + 'tvmaze' => ['type' => 'integer'], + 'tvrage' => ['type' => 'integer'], + 'imdb' => ['type' => 'keyword', 'ignore_above' => 32], + 'tmdb' => ['type' => 'integer'], + 'started' => ['type' => 'keyword', 'ignore_above' => 32], + 'type' => ['type' => 'integer'], + ], + ], + ]); + + $musicIndex = (string) ($names['music'] ?? 'music'); + $this->recreateIndex($client, $musicIndex, [ + 'settings' => $metaSettings, + 'mappings' => [ + 'properties' => [ + 'title' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'artist' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'year' => ['type' => 'keyword', 'ignore_above' => 16], + 'genres_id' => ['type' => 'integer'], + 'cover' => ['type' => 'integer'], + ], + ], + ]); + + $booksIndex = (string) ($names['books'] ?? 'books'); + $this->recreateIndex($client, $booksIndex, [ + 'settings' => $metaSettings, + 'mappings' => [ + 'properties' => [ + 'title' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'author' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'publishdate' => ['type' => 'keyword', 'ignore_above' => 32], + 'cover' => ['type' => 'integer'], + ], + ], + ]); + + $gamesIndex = (string) ($names['games'] ?? 'games'); + $this->recreateIndex($client, $gamesIndex, [ + 'settings' => $metaSettings, + 'mappings' => [ + 'properties' => [ + 'title' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'genres_id' => ['type' => 'integer'], + 'releasedate_ts' => ['type' => 'long'], + 'cover' => ['type' => 'integer'], + ], + ], + ]); + + $consoleIndex = (string) ($names['console'] ?? 'console'); + $this->recreateIndex($client, $consoleIndex, [ + 'settings' => $metaSettings, + 'mappings' => [ + 'properties' => [ + 'title' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'platform' => ['type' => 'keyword', 'ignore_above' => 128], + 'genres_id' => ['type' => 'integer'], + 'releasedate_ts' => ['type' => 'long'], + 'cover' => ['type' => 'integer'], + ], + ], + ]); + + $steamIndex = (string) ($names['steam'] ?? 'steam'); + $this->recreateIndex($client, $steamIndex, [ + 'settings' => $metaSettings, + 'mappings' => [ + 'properties' => [ + 'name' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'appid' => ['type' => 'integer'], + ], + ], + ]); + + $animeIndex = (string) ($names['anime'] ?? 'anime'); + $this->recreateIndex($client, $animeIndex, [ + 'settings' => $metaSettings, + 'mappings' => [ + 'properties' => [ + 'title' => ['type' => 'text', 'analyzer' => 'metadata_analyzer'], + 'anidbid' => ['type' => 'integer'], + 'anilist_id' => ['type' => 'integer'], + 'mal_id' => ['type' => 'integer'], + 'lang' => ['type' => 'keyword', 'ignore_above' => 16], + 'title_type' => ['type' => 'keyword', 'ignore_above' => 32], + 'media_type' => ['type' => 'keyword', 'ignore_above' => 32], + 'status' => ['type' => 'keyword', 'ignore_above' => 32], + ], + ], + ]); + + $this->info('All Elasticsearch indexes are created (or recreated).'); + } + + /** + * @param array $body + */ + private function recreateIndex(Client $client, string $indexName, array $body): void + { + if (ElasticsearchResponseHelper::boolResponse($client, fn (Client $elasticClient) => $elasticClient->indices()->exists(['index' => $indexName]))) { + Elasticsearch::indices()->delete(['index' => $indexName]); + } + + Elasticsearch::indices()->create([ + 'index' => $indexName, + 'body' => $body, + ]); + + $this->info("Index {$indexName} created successfully."); } } diff --git a/app/Console/Commands/NntmuxPopulateSearchIndexes.php b/app/Console/Commands/NntmuxPopulateSearchIndexes.php index 6deb16855..fb6f087f3 100644 --- a/app/Console/Commands/NntmuxPopulateSearchIndexes.php +++ b/app/Console/Commands/NntmuxPopulateSearchIndexes.php @@ -4,14 +4,23 @@ declare(strict_types=1); namespace App\Console\Commands; +use App\Enums\SecondarySearchIndex; use App\Facades\Elasticsearch; use App\Facades\Search; +use App\Models\BookInfo; +use App\Models\ConsoleInfo; +use App\Models\GamesInfo; use App\Models\MovieInfo; +use App\Models\MusicInfo; use App\Models\Predb; use App\Models\Release; +use App\Models\SteamApp; use App\Models\Video; +use App\Support\ReleaseSearchIndexDocument; +use App\Support\SecondaryIndexDocuments; use Exception; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; class NntmuxPopulateSearchIndexes extends Command @@ -28,6 +37,13 @@ class NntmuxPopulateSearchIndexes extends Command {--predb : Populates the predb index} {--movies : Populates the movies index} {--tvshows : Populates the TV shows index} + {--music : Populates the music metadata index} + {--books : Populates the books metadata index} + {--games : Populates the games metadata index} + {--console : Populates the console metadata index} + {--steam : Populates the Steam apps index} + {--anime : Populates the anime titles index} + {--all : Populate all supported indexes for the selected engine} {--count=50000 : Sets the chunk size} {--parallel=4 : Number of parallel processes} {--batch-size=5000 : Batch size for bulk operations} @@ -39,11 +55,14 @@ class NntmuxPopulateSearchIndexes extends Command * * @var string */ - protected $description = 'Populate Manticore/Elasticsearch indexes with releases, predb, movies, or tvshows'; + protected $description = 'Populate Manticore/Elasticsearch indexes (releases, predb, movies, tvshows, music, books, games, console, steam, anime)'; private const SUPPORTED_ENGINES = ['manticore', 'elastic']; - private const SUPPORTED_INDEXES = ['releases', 'predb', 'movies', 'tvshows']; + private const SUPPORTED_INDEXES = [ + 'releases', 'predb', 'movies', 'tvshows', + 'music', 'books', 'games', 'console', 'steam', 'anime', + ]; private const GROUP_CONCAT_MAX_LEN = 16384; @@ -65,16 +84,24 @@ class NntmuxPopulateSearchIndexes extends Command } $engine = $this->getSelectedEngine(); - $index = $this->getSelectedIndex(); + $indexes = $this->getSelectedIndexes(); - if (! $engine || ! $index) { - $this->error('You must specify both an engine (--manticore or --elastic) and an index (--releases or --predb).'); + if (! $engine || $indexes === []) { + $this->error('You must specify an engine (--manticore or --elastic) and at least one index (or --all).'); $this->info('Use --help to see all available options.'); return Command::FAILURE; } - return $this->populateIndex($engine, $index); + $exit = Command::SUCCESS; + foreach ($indexes as $index) { + $result = $this->populateIndex($engine, $index); + if ($result !== Command::SUCCESS) { + $exit = $result; + } + } + + return $exit; } catch (Exception $e) { $this->error("An error occurred: {$e->getMessage()}"); @@ -102,17 +129,22 @@ class NntmuxPopulateSearchIndexes extends Command } /** - * Get the selected index from options + * @return list */ - private function getSelectedIndex(): ?string + private function getSelectedIndexes(): array { + if ($this->option('all')) { + return self::SUPPORTED_INDEXES; + } + + $selected = []; foreach (self::SUPPORTED_INDEXES as $index) { if ($this->option($index)) { - return $index; + $selected[] = $index; } } - return null; + return $selected; } /** @@ -183,6 +215,15 @@ class NntmuxPopulateSearchIndexes extends Command 'releases.searchname', 'releases.fromname', 'releases.categories_id', + 'releases.size', + 'releases.postdate', + 'releases.adddate', + 'releases.totalpart', + 'releases.grabs', + 'releases.passwordstatus', + 'releases.groups_id', + 'releases.nzbstatus', + 'releases.haspreview', 'releases.videos_id', 'releases.movieinfo_id', 'releases.imdbid', @@ -193,22 +234,10 @@ class NntmuxPopulateSearchIndexes extends Command $total, $query, function ($item) { - return [ - 'id' => (int) $item->id, - 'name' => (string) ($item->name ?? ''), - 'searchname' => (string) ($item->searchname ?? ''), - 'fromname' => (string) ($item->fromname ?? ''), - 'categories_id' => (int) ($item->categories_id ?? 0), - 'filename' => '', - 'videos_id' => (int) ($item->videos_id ?? 0), - 'movieinfo_id' => (int) ($item->movieinfo_id ?? 0), - 'imdbid' => (string) ($item->imdbid ?? ''), - 'tmdbid' => 0, - 'traktid' => 0, - 'tvdb' => 0, - 'tvmaze' => 0, - 'tvrage' => 0, - ]; + return ReleaseSearchIndexDocument::normalize(array_merge( + (array) $item->getAttributes(), + ['filename' => ''] + )); } ); } @@ -354,6 +383,150 @@ class NntmuxPopulateSearchIndexes extends Command ); } + private function manticoreMusic(): int + { + $indexName = 'music_rt'; + Search::truncateIndex([$indexName]); + $total = MusicInfo::count(); + if (! $total) { + $this->warn('MusicInfo table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + $query = MusicInfo::query()->orderBy('id'); + + return $this->processManticoreData( + $indexName, + $total, + $query, + fn (MusicInfo $m): array => array_merge(['id' => $m->id], SecondaryIndexDocuments::music($m)) + ); + } + + private function manticoreBooks(): int + { + $indexName = 'books_rt'; + Search::truncateIndex([$indexName]); + $total = BookInfo::count(); + if (! $total) { + $this->warn('BookInfo table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + $query = BookInfo::query()->orderBy('id'); + + return $this->processManticoreData( + $indexName, + $total, + $query, + fn (BookInfo $b): array => array_merge(['id' => $b->id], SecondaryIndexDocuments::book($b)) + ); + } + + private function manticoreGames(): int + { + $indexName = 'games_rt'; + Search::truncateIndex([$indexName]); + $total = GamesInfo::count(); + if (! $total) { + $this->warn('GamesInfo table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + $query = GamesInfo::query()->orderBy('id'); + + return $this->processManticoreData( + $indexName, + $total, + $query, + fn (GamesInfo $g): array => array_merge(['id' => $g->id], SecondaryIndexDocuments::games($g)) + ); + } + + private function manticoreConsole(): int + { + $indexName = 'console_rt'; + Search::truncateIndex([$indexName]); + $total = ConsoleInfo::count(); + if (! $total) { + $this->warn('ConsoleInfo table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + $query = ConsoleInfo::query()->orderBy('id'); + + return $this->processManticoreData( + $indexName, + $total, + $query, + fn (ConsoleInfo $c): array => array_merge(['id' => $c->id], SecondaryIndexDocuments::console($c)) + ); + } + + private function manticoreSteam(): int + { + $indexName = 'steam_rt'; + Search::truncateIndex([$indexName]); + $total = SteamApp::count(); + if (! $total) { + $this->warn('Steam apps table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + $query = SteamApp::query()->orderBy('id'); + + return $this->processManticoreData( + $indexName, + $total, + $query, + fn (SteamApp $s): array => array_merge(['id' => (int) $s->id], SecondaryIndexDocuments::steam($s)) + ); + } + + private function manticoreAnime(): int + { + $indexName = 'anime_rt'; + Search::truncateIndex([$indexName]); + $total = (int) DB::table('anidb_titles')->count(); + if (! $total) { + $this->warn('anidb_titles table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + $query = DB::table('anidb_titles as t') + ->leftJoin('anidb_info as i', 't.anidbid', '=', 'i.anidbid') + ->orderBy('t.anidbid') + ->select([ + 't.anidbid', 't.type', 't.lang', 't.title', + 'i.anilist_id', 'i.mal_id', 'i.media_type', 'i.status', + ]); + + return $this->processManticoreData( + $indexName, + $total, + $query, + function ($row) { + $docId = SecondarySearchIndex::animeTitleDocumentId( + (int) $row->anidbid, + (string) $row->type, + (string) $row->lang, + (string) $row->title + ); + + return array_merge(['id' => $docId], [ + 'title' => (string) $row->title, + 'anidbid' => (int) $row->anidbid, + 'anilist_id' => (int) ($row->anilist_id ?? 0), + 'mal_id' => (int) ($row->mal_id ?? 0), + 'lang' => (string) $row->lang, + 'title_type' => (string) $row->type, + 'media_type' => (string) ($row->media_type ?? ''), + 'status' => (string) ($row->status ?? ''), + ]); + } + ); + } + /** * Process data for ManticoreSearch with optimizations */ @@ -665,6 +838,215 @@ class NntmuxPopulateSearchIndexes extends Command ); } + private function elasticMusic(): int + { + $ix = (string) config('search.drivers.elasticsearch.indexes.music', 'music'); + Search::truncateIndex([$ix]); + $total = MusicInfo::count(); + if (! $total) { + $this->warn('MusicInfo table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + $query = MusicInfo::query()->orderBy('id'); + + return $this->processSecondaryElasticChunks( + SecondarySearchIndex::Music, + $total, + $query, + fn (MusicInfo $m): array => array_merge(['id' => $m->id], SecondaryIndexDocuments::music($m)) + ); + } + + private function elasticBooks(): int + { + $ix = (string) config('search.drivers.elasticsearch.indexes.books', 'books'); + Search::truncateIndex([$ix]); + $total = BookInfo::count(); + if (! $total) { + $this->warn('BookInfo table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + $query = BookInfo::query()->orderBy('id'); + + return $this->processSecondaryElasticChunks( + SecondarySearchIndex::Books, + $total, + $query, + fn (BookInfo $b): array => array_merge(['id' => $b->id], SecondaryIndexDocuments::book($b)) + ); + } + + private function elasticGames(): int + { + $ix = (string) config('search.drivers.elasticsearch.indexes.games', 'games'); + Search::truncateIndex([$ix]); + $total = GamesInfo::count(); + if (! $total) { + $this->warn('GamesInfo table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + $query = GamesInfo::query()->orderBy('id'); + + return $this->processSecondaryElasticChunks( + SecondarySearchIndex::Games, + $total, + $query, + fn (GamesInfo $g): array => array_merge(['id' => $g->id], SecondaryIndexDocuments::games($g)) + ); + } + + private function elasticConsole(): int + { + $ix = (string) config('search.drivers.elasticsearch.indexes.console', 'console'); + Search::truncateIndex([$ix]); + $total = ConsoleInfo::count(); + if (! $total) { + $this->warn('ConsoleInfo table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + $query = ConsoleInfo::query()->orderBy('id'); + + return $this->processSecondaryElasticChunks( + SecondarySearchIndex::Console, + $total, + $query, + fn (ConsoleInfo $c): array => array_merge(['id' => $c->id], SecondaryIndexDocuments::console($c)) + ); + } + + private function elasticSteam(): int + { + $ix = (string) config('search.drivers.elasticsearch.indexes.steam', 'steam'); + Search::truncateIndex([$ix]); + $total = SteamApp::count(); + if (! $total) { + $this->warn('Steam apps table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + $query = SteamApp::query()->orderBy('id'); + + return $this->processSecondaryElasticChunks( + SecondarySearchIndex::Steam, + $total, + $query, + fn (SteamApp $s): array => array_merge(['id' => (int) $s->id], SecondaryIndexDocuments::steam($s)) + ); + } + + private function elasticAnime(): int + { + $ix = (string) config('search.drivers.elasticsearch.indexes.anime', 'anime'); + Search::truncateIndex([$ix]); + $total = (int) DB::table('anidb_titles')->count(); + if (! $total) { + $this->warn('anidb_titles table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + $query = DB::table('anidb_titles as t') + ->leftJoin('anidb_info as i', 't.anidbid', '=', 'i.anidbid') + ->orderBy('t.anidbid') + ->select([ + 't.anidbid', 't.type', 't.lang', 't.title', + 'i.anilist_id', 'i.mal_id', 'i.media_type', 'i.status', + ]); + + return $this->processSecondaryElasticChunks( + SecondarySearchIndex::Anime, + $total, + $query, + function ($row) { + $docId = SecondarySearchIndex::animeTitleDocumentId( + (int) $row->anidbid, + (string) $row->type, + (string) $row->lang, + (string) $row->title + ); + + return array_merge(['id' => $docId], [ + 'title' => (string) $row->title, + 'anidbid' => (int) $row->anidbid, + 'anilist_id' => (int) ($row->anilist_id ?? 0), + 'mal_id' => (int) ($row->mal_id ?? 0), + 'lang' => (string) $row->lang, + 'title_type' => (string) $row->type, + 'media_type' => (string) ($row->media_type ?? ''), + 'status' => (string) ($row->status ?? ''), + ]); + } + ); + } + + /** + * @param Builder|\Illuminate\Database\Query\Builder $query + */ + private function processSecondaryElasticChunks(SecondarySearchIndex $enum, int $total, mixed $query, callable $transformer): int + { + $chunkSize = $this->getChunkSize(); + $batchSize = $this->getBatchSize(); + + $this->optimizeDatabase(); + $this->setGroupConcatMaxLen(); + + $this->info(sprintf( + 'Populating Elasticsearch secondary index %s with %s rows.', + $enum->value, + number_format($total) + )); + + $bar = $this->output->createProgressBar($total); + $bar->setFormat('verbose'); + $bar->start(); + + $processedCount = 0; + $errorCount = 0; + $batchData = []; + + try { + $query->chunk($chunkSize, function ($items) use ($enum, $transformer, $bar, &$processedCount, &$errorCount, $batchSize, &$batchData) { + foreach ($items as $item) { + try { + $batchData[] = $transformer($item); + $processedCount++; + + if (count($batchData) >= $batchSize) { + Search::bulkInsertSecondary($enum, $batchData); + $batchData = []; + } + } catch (Exception $e) { + $errorCount++; + if ($this->output->isVerbose()) { + $this->error('Error processing secondary index row: '.$e->getMessage()); + } + } + $bar->advance(); + } + }); + + if ($batchData !== []) { + Search::bulkInsertSecondary($enum, $batchData); + } + + $bar->finish(); + $this->newLine(); + + return $errorCount > 0 ? Command::FAILURE : Command::SUCCESS; + } catch (Exception $e) { + $bar->finish(); + $this->newLine(); + $this->error('Failed to populate secondary Elasticsearch index: '.$e->getMessage()); + + return Command::FAILURE; + } finally { + $this->restoreDatabase(); + } + } + /** * Process data for ElasticSearch with optimizations */ @@ -765,6 +1147,18 @@ class NntmuxPopulateSearchIndexes extends Command Search::bulkInsertReleases($data); } elseif ($indexName === 'predb_rt') { Search::bulkInsertPredb($data); + } elseif ($indexName === 'music_rt') { + Search::bulkInsertSecondary(SecondarySearchIndex::Music, $data); + } elseif ($indexName === 'books_rt') { + Search::bulkInsertSecondary(SecondarySearchIndex::Books, $data); + } elseif ($indexName === 'games_rt') { + Search::bulkInsertSecondary(SecondarySearchIndex::Games, $data); + } elseif ($indexName === 'console_rt') { + Search::bulkInsertSecondary(SecondarySearchIndex::Console, $data); + } elseif ($indexName === 'steam_rt') { + Search::bulkInsertSecondary(SecondarySearchIndex::Steam, $data); + } elseif ($indexName === 'anime_rt') { + Search::bulkInsertSecondary(SecondarySearchIndex::Anime, $data); } else { throw new Exception("Unknown index: {$indexName}"); } diff --git a/app/Console/Commands/NntmuxResetDb.php b/app/Console/Commands/NntmuxResetDb.php index ffe9362cc..50c9f03fa 100644 --- a/app/Console/Commands/NntmuxResetDb.php +++ b/app/Console/Commands/NntmuxResetDb.php @@ -260,7 +260,10 @@ class NntmuxResetDb extends Command $this->info('All done! ElasticSearch indexes are deleted and recreated.'); } else { - Search::truncateIndex(['releases_rt', 'predb_rt', 'movies_rt', 'tvshows_rt']); + Search::truncateIndex([ + 'releases_rt', 'predb_rt', 'movies_rt', 'tvshows_rt', + 'music_rt', 'books_rt', 'games_rt', 'console_rt', 'steam_rt', 'anime_rt', + ]); } $this->info('Deleting nzbfiles subfolders.'); diff --git a/app/Console/Commands/UpdateReleasesIndexSchema.php b/app/Console/Commands/UpdateReleasesIndexSchema.php index a1575d8cf..483a4d709 100644 --- a/app/Console/Commands/UpdateReleasesIndexSchema.php +++ b/app/Console/Commands/UpdateReleasesIndexSchema.php @@ -209,7 +209,7 @@ class UpdateReleasesIndexSchema extends Command $existingFields[$field] = strtolower($props['Type'] ?? 'unknown'); } - // Media fields that should exist + // Media + filter/sort fields that should exist $mediaFields = [ 'imdbid' => 'int', 'tmdbid' => 'int', @@ -219,6 +219,15 @@ class UpdateReleasesIndexSchema extends Command 'tvrage' => 'int', 'videos_id' => 'int', 'movieinfo_id' => 'int', + 'size' => 'bigint', + 'postdate_ts' => 'bigint', + 'adddate_ts' => 'bigint', + 'totalpart' => 'int', + 'grabs' => 'int', + 'passwordstatus' => 'int', + 'groups_id' => 'int', + 'nzbstatus' => 'int', + 'haspreview' => 'int', ]; $fieldsToAdd = []; diff --git a/app/Enums/SecondarySearchIndex.php b/app/Enums/SecondarySearchIndex.php new file mode 100644 index 000000000..a47dc9825 --- /dev/null +++ b/app/Enums/SecondarySearchIndex.php @@ -0,0 +1,96 @@ +> + */ + public function manticoreColumns(): array + { + return match ($this) { + self::Music => [ + 'title' => ['type' => 'text'], + 'artist' => ['type' => 'text'], + 'year' => ['type' => 'string'], + 'genres_id' => ['type' => 'integer'], + 'cover' => ['type' => 'integer'], + ], + self::Books => [ + 'title' => ['type' => 'text'], + 'author' => ['type' => 'text'], + 'publishdate' => ['type' => 'string'], + 'cover' => ['type' => 'integer'], + ], + self::Games => [ + 'title' => ['type' => 'text'], + 'genres_id' => ['type' => 'integer'], + 'releasedate_ts' => ['type' => 'bigint'], + 'cover' => ['type' => 'integer'], + ], + self::Console => [ + 'title' => ['type' => 'text'], + 'platform' => ['type' => 'string'], + 'genres_id' => ['type' => 'integer'], + 'releasedate_ts' => ['type' => 'bigint'], + 'cover' => ['type' => 'integer'], + ], + self::Steam => [ + 'name' => ['type' => 'text'], + 'appid' => ['type' => 'integer'], + ], + self::Anime => [ + 'title' => ['type' => 'text'], + 'anidbid' => ['type' => 'integer'], + 'anilist_id' => ['type' => 'integer'], + 'mal_id' => ['type' => 'integer'], + 'lang' => ['type' => 'string'], + 'title_type' => ['type' => 'string'], + 'media_type' => ['type' => 'string'], + 'status' => ['type' => 'string'], + ], + }; + } + + /** + * Fields used for full-text MATCH (Manticore) / multi_match (ES). + * + * @return list + */ + public function fulltextFields(): array + { + return match ($this) { + self::Music => ['artist', 'title'], + self::Books => ['author', 'title'], + self::Games => ['title'], + self::Console => ['title', 'platform'], + self::Steam => ['name'], + self::Anime => ['title'], + }; + } + + /** + * Stable integer document id for composite anime title rows (PK is multi-column in MySQL). + */ + public static function animeTitleDocumentId(int $anidbid, string $type, string $lang, string $title): int + { + $payload = $anidbid.'|'.$type.'|'.$lang.'|'.$title; + + return (int) sprintf('%u', crc32($payload)); + } +} diff --git a/app/Facades/Search.php b/app/Facades/Search.php index d9a9729f8..237c197ce 100644 --- a/app/Facades/Search.php +++ b/app/Facades/Search.php @@ -37,6 +37,14 @@ use Illuminate\Support\Facades\Facade; * @method static array searchReleasesByExternalId(array $externalIds, int $limit = 1000) * @method static array searchReleasesByCategory(array $categoryIds, int $limit = 1000) * @method static array searchReleasesWithCategoryFilter(string $searchTerm, array $categoryIds = [], int $limit = 1000) + * @method static array searchReleasesFiltered(array $criteria, int $limit, int $offset = 0) + * @method static void insertSecondary(\App\Enums\SecondarySearchIndex $index, int $id, array $document) + * @method static void updateSecondary(\App\Enums\SecondarySearchIndex $index, int $id) + * @method static void deleteSecondary(\App\Enums\SecondarySearchIndex $index, int $id) + * @method static array bulkInsertSecondary(\App\Enums\SecondarySearchIndex $index, array $documents) + * @method static array searchSecondary(\App\Enums\SecondarySearchIndex $index, string $query, int $limit = 100) + * @method static array searchAnimeTitle(string $query, int $limit = 100) + * @method static array searchMoviesByFields(array $fieldTerms, int $limit = 5000) * * @see SearchService */ diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 58a5c6787..ae9c96dbd 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -81,6 +81,17 @@ class ApiController extends BasePageController case 'movie': $function = 'm'; break; + case 'music': + case 'audio': + $function = 'music'; + break; + case 'b': + case 'book': + $function = 'book'; + break; + case 'anime': + $function = 'anime'; + break; case 'gn': case 'n': case 'nfo': @@ -289,6 +300,77 @@ class ApiController extends BasePageController $this->output($relData, $params, $outputXML, $offset, 'api'); break; + case 'music': + if (! $request->filled('q')) { + return showApiError(200, 'Missing parameter (q)'); + } + $maxAge = $this->maxAge($request); + if (! is_int($maxAge)) { + return $maxAge; + } + $groupName = $this->group($request); + UserRequest::addApiRequest($uid, $request->getRequestUri()); + $relData = $this->releaseSearchService->apiMusicSearch( + (string) $request->input('q'), + $groupName, + $offset, + $this->limit($request), + $maxAge, + $catExclusions, + $this->categoryID($request), + $minSize + ); + $this->output($relData, $params, $outputXML, $offset, 'api'); + break; + + case 'book': + if (! $request->filled('q')) { + return showApiError(200, 'Missing parameter (q)'); + } + $maxAge = $this->maxAge($request); + if (! is_int($maxAge)) { + return $maxAge; + } + $groupName = $this->group($request); + UserRequest::addApiRequest($uid, $request->getRequestUri()); + $relData = $this->releaseSearchService->apiBookSearch( + (string) $request->input('q'), + $groupName, + $offset, + $this->limit($request), + $maxAge, + $catExclusions, + $this->categoryID($request), + $minSize + ); + $this->output($relData, $params, $outputXML, $offset, 'api'); + break; + + case 'anime': + $q = (string) ($request->input('q') ?? ''); + $anidb = $request->has('anidbid') && $request->filled('anidbid') ? (int) $request->input('anidbid') : -1; + $anilist = $request->has('anilistid') && $request->filled('anilistid') ? (int) $request->input('anilistid') : -1; + if ($q === '' && $anidb <= 0 && $anilist <= 0) { + return showApiError(200, 'Missing parameter (specify q, anidbid, or anilistid)'); + } + $maxAge = $this->maxAge($request); + if (! is_int($maxAge)) { + return $maxAge; + } + UserRequest::addApiRequest($uid, $request->getRequestUri()); + $relData = $this->releaseSearchService->animeSearch( + $anidb, + $offset, + $this->limit($request), + $q, + $this->categoryID($request), + $maxAge, + $catExclusions, + $anilist + ); + $this->output($relData, $params, $outputXML, $offset, 'api'); + break; + // Get NZB. case 'g': $this->verifyEmptyParameter($request, 'g'); @@ -472,7 +554,9 @@ class ApiController extends BasePageController 'search' => ['available' => 'yes', 'supportedParams' => 'q'], 'tv-search' => ['available' => 'yes', 'supportedParams' => 'q,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep'], 'movie-search' => ['available' => 'yes', 'supportedParams' => 'q,imdbid, tmdbid, traktid'], - 'audio-search' => ['available' => 'no', 'supportedParams' => ''], + 'audio-search' => ['available' => 'yes', 'supportedParams' => 'q,cat,minsize,maxage,group'], + 'book-search' => ['available' => 'yes', 'supportedParams' => 'q,cat,minsize,maxage,group'], + 'anime-search' => ['available' => 'yes', 'supportedParams' => 'q,anidbid,anilistid,cat,maxage'], ], ]; }); diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 19e3638b1..f3be43093 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -17,11 +17,9 @@ use App\Services\Releases\ReleaseSearchService; use App\Transformers\ApiTransformer; use App\Transformers\CategoryTransformer; use App\Transformers\DetailsTransformer; -use Illuminate\Foundation\Application; use Illuminate\Http\JsonResponse; -use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Routing\Redirector; +use Illuminate\Http\Response; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; @@ -105,7 +103,9 @@ class ApiV2Controller extends BasePageController 'search' => ['available' => 'yes', 'supportedParams' => 'id'], 'tv-search' => ['available' => 'yes', 'supportedParams' => 'id,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep'], 'movie-search' => ['available' => 'yes', 'supportedParams' => 'id, imdbid, tmdbid, traktid'], - 'audio-search' => ['available' => 'no', 'supportedParams' => ''], + 'audio-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxage,group'], + 'book-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxage,group'], + 'anime-search' => ['available' => 'yes', 'supportedParams' => 'id,anidbid,anilistid,cat,maxage'], ], 'categories' => fractal($category, new CategoryTransformer), ]; @@ -179,6 +179,147 @@ class ApiV2Controller extends BasePageController return response()->json($response); } + public function audio(Request $request): JsonResponse|Response + { + $user = $this->resolveUser($request); + if (! $user) { + return response()->json(['error' => 'Missing or invalid API key'], 403); + } + + UserRequest::addApiRequest($user->id, $request->getRequestUri()); + event(new UserAccessedApi($user, $request->ip())); + + $q = (string) $request->input('id', ''); + if ($q === '') { + return response()->json(['error' => 'Missing id (search query)'], 400); + } + + $offset = $this->api->offset($request); + $limit = $this->api->limit($request); + $categoryID = $this->api->categoryID($request); + $maxAge = $this->api->maxAge($request); + if (! is_int($maxAge)) { + return $maxAge; + } + + $minSize = max(0, (int) $request->input('minsize', 0)); + $catExclusions = User::getCategoryExclusionById($user->id); + $groupName = $this->api->group($request); + + $relData = $this->releaseSearchService->apiMusicSearch( + $q, + $groupName, + $offset, + $limit, + $maxAge, + $catExclusions, + $categoryID, + $minSize + ); + + $response = array_merge( + ['Total' => $relData[0]->_totalrows ?? 0], + $this->buildUserStatsResponse($user), + ['Results' => fractal($relData, new ApiTransformer($user))] + ); + + return response()->json($response); + } + + public function books(Request $request): JsonResponse|Response + { + $user = $this->resolveUser($request); + if (! $user) { + return response()->json(['error' => 'Missing or invalid API key'], 403); + } + + UserRequest::addApiRequest($user->id, $request->getRequestUri()); + event(new UserAccessedApi($user, $request->ip())); + + $q = (string) $request->input('id', ''); + if ($q === '') { + return response()->json(['error' => 'Missing id (search query)'], 400); + } + + $offset = $this->api->offset($request); + $limit = $this->api->limit($request); + $categoryID = $this->api->categoryID($request); + $maxAge = $this->api->maxAge($request); + if (! is_int($maxAge)) { + return $maxAge; + } + + $minSize = max(0, (int) $request->input('minsize', 0)); + $catExclusions = User::getCategoryExclusionById($user->id); + $groupName = $this->api->group($request); + + $relData = $this->releaseSearchService->apiBookSearch( + $q, + $groupName, + $offset, + $limit, + $maxAge, + $catExclusions, + $categoryID, + $minSize + ); + + $response = array_merge( + ['Total' => $relData[0]->_totalrows ?? 0], + $this->buildUserStatsResponse($user), + ['Results' => fractal($relData, new ApiTransformer($user))] + ); + + return response()->json($response); + } + + public function anime(Request $request): JsonResponse|Response + { + $user = $this->resolveUser($request); + if (! $user) { + return response()->json(['error' => 'Missing or invalid API key'], 403); + } + + UserRequest::addApiRequest($user->id, $request->getRequestUri()); + event(new UserAccessedApi($user, $request->ip())); + + $q = (string) $request->input('id', ''); + $anidb = (int) $request->input('anidbid', -1); + $anilist = (int) $request->input('anilistid', -1); + if ($q === '' && $anidb <= 0 && $anilist <= 0) { + return response()->json(['error' => 'Specify id (query), anidbid, or anilistid'], 400); + } + + $offset = $this->api->offset($request); + $limit = $this->api->limit($request); + $categoryID = $this->api->categoryID($request); + $maxAge = $this->api->maxAge($request); + if (! is_int($maxAge)) { + return $maxAge; + } + + $catExclusions = User::getCategoryExclusionById($user->id); + + $relData = $this->releaseSearchService->animeSearch( + $anidb, + $offset, + $limit, + $q, + $categoryID, + $maxAge, + $catExclusions, + $anilist + ); + + $response = array_merge( + ['Total' => $relData[0]->_totalrows ?? 0], + $this->buildUserStatsResponse($user), + ['Results' => fractal($relData, new ApiTransformer($user))] + ); + + return response()->json($response); + } + /** * @throws \Exception * @throws \Throwable diff --git a/app/Http/Controllers/Api/RSS.php b/app/Http/Controllers/Api/RSS.php index 6c8faad96..dc37a156f 100644 --- a/app/Http/Controllers/Api/RSS.php +++ b/app/Http/Controllers/Api/RSS.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; +use App\Facades\Search; use App\Models\Category; use App\Models\Release; use App\Services\Releases\ReleaseBrowseService; @@ -12,6 +13,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; /** * Class RSS -- contains specific functions for RSS. @@ -83,6 +85,27 @@ class RSS extends ApiController ); $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); + + if (Search::isAvailable() && $cartSearch === '' && $videosId <= 0 && $aniDbID <= 0 && $airDate <= -1) { + $idxCacheKey = md5('rss_search_'.serialize([$cat, $limit, $offset, Search::getCurrentDriver()])); + $cachedIdx = Cache::get($idxCacheKey); + if ($cachedIdx !== null) { + return $cachedIdx; + } + try { + $fromIndex = $this->fetchRssRowsViaSearchIndex($cat, $limit, $offset); + Cache::put($idxCacheKey, $fromIndex, $expiresAt); + + return $fromIndex; + } catch (\Throwable $e) { + if (config('app.debug')) { + Log::debug('RSS: search index path failed, falling back to SQL', [ + 'message' => $e->getMessage(), + ]); + } + } + } + $result = Cache::get(md5($sql)); if ($result !== null) { return $result; @@ -94,6 +117,80 @@ class RSS extends ApiController return $result; } + /** + * Hydrate RSS rows using pre-filtered release IDs from the search engine. + * + * @param array $cat + * @return Collection + */ + private function fetchRssRowsViaSearchIndex(array $cat, int $limit, int $offset): Collection + { + $categoryIdsRaw = Category::getCategorySearch($cat, null, true); + $categoryIds = null; + if (is_array($categoryIdsRaw)) { + $categoryIds = array_map(static fn ($id): int => (int) $id, $categoryIdsRaw); + } elseif (is_int($categoryIdsRaw) || (is_string($categoryIdsRaw) && ctype_digit((string) $categoryIdsRaw))) { + $categoryIds = [(int) $categoryIdsRaw]; + } + + $criteria = [ + 'phrases' => null, + 'category_ids' => $categoryIds, + 'excluded_category_ids' => [], + 'min_size' => 0, + 'max_age_days' => 0, + 'groups_id' => null, + 'password_allow_rar' => str_contains($this->releaseBrowseService->showPasswords(), '<='), + 'sort_field' => 'postdate_ts', + 'sort_dir' => 'desc', + 'try_fuzzy' => false, + ]; + + $maxMatches = (int) config('search.drivers.manticore.max_matches', 10000); + $pageLimit = $limit === -1 ? min($maxMatches, 5000) : max(1, $limit); + + $filtered = Search::searchReleasesFiltered($criteria, $pageLimit, max(0, $offset)); + if ($filtered['ids'] === []) { + return new Collection; + } + + $ids = array_map(static fn ($id): int => (int) $id, $filtered['ids']); + $idList = implode(',', $ids); + $fieldOrder = implode(',', $ids); + + $sql = sprintf( + "SELECT r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, r.videos_id, r.tv_episodes_id, + m.cover, m.imdbid, m.rating, m.plot, m.year, m.genre, m.director, m.actors, + g.name AS group_name, + CONCAT(cp.title, ' > ', c.title) AS category_name, + COALESCE(cp.id,0) AS parentid, + mu.title AS mu_title, mu.url AS mu_url, mu.artist AS mu_artist, + mu.publisher AS mu_publisher, mu.releasedate AS mu_releasedate, + mu.review AS mu_review, mu.tracks AS mu_tracks, mu.cover AS mu_cover, + mug.title AS mu_genre, co.title AS co_title, co.url AS co_url, + co.publisher AS co_publisher, co.releasedate AS co_releasedate, + co.review AS co_review, co.cover AS co_cover, cog.title AS co_genre, + bo.cover AS bo_cover + FROM releases r + LEFT JOIN categories c ON c.id = r.categories_id + INNER JOIN root_categories cp ON cp.id = c.root_categories_id + LEFT JOIN usenet_groups g ON g.id = r.groups_id + LEFT OUTER JOIN musicinfo mu ON mu.id = r.musicinfo_id + LEFT OUTER JOIN genres mug ON mug.id = mu.genres_id + LEFT OUTER JOIN consoleinfo co ON co.id = r.consoleinfo_id + LEFT JOIN movieinfo m ON m.id = r.movieinfo_id + LEFT OUTER JOIN genres cog ON cog.id = co.genres_id + LEFT OUTER JOIN tv_episodes tve ON tve.id = r.tv_episodes_id + LEFT OUTER JOIN bookinfo bo ON bo.id = r.bookinfo_id + WHERE r.id IN (%s) + ORDER BY FIELD(r.id, %s)", + $idList, + $fieldOrder + ); + + return Release::fromQuery($sql); + } + /** * @param array $excludedCats */ diff --git a/app/Models/BookInfo.php b/app/Models/BookInfo.php index 4d3c4ee6f..2c83f22f4 100644 --- a/app/Models/BookInfo.php +++ b/app/Models/BookInfo.php @@ -7,7 +7,6 @@ namespace App\Models; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; -use Laravel\Scout\Searchable; /** * App\Models\BookInfo. @@ -54,8 +53,6 @@ use Laravel\Scout\Searchable; */ class BookInfo extends Model { - use Searchable; - /** * @var string */ @@ -68,22 +65,6 @@ class BookInfo extends Model */ protected $guarded = []; - public function searchableAs(): string - { - return 'ix_bookinfo_author_title_ft'; - } - - /** - * @return array - */ - public function toSearchableArray(): array - { - return [ - 'author' => $this->author, - 'title' => $this->title, - ]; - } - /** * Get the releases associated with this book. * diff --git a/app/Models/Category.php b/app/Models/Category.php index 3e52e782d..44c3bfa06 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -331,6 +331,11 @@ class Category extends Model $cat = self::MOVIES_GROUP; } + // Anime API/search: default to TV anime category when client sends cat=-1 (avoid unrestricted browse) + if ($searchType === 'anime' && (empty($cat) || $cat === [-1] || $cat === ['-1'])) { + $cat = [self::TV_ANIME]; + } + // If multiple categories were sent in a single array position, slice and add them if (isset($cat[0]) && is_string($cat[0]) && str_contains($cat[0], ',')) { $tmpcats = explode(',', $cat[0]); diff --git a/app/Models/ConsoleInfo.php b/app/Models/ConsoleInfo.php index f4c5a91b2..41f4dab5b 100644 --- a/app/Models/ConsoleInfo.php +++ b/app/Models/ConsoleInfo.php @@ -8,7 +8,6 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Laravel\Scout\Searchable; /** * App\Models\ConsoleInfo. @@ -51,8 +50,6 @@ use Laravel\Scout\Searchable; */ class ConsoleInfo extends Model { - use Searchable; - /** * @var string */ @@ -65,22 +62,6 @@ class ConsoleInfo extends Model */ protected $guarded = []; - public function searchableAs(): string - { - return 'ix_consoleinfo_title_platform_ft'; - } - - /** - * @return array - */ - public function toSearchableArray(): array - { - return [ - 'title' => $this->title, - 'platform' => $this->platform, - ]; - } - // ======================================== // Relationships // ======================================== diff --git a/app/Models/GamesInfo.php b/app/Models/GamesInfo.php index 52d508326..e35e4dd50 100644 --- a/app/Models/GamesInfo.php +++ b/app/Models/GamesInfo.php @@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Laravel\Scout\Searchable; /** * App\Models\GamesInfo. @@ -56,8 +55,6 @@ use Laravel\Scout\Searchable; */ class GamesInfo extends Model { - use Searchable; - /** * @var string */ @@ -100,21 +97,6 @@ class GamesInfo extends Model return $this->hasMany(Release::class, 'gamesinfo_id'); } - public function searchableAs(): string - { - return 'ix_title_ft'; - } - - /** - * @return array - */ - public function toSearchableArray(): array - { - return [ - 'title' => $this->title, - ]; - } - /** * Get the cover image path. */ diff --git a/app/Models/MusicInfo.php b/app/Models/MusicInfo.php index a00847187..f11746aa8 100644 --- a/app/Models/MusicInfo.php +++ b/app/Models/MusicInfo.php @@ -7,7 +7,6 @@ namespace App\Models; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Laravel\Scout\Searchable; /** * App\Models\MusicInfo. @@ -54,8 +53,6 @@ use Laravel\Scout\Searchable; */ class MusicInfo extends Model { - use Searchable; - /** * @var string */ @@ -80,20 +77,4 @@ class MusicInfo extends Model { return $this->belongsTo(Genre::class, 'genres_id'); } - - public function searchableAs(): string - { - return 'ix_musicinfo_artist_title_ft'; - } - - /** - * @return array - */ - public function toSearchableArray(): array - { - return [ - 'artist' => $this->artist, - 'title' => $this->title, - ]; - } } diff --git a/app/Models/Predb.php b/app/Models/Predb.php index 0f5f8b0e8..d0825fd91 100644 --- a/app/Models/Predb.php +++ b/app/Models/Predb.php @@ -8,7 +8,6 @@ use App\Facades\Search; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Cache; -use Laravel\Scout\Searchable; /** * App\Models\Predb. @@ -53,8 +52,6 @@ use Laravel\Scout\Searchable; */ class Predb extends Model { - use Searchable; - // Nuke status. public const PRE_NONUKE = 0; // Pre is not nuked. @@ -229,19 +226,4 @@ class Predb extends Model { return self::query()->where('id', $preID)->first(); } - - public function searchableAs(): string - { - return 'ft_predb_filename'; - } - - /** - * @return array - */ - public function toSearchableArray(): array - { - return [ - 'filename' => $this->filename, - ]; - } } diff --git a/app/Models/SteamApp.php b/app/Models/SteamApp.php index 3dd41a119..28e97d861 100644 --- a/app/Models/SteamApp.php +++ b/app/Models/SteamApp.php @@ -5,15 +5,16 @@ declare(strict_types=1); namespace App\Models; use Illuminate\Database\Eloquent\Model; -use Laravel\Scout\Searchable; /** * App\Models\SteamApp. * + * @property int $id * @property string $name Steam application name * @property int $appid Steam application id * * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\SteamApp whereAppid($value) + * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\SteamApp whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\SteamApp whereName($value) * * @mixin \Eloquent @@ -24,13 +25,6 @@ use Laravel\Scout\Searchable; */ class SteamApp extends Model { - use Searchable; - - /** - * @var bool - */ - public $incrementing = false; - protected $dateFormat = false; /** @@ -42,19 +36,4 @@ class SteamApp extends Model * @var array */ protected $guarded = []; - - public function searchableAs(): string - { - return 'ix_name_ft'; - } - - /** - * @return array - */ - public function toSearchableArray(): array - { - return [ - 'name' => $this->name, - ]; - } } diff --git a/app/Models/Video.php b/app/Models/Video.php index 460a3babd..ff52c6bb6 100644 --- a/app/Models/Video.php +++ b/app/Models/Video.php @@ -4,11 +4,13 @@ declare(strict_types=1); namespace App\Models; +use App\Facades\Search; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; /** @@ -148,6 +150,18 @@ class Video extends Model * @return array */ public static function getSeriesList(mixed $uid, string $letter = '', string $showname = ''): array + { + $cacheKey = 'video_series_list:'.md5(serialize([(string) $uid, $letter, $showname])); + + return Cache::remember($cacheKey, now()->addMinutes((int) config('nntmux.cache_expiry_medium', 60)), function () use ($uid, $letter, $showname): array { + return self::getSeriesListUncached($uid, $letter, $showname); + }); + } + + /** + * @internal Used by {@see getSeriesList} with cache wrapper. + */ + private static function getSeriesListUncached(mixed $uid, string $letter = '', string $showname = ''): array { $params = [ 'uid' => $uid, @@ -168,8 +182,17 @@ class Video extends Model $shownameCondition = ''; if ($showname !== '') { - $shownameCondition = 'AND videos.title LIKE :showname'; - $params['showname'] = '%'.$showname.'%'; + if (Search::isAvailable()) { + $hits = Search::searchTvShows($showname, 800); + $vids = array_values(array_filter(array_map(static fn ($id): int => (int) $id, $hits['id'] ?? []))); + if ($vids === []) { + return []; + } + $shownameCondition = 'AND videos.id IN ('.implode(',', $vids).')'; + } else { + $shownameCondition = 'AND videos.title LIKE :showname'; + $params['showname'] = '%'.$showname.'%'; + } } $sql = " diff --git a/app/Observers/AnidbInfoObserver.php b/app/Observers/AnidbInfoObserver.php new file mode 100644 index 000000000..247c55b7c --- /dev/null +++ b/app/Observers/AnidbInfoObserver.php @@ -0,0 +1,65 @@ +reindexTitles($anidbInfo); + } + + public function updated(AnidbInfo $anidbInfo): void + { + $this->reindexTitles($anidbInfo); + } + + public function deleted(AnidbInfo $anidbInfo): void + { + try { + foreach (AnidbTitle::query()->where('anidbid', $anidbInfo->anidbid)->cursor() as $titleRow) { + $docId = SecondarySearchIndex::animeTitleDocumentId( + (int) $titleRow->anidbid, + (string) $titleRow->type, + (string) $titleRow->lang, + (string) $titleRow->title + ); + Search::deleteSecondary(SecondarySearchIndex::Anime, $docId); + } + } catch (\Throwable $e) { + Log::error('AnidbInfoObserver: delete from search index failed', [ + 'anidbid' => $anidbInfo->anidbid, + 'error' => $e->getMessage(), + ]); + } + } + + private function reindexTitles(AnidbInfo $anidbInfo): void + { + try { + foreach (AnidbTitle::query()->where('anidbid', $anidbInfo->anidbid)->cursor() as $titleRow) { + $this->titleObserver->upsert($titleRow); + } + } catch (\Throwable $e) { + Log::error('AnidbInfoObserver: reindex titles failed', [ + 'anidbid' => $anidbInfo->anidbid, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Observers/AnidbTitleObserver.php b/app/Observers/AnidbTitleObserver.php new file mode 100644 index 000000000..6dbb73ab4 --- /dev/null +++ b/app/Observers/AnidbTitleObserver.php @@ -0,0 +1,67 @@ +upsert($anidbTitle); + } + + public function updated(AnidbTitle $anidbTitle): void + { + $this->upsert($anidbTitle); + } + + public function upsert(AnidbTitle $anidbTitle): void + { + $this->sync($anidbTitle); + } + + public function deleted(AnidbTitle $anidbTitle): void + { + try { + $docId = SecondarySearchIndex::animeTitleDocumentId( + (int) $anidbTitle->anidbid, + (string) $anidbTitle->type, + (string) $anidbTitle->lang, + (string) $anidbTitle->title + ); + Search::deleteSecondary(SecondarySearchIndex::Anime, $docId); + } catch (\Throwable $e) { + Log::error('AnidbTitleObserver: delete from search index failed', [ + 'error' => $e->getMessage(), + ]); + } + } + + private function sync(AnidbTitle $anidbTitle): void + { + try { + $docId = SecondarySearchIndex::animeTitleDocumentId( + (int) $anidbTitle->anidbid, + (string) $anidbTitle->type, + (string) $anidbTitle->lang, + (string) $anidbTitle->title + ); + Search::insertSecondary( + SecondarySearchIndex::Anime, + $docId, + SecondaryIndexDocuments::animeTitle($anidbTitle) + ); + } catch (\Throwable $e) { + Log::error('AnidbTitleObserver: sync to search index failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Observers/BookInfoObserver.php b/app/Observers/BookInfoObserver.php new file mode 100644 index 000000000..2924115b7 --- /dev/null +++ b/app/Observers/BookInfoObserver.php @@ -0,0 +1,52 @@ +sync($bookInfo); + } + + public function updated(BookInfo $bookInfo): void + { + $this->sync($bookInfo); + } + + public function deleted(BookInfo $bookInfo): void + { + try { + Search::deleteSecondary(SecondarySearchIndex::Books, $bookInfo->id); + } catch (\Throwable $e) { + Log::error('BookInfoObserver: delete from search index failed', [ + 'id' => $bookInfo->id, + 'error' => $e->getMessage(), + ]); + } + } + + private function sync(BookInfo $bookInfo): void + { + try { + Search::insertSecondary( + SecondarySearchIndex::Books, + $bookInfo->id, + SecondaryIndexDocuments::book($bookInfo) + ); + } catch (\Throwable $e) { + Log::error('BookInfoObserver: sync to search index failed', [ + 'id' => $bookInfo->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Observers/ConsoleInfoObserver.php b/app/Observers/ConsoleInfoObserver.php new file mode 100644 index 000000000..4b94d80ce --- /dev/null +++ b/app/Observers/ConsoleInfoObserver.php @@ -0,0 +1,52 @@ +sync($consoleInfo); + } + + public function updated(ConsoleInfo $consoleInfo): void + { + $this->sync($consoleInfo); + } + + public function deleted(ConsoleInfo $consoleInfo): void + { + try { + Search::deleteSecondary(SecondarySearchIndex::Console, $consoleInfo->id); + } catch (\Throwable $e) { + Log::error('ConsoleInfoObserver: delete from search index failed', [ + 'id' => $consoleInfo->id, + 'error' => $e->getMessage(), + ]); + } + } + + private function sync(ConsoleInfo $consoleInfo): void + { + try { + Search::insertSecondary( + SecondarySearchIndex::Console, + $consoleInfo->id, + SecondaryIndexDocuments::console($consoleInfo) + ); + } catch (\Throwable $e) { + Log::error('ConsoleInfoObserver: sync to search index failed', [ + 'id' => $consoleInfo->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Observers/GamesInfoObserver.php b/app/Observers/GamesInfoObserver.php new file mode 100644 index 000000000..c0821be6d --- /dev/null +++ b/app/Observers/GamesInfoObserver.php @@ -0,0 +1,52 @@ +sync($gamesInfo); + } + + public function updated(GamesInfo $gamesInfo): void + { + $this->sync($gamesInfo); + } + + public function deleted(GamesInfo $gamesInfo): void + { + try { + Search::deleteSecondary(SecondarySearchIndex::Games, $gamesInfo->id); + } catch (\Throwable $e) { + Log::error('GamesInfoObserver: delete from search index failed', [ + 'id' => $gamesInfo->id, + 'error' => $e->getMessage(), + ]); + } + } + + private function sync(GamesInfo $gamesInfo): void + { + try { + Search::insertSecondary( + SecondarySearchIndex::Games, + $gamesInfo->id, + SecondaryIndexDocuments::games($gamesInfo) + ); + } catch (\Throwable $e) { + Log::error('GamesInfoObserver: sync to search index failed', [ + 'id' => $gamesInfo->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Observers/MusicInfoObserver.php b/app/Observers/MusicInfoObserver.php new file mode 100644 index 000000000..024cd2a13 --- /dev/null +++ b/app/Observers/MusicInfoObserver.php @@ -0,0 +1,52 @@ +sync($musicInfo); + } + + public function updated(MusicInfo $musicInfo): void + { + $this->sync($musicInfo); + } + + public function deleted(MusicInfo $musicInfo): void + { + try { + Search::deleteSecondary(SecondarySearchIndex::Music, $musicInfo->id); + } catch (\Throwable $e) { + Log::error('MusicInfoObserver: delete from search index failed', [ + 'id' => $musicInfo->id, + 'error' => $e->getMessage(), + ]); + } + } + + private function sync(MusicInfo $musicInfo): void + { + try { + Search::insertSecondary( + SecondarySearchIndex::Music, + $musicInfo->id, + SecondaryIndexDocuments::music($musicInfo) + ); + } catch (\Throwable $e) { + Log::error('MusicInfoObserver: sync to search index failed', [ + 'id' => $musicInfo->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Observers/SteamAppObserver.php b/app/Observers/SteamAppObserver.php new file mode 100644 index 000000000..b3dab01e5 --- /dev/null +++ b/app/Observers/SteamAppObserver.php @@ -0,0 +1,57 @@ +sync($steamApp); + } + + public function updated(SteamApp $steamApp): void + { + $this->sync($steamApp); + } + + public function deleted(SteamApp $steamApp): void + { + try { + $id = (int) ($steamApp->getAttribute('id') ?? 0); + if ($id > 0) { + Search::deleteSecondary(SecondarySearchIndex::Steam, $id); + } + } catch (\Throwable $e) { + Log::error('SteamAppObserver: delete from search index failed', [ + 'error' => $e->getMessage(), + ]); + } + } + + private function sync(SteamApp $steamApp): void + { + try { + $id = (int) ($steamApp->getAttribute('id') ?? 0); + if ($id <= 0) { + return; + } + Search::insertSecondary( + SecondarySearchIndex::Steam, + $id, + SecondaryIndexDocuments::steam($steamApp) + ); + } catch (\Throwable $e) { + Log::error('SteamAppObserver: sync to search index failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index cc361eb8e..98937cd73 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,14 +4,28 @@ declare(strict_types=1); namespace App\Providers; +use App\Models\AnidbInfo; +use App\Models\AnidbTitle; +use App\Models\BookInfo; +use App\Models\ConsoleInfo; +use App\Models\GamesInfo; use App\Models\MovieInfo; +use App\Models\MusicInfo; use App\Models\Release; use App\Models\RolePromotion; +use App\Models\SteamApp; use App\Models\User; use App\Models\Video; +use App\Observers\AnidbInfoObserver; +use App\Observers\AnidbTitleObserver; +use App\Observers\BookInfoObserver; +use App\Observers\ConsoleInfoObserver; +use App\Observers\GamesInfoObserver; use App\Observers\MovieInfoObserver; +use App\Observers\MusicInfoObserver; use App\Observers\ReleaseObserver; use App\Observers\RolePromotionObserver; +use App\Observers\SteamAppObserver; use App\Observers\VideoObserver; use App\View\Composers\GlobalDataComposer; use Illuminate\Auth\Events\Login; @@ -51,6 +65,13 @@ class AppServiceProvider extends ServiceProvider MovieInfo::observe(MovieInfoObserver::class); Video::observe(VideoObserver::class); Release::observe(ReleaseObserver::class); + MusicInfo::observe(MusicInfoObserver::class); + BookInfo::observe(BookInfoObserver::class); + GamesInfo::observe(GamesInfoObserver::class); + ConsoleInfo::observe(ConsoleInfoObserver::class); + SteamApp::observe(SteamAppObserver::class); + AnidbTitle::observe(AnidbTitleObserver::class); + AnidbInfo::observe(AnidbInfoObserver::class); } /** diff --git a/app/Services/BookService.php b/app/Services/BookService.php index b58d373e4..fb6af6bb5 100644 --- a/app/Services/BookService.php +++ b/app/Services/BookService.php @@ -4,11 +4,14 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\SecondarySearchIndex; +use App\Facades\Search; use App\Models\BookInfo; use App\Models\Category; use App\Models\Release; use App\Models\Settings; use App\Services\Releases\ReleaseBrowseService; +use App\Support\MetadataSearchLookup; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -90,7 +93,24 @@ class BookService } $searchWords = trim($searchWords); - return BookInfo::search($searchWords)->first(); + if (Search::isAvailable()) { + $q = MetadataSearchLookup::normalizeBooleanSearchWords($searchWords); + if ($q !== '') { + $hits = Search::searchSecondary(SecondarySearchIndex::Books, $q, 25); + foreach ($hits['id'] as $bid) { + $found = BookInfo::query()->where('id', $bid)->first(); + if ($found !== null) { + return $found; + } + } + } + + return null; + } + + return BookInfo::query() + ->whereRaw('MATCH (author, title) AGAINST (? IN BOOLEAN MODE)', [$searchWords]) + ->first(); } /** @@ -104,7 +124,29 @@ class BookService $page = max(1, $page); $start = max(0, $start); - $browseby = $this->getBrowseBy(); + $useIndexForAuthorTitle = Search::isAvailable() + && (! empty($_REQUEST['author']) || ! empty($_REQUEST['title'])); + $bookIdsFromSearch = null; + if ($useIndexForAuthorTitle) { + $q = trim( + stripslashes((string) ($_REQUEST['author'] ?? '')).' ' + .stripslashes((string) ($_REQUEST['title'] ?? '')) + ); + if ($q === '') { + $bookIdsFromSearch = []; + } else { + $bookIdsFromSearch = Search::searchSecondary(SecondarySearchIndex::Books, $q, 5000)['id']; + } + if ($bookIdsFromSearch === []) { + return collect(); + } + } + + $browseby = $this->getBrowseBy($useIndexForAuthorTitle); + $bookInClause = ''; + if (is_array($bookIdsFromSearch) && $bookIdsFromSearch !== []) { + $bookInClause = ' AND boo.id IN ('.implode(',', array_map('intval', $bookIdsFromSearch)).')'; + } $catsrch = ''; if (\count($cat) > 0 && $cat[0] !== -1) { $catsrch = Category::getCategorySearch($cat); @@ -120,6 +162,7 @@ class BookService $baseWhere = "boo.cover = 1 AND boo.title != '' " ."AND r.passwordstatus {$showPasswords} " .$browseby.' ' + .$bookInClause.' ' .$catsrch.' ' .$exccatlist; @@ -272,10 +315,13 @@ class BookService /** * Get browse by SQL clause. */ - public function getBrowseBy(): string + public function getBrowseBy(bool $skipAuthorTitleLike = false): string { $browseby = ' '; foreach ($this->getBrowseByOptions() as $bbk => $bbv) { + if ($skipAuthorTitleLike && ($bbk === 'author' || $bbk === 'title')) { + continue; + } if (! empty($_REQUEST[$bbk])) { $bbs = stripslashes($_REQUEST[$bbk]); $browseby .= ' AND boo.'.$bbv.' '.'LIKE '.escapeString('%'.$bbs.'%'); diff --git a/app/Services/ConsoleService.php b/app/Services/ConsoleService.php index 19cb80557..514f2bf04 100644 --- a/app/Services/ConsoleService.php +++ b/app/Services/ConsoleService.php @@ -4,12 +4,15 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\SecondarySearchIndex; +use App\Facades\Search; use App\Models\Category; use App\Models\ConsoleInfo; use App\Models\Genre; use App\Models\Release; use App\Models\Settings; use App\Services\IGDB\Exceptions\IgdbHttpException; +use App\Support\MetadataSearchLookup; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -99,7 +102,26 @@ class ConsoleService } $searchWords = trim($searchWords.'+'.$platform); - return ConsoleInfo::search($searchWords)->first() ?? false; + if (Search::isAvailable()) { + $q = MetadataSearchLookup::normalizeBooleanSearchWords($searchWords); + if ($q !== '') { + $hits = Search::searchSecondary(SecondarySearchIndex::Console, $q, 25); + foreach ($hits['id'] as $cid) { + $found = ConsoleInfo::query()->where('id', $cid)->first(); + if ($found !== null) { + return $found; + } + } + } + + return false; + } + + $row = ConsoleInfo::query() + ->whereRaw('MATCH (title, platform) AGAINST (? IN BOOLEAN MODE)', [$searchWords]) + ->first(); + + return $row ?? false; } // ======================================== @@ -119,7 +141,29 @@ class ConsoleService $page = max(1, $page); $start = max(0, $start); - $browseBy = $this->getBrowseBy(); + $useIndexForTitlePlatform = Search::isAvailable() + && (! empty($_REQUEST['title']) || ! empty($_REQUEST['platform'])); + $consoleIdsFromSearch = null; + if ($useIndexForTitlePlatform) { + $q = trim( + stripslashes((string) ($_REQUEST['title'] ?? '')).' ' + .stripslashes((string) ($_REQUEST['platform'] ?? '')) + ); + if ($q === '') { + $consoleIdsFromSearch = []; + } else { + $consoleIdsFromSearch = Search::searchSecondary(SecondarySearchIndex::Console, $q, 5000)['id']; + } + if ($consoleIdsFromSearch === []) { + return collect(); + } + } + + $browseBy = $this->getBrowseBy($useIndexForTitlePlatform); + $consoleInClause = ''; + if (is_array($consoleIdsFromSearch) && $consoleIdsFromSearch !== []) { + $consoleInClause = ' AND con.id IN ('.implode(',', array_map('intval', $consoleIdsFromSearch)).')'; + } $catsrch = ''; if (\count($cat) > 0 && (int) $cat[0] !== -1) { $catsrch = Category::getCategorySearch($cat); @@ -135,6 +179,7 @@ class ConsoleService $baseWhere = "con.title != '' AND con.cover = 1 " ."AND r.passwordstatus {$showPasswords} " .$browseBy.' ' + .$consoleInClause.' ' .$catsrch.' ' .$exccatlist; @@ -286,13 +331,20 @@ class ConsoleService /** * Get browse by SQL clause. */ - public function getBrowseBy(): string + public function getBrowseBy(bool $skipTitlePlatformLike = false): string { $browseBy = ' '; foreach ($this->getBrowseByOptions() as $bbk => $bbv) { + if ($skipTitlePlatformLike && ($bbk === 'title' || $bbk === 'platform')) { + continue; + } if (! empty($_REQUEST[$bbk])) { $bbs = stripslashes($_REQUEST[$bbk]); - $browseBy .= ' AND con.'.$bbv.' LIKE '.escapeString('%'.$bbs.'%'); + if (stripos($bbv, 'id') !== false) { + $browseBy .= ' AND con.'.$bbv.' = '.(int) $bbs; + } else { + $browseBy .= ' AND con.'.$bbv.' LIKE '.escapeString('%'.$bbs.'%'); + } } } diff --git a/app/Services/GamesService.php b/app/Services/GamesService.php index a3a9b7c84..068eff8f8 100644 --- a/app/Services/GamesService.php +++ b/app/Services/GamesService.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\SecondarySearchIndex; +use App\Facades\Search; use App\Models\Category; use App\Models\GamesInfo; use App\Models\Genre; @@ -119,18 +121,24 @@ class GamesService return false; } - $results = GamesInfo::search($title)->get(); - - if ($results instanceof \Traversable) { + if (Search::isAvailable()) { + $hits = Search::searchSecondary(SecondarySearchIndex::Games, $title, 80); $bestMatchPct = 0; $normQuery = $this->titleParser->normalizeForMatch($title); - foreach ($results as $result) { - $candidate = is_array($result) ? ($result['title'] ?? '') : ($result->title ?? ''); + foreach ($hits['id'] as $gid) { + $result = GamesInfo::query() + ->where('gamesinfo.id', $gid) + ->leftJoin('genres as g', 'g.id', '=', 'gamesinfo.genres_id') + ->select(['gamesinfo.*', 'g.title as genres']) + ->first(); + if ($result === null) { + continue; + } + $candidate = (string) $result->title; if ($candidate === '') { continue; } - // Exact match fast-path if ($candidate === $title) { $bestMatch = $result; break; @@ -141,6 +149,31 @@ class GamesService $bestMatchPct = $score; } } + + return $bestMatch; + } + + $results = GamesInfo::query() + ->whereRaw('MATCH (title) AGAINST (? IN NATURAL LANGUAGE MODE)', [$title]) + ->get(); + + $bestMatchPct = 0; + $normQuery = $this->titleParser->normalizeForMatch($title); + + foreach ($results as $result) { + $candidate = (string) $result->title; + if ($candidate === '') { + continue; + } + if ($candidate === $title) { + $bestMatch = $result; + break; + } + $score = $this->titleParser->computeSimilarity($normQuery, $this->titleParser->normalizeForMatch($candidate)); + if ($score >= self::GAME_MATCH_PERCENTAGE && $score > $bestMatchPct) { + $bestMatch = $result; + $bestMatchPct = $score; + } } return $bestMatch; @@ -157,7 +190,15 @@ class GamesService ->join('genres as g', 'gi.genres_id', '=', 'g.id'); if (! empty($search)) { - $query->where('gi.title', 'like', '%'.$search.'%'); + if (Search::isAvailable()) { + $ids = Search::searchSecondary(SecondarySearchIndex::Games, $search, 3000)['id']; + if ($ids === []) { + return $query->whereRaw('1 = 0')->paginate(config('nntmux.items_per_page')); + } + $query->whereIn('gi.id', $ids); + } else { + $query->where('gi.title', 'like', '%'.$search.'%'); + } } return $query->orderByDesc('created_at') diff --git a/app/Services/MovieBrowseService.php b/app/Services/MovieBrowseService.php index f0a665bed..e2c5e0d91 100644 --- a/app/Services/MovieBrowseService.php +++ b/app/Services/MovieBrowseService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services; +use App\Facades\Search; use App\Models\Category; use App\Models\MovieInfo; use App\Services\Releases\ReleaseBrowseService; @@ -61,11 +62,40 @@ class MovieBrowseService $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); $whereAge = $maxAge > 0 ? 'AND r.postdate > NOW() - INTERVAL '.$maxAge.' DAY ' : ''; - $browseBy = $this->getBrowseBy(); + + $skipBrowseLikeFields = []; + $indexImdbClause = ''; + if (Search::isAvailable()) { + $fieldTerms = []; + foreach (['title', 'director', 'actors', 'genre'] as $field) { + if (request()->has($field)) { + $value = request()->input($field); + if (is_array($value)) { + continue; + } + $value = stripslashes((string) $value); + if ($value !== '') { + $fieldTerms[$field] = $value; + } + } + } + if ($fieldTerms !== []) { + $found = Search::searchMoviesByFields($fieldTerms, 8000); + if ($found['imdbids'] === []) { + return collect(); + } + $quoted = array_map(static fn (string $id): string => escapeString($id), $found['imdbids']); + $indexImdbClause = ' AND m.imdbid IN ('.implode(',', $quoted).') '; + $skipBrowseLikeFields = ['title', 'director', 'actors', 'genre']; + } + } + + $browseBy = $this->getBrowseBy($skipBrowseLikeFields); $baseWhere = "m.title != '' AND m.imdbid IS NOT NULL AND m.imdbid != '' " ."AND r.passwordstatus {$this->showPasswords} " .$browseBy.' ' + .$indexImdbClause .$catFilter .$whereAge .$whereExcluded; @@ -229,11 +259,17 @@ class MovieBrowseService return ['title_asc', 'title_desc', 'year_asc', 'year_desc', 'rating_asc', 'rating_desc']; } - protected function getBrowseBy(): string + /** + * @param array $skipFields Request keys to ignore (e.g. already applied via search index). + */ + protected function getBrowseBy(array $skipFields = []): string { $browseBy = ' '; $browseByArr = ['title', 'director', 'actors', 'genre', 'rating', 'year', 'imdb']; foreach ($browseByArr as $bb) { + if ($skipFields !== [] && in_array($bb, $skipFields, true)) { + continue; + } if (request()->has($bb) && ! empty(request()->input($bb))) { $bbv = request()->input($bb); if (is_array($bbv)) { diff --git a/app/Services/MusicService.php b/app/Services/MusicService.php index 1798b01b0..3db3f4b86 100644 --- a/app/Services/MusicService.php +++ b/app/Services/MusicService.php @@ -4,12 +4,15 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\SecondarySearchIndex; +use App\Facades\Search; use App\Models\Category; use App\Models\Genre; use App\Models\MusicInfo; use App\Models\Release; use App\Models\Settings; use App\Services\Releases\ReleaseBrowseService; +use App\Support\MetadataSearchLookup; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -86,7 +89,24 @@ class MusicService } $searchwords = trim($searchwords); - return MusicInfo::search($searchwords)->first(); + if (Search::isAvailable()) { + $q = MetadataSearchLookup::normalizeBooleanSearchWords($searchwords); + if ($q !== '') { + $hits = Search::searchSecondary(SecondarySearchIndex::Music, $q, 25); + foreach ($hits['id'] as $mid) { + $found = MusicInfo::query()->with('genre')->where('id', $mid)->first(); + if ($found !== null) { + return $found; + } + } + } + + return null; + } + + return MusicInfo::query() + ->whereRaw('MATCH (artist, title) AGAINST (? IN BOOLEAN MODE)', [$searchwords]) + ->first(); } /** @@ -100,7 +120,29 @@ class MusicService $page = max(1, $page); $start = max(0, $start); - $browseby = $this->getBrowseBy(); + $useIndexForArtistTitle = Search::isAvailable() + && (! empty($_REQUEST['artist']) || ! empty($_REQUEST['title'])); + $musicIdsFromSearch = null; + if ($useIndexForArtistTitle) { + $q = trim( + stripslashes((string) ($_REQUEST['artist'] ?? '')).' ' + .stripslashes((string) ($_REQUEST['title'] ?? '')) + ); + if ($q === '') { + $musicIdsFromSearch = []; + } else { + $musicIdsFromSearch = Search::searchSecondary(SecondarySearchIndex::Music, $q, 5000)['id']; + } + if ($musicIdsFromSearch === []) { + return collect(); + } + } + + $browseby = $this->getBrowseBy($useIndexForArtistTitle); + $musicInClause = ''; + if (is_array($musicIdsFromSearch) && $musicIdsFromSearch !== []) { + $musicInClause = ' AND m.id IN ('.implode(',', array_map('intval', $musicIdsFromSearch)).')'; + } $catsrch = ''; if (\count($cat) > 0 && (int) $cat[0] !== -1) { $catsrch = Category::getCategorySearch($cat); @@ -118,6 +160,7 @@ class MusicService $baseWhere = "m.title != '' AND m.cover = 1 " ."AND r.passwordstatus {$showPasswords} " .$browseby.' ' + .$musicInClause.' ' .$catsrch.' ' .$exccatlist; @@ -263,10 +306,13 @@ class MusicService /** * Build browse by SQL clause. */ - public function getBrowseBy(): string + public function getBrowseBy(bool $skipArtistTitleLike = false): string { $browseby = ' '; foreach ($this->getBrowseByOptions() as $bbk => $bbv) { + if ($skipArtistTitleLike && ($bbk === 'artist' || $bbk === 'title')) { + continue; + } if (! empty($_REQUEST[$bbk])) { $bbs = stripslashes($_REQUEST[$bbk]); if (stripos($bbv, 'id') !== false) { diff --git a/app/Services/Releases/ReleaseBrowseService.php b/app/Services/Releases/ReleaseBrowseService.php index f885689c8..02ceb3d40 100644 --- a/app/Services/Releases/ReleaseBrowseService.php +++ b/app/Services/Releases/ReleaseBrowseService.php @@ -8,6 +8,7 @@ use App\Facades\Search; use App\Models\Category; use App\Models\Release; use App\Models\Settings; +use App\Models\UsenetGroup; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -70,23 +71,77 @@ class ReleaseBrowseService $orderBy = $this->getBrowseOrder($orderBy); + if ($searchTerm === null && $purpose === 'api' && Search::isAvailable()) { + $indexSort = $this->browseOrderToIndexSortField($orderBy[0]); // @phpstan-ignore offsetAccess.notFound + if ($indexSort !== null) { + return $this->executeApiBrowseViaSearchIndex( + $cat, + $start, + $num, + $orderBy, + $maxAge, + $excludedCats, + $groupName, + $minSize, + $indexSort, + $page + ); + } + } + // Use search index filtering when a search term is provided $searchIndexFilter = ''; $searchIndexIds = []; + $searchIndexTotal = null; if (! empty($searchTerm) && Search::isAvailable()) { - $searchResult = Search::searchReleasesWithFuzzy(['searchname' => $searchTerm], $num * 10); - $searchIndexIds = $searchResult['ids'] ?? []; + $indexSort = $this->browseOrderToIndexSortField($orderBy[0]); // @phpstan-ignore offsetAccess.notFound + if ($indexSort !== null) { + $categoryIdsRaw = Category::getCategorySearch(\is_array($cat) ? $cat : [], null, true); + $categoryIds = null; + if (is_array($categoryIdsRaw)) { + $categoryIds = array_map(static fn ($id): int => (int) $id, $categoryIdsRaw); + } elseif (is_int($categoryIdsRaw) || (is_string($categoryIdsRaw) && ctype_digit((string) $categoryIdsRaw))) { + $categoryIds = [(int) $categoryIdsRaw]; + } + $groupId = null; + if ((int) $groupName !== -1) { + $resolved = UsenetGroup::getIDByName((string) $groupName); + if ($resolved) { + $groupId = (int) $resolved; + } + } + $filtered = Search::searchReleasesFiltered([ + 'phrases' => ['searchname' => $searchTerm], + 'category_ids' => $categoryIds, + 'excluded_category_ids' => $excludedCats, + 'min_size' => $minSize, + 'max_age_days' => $maxAge, + 'groups_id' => $groupId, + 'password_allow_rar' => str_contains($this->showPasswords(), '<='), + 'sort_field' => $indexSort, + 'sort_dir' => $orderBy[1] ?? 'desc', // @phpstan-ignore offsetAccess.notFound + 'try_fuzzy' => true, + ], (int) $num, (int) $start); + $searchIndexIds = $filtered['ids']; + $searchIndexTotal = $filtered['total']; + if ($searchIndexIds === []) { + return []; + } + $searchIndexFilter = sprintf(' AND r.id IN (%s)', implode(',', array_map(static fn ($id): int => (int) $id, $searchIndexIds))); + } else { + $searchResult = Search::searchReleasesWithFuzzy(['searchname' => $searchTerm], $num * 10); + $searchIndexIds = $searchResult['ids'] ?? []; - if (config('app.debug') && ($searchResult['fuzzy'] ?? false)) { - Log::debug('getBrowseRange: Using fuzzy search results for browse filtering'); + if (config('app.debug') && ($searchResult['fuzzy'] ?? false)) { + Log::debug('getBrowseRange: Using fuzzy search results for browse filtering'); + } + + if ($searchIndexIds === []) { + return []; + } + + $searchIndexFilter = sprintf(' AND r.id IN (%s)', implode(',', array_map(static fn ($id): int => (int) $id, $searchIndexIds))); } - - if (empty($searchIndexIds)) { - // No results from search index, return empty result - return []; - } - - $searchIndexFilter = sprintf(' AND r.id IN (%s)', implode(',', array_map('intval', $searchIndexIds))); } // Build SELECT and JOINs based on purpose @@ -160,7 +215,7 @@ class ReleaseBrowseService if (\count($sql) > 0) { // When using search index, use the ID count for total rows if (! empty($searchIndexIds)) { - $sql[0]->_totalcount = $sql[0]->_totalrows = count($searchIndexIds); + $sql[0]->_totalcount = $sql[0]->_totalrows = $searchIndexTotal ?? count($searchIndexIds); } else { $possibleRows = $this->getBrowseCount($cat, $maxAge, $excludedCats, $groupName); $sql[0]->_totalcount = $sql[0]->_totalrows = $possibleRows; @@ -250,6 +305,123 @@ class ReleaseBrowseService } } + /** + * Map SQL browse order column to a numeric field stored in the search index. + */ + private function browseOrderToIndexSortField(string $field): ?string + { + return match ($field) { + 'postdate' => 'postdate_ts', + 'adddate' => 'adddate_ts', + 'size' => 'size', + 'totalpart' => 'totalpart', + 'grabs' => 'grabs', + 'categories_id' => 'categories_id', + default => null, + }; + } + + /** + * API browse without text: filter and sort via search engine, hydrate rows from SQL. + * + * @param array $cat + * @param array $excludedCats + * @param array{0: string, 1: string} $orderBy + */ + private function executeApiBrowseViaSearchIndex( + mixed $cat, + mixed $start, + mixed $num, + array $orderBy, + int $maxAge, + array $excludedCats, + int|string $groupName, + int $minSize, + string $indexSortField, + mixed $page + ): mixed { + $cacheVersion = $this->getCacheVersion(); + + $categoryIdsRaw = Category::getCategorySearch(is_array($cat) ? $cat : [], null, true); + $categoryIds = null; + if (is_array($categoryIdsRaw)) { + $categoryIds = array_map(static fn ($id): int => (int) $id, $categoryIdsRaw); + } elseif (is_int($categoryIdsRaw) || (is_string($categoryIdsRaw) && ctype_digit((string) $categoryIdsRaw))) { + $categoryIds = [(int) $categoryIdsRaw]; + } + + $groupId = null; + if ((int) $groupName !== -1) { + $resolved = UsenetGroup::getIDByName((string) $groupName); + if ($resolved) { + $groupId = (int) $resolved; + } + } + + $criteria = [ + 'phrases' => null, + 'category_ids' => $categoryIds, + 'excluded_category_ids' => $excludedCats, + 'min_size' => $minSize, + 'max_age_days' => $maxAge, + 'groups_id' => $groupId, + 'password_allow_rar' => str_contains($this->showPasswords(), '<='), + 'sort_field' => $indexSortField, + 'sort_dir' => $orderBy[1] ?? 'desc', + 'try_fuzzy' => false, + ]; + + $filtered = Search::searchReleasesFiltered($criteria, (int) $num, (int) $start); + if ($filtered['ids'] === []) { + return []; + } + + $ids = array_map(static fn (int|string $id): int => (int) $id, $filtered['ids']); + $idList = implode(',', $ids); + $fieldOrder = implode(',', $ids); + + $sql = sprintf( + "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, r.videos_id, r.haspreview, r.nfostatus, r.group_name, + CONCAT(cp.title, ' > ', c.title) AS category_name, + rn.releases_id AS nfoid, + v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb, + m.imdbid, m.tmdbid, m.traktid, + tve.title, tve.series, tve.episode, tve.firstaired + FROM ( + SELECT r.id, r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, r.videos_id, r.tv_episodes_id, r.haspreview, r.nfostatus, g.name AS group_name, r.movieinfo_id + FROM releases r + LEFT JOIN usenet_groups g ON g.id = r.groups_id + WHERE r.id IN (%s) + ) r + LEFT JOIN categories c ON c.id = r.categories_id + LEFT JOIN root_categories cp ON cp.id = c.root_categories_id + LEFT OUTER JOIN videos v ON r.videos_id = v.id + LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id + LEFT OUTER JOIN movieinfo m ON m.id = r.movieinfo_id + LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id + GROUP BY r.id + ORDER BY FIELD(r.id, %s)", + $idList, + $fieldOrder + ); + + $cacheKey = md5($cacheVersion.$sql.$page); + $releases = Cache::get($cacheKey); + if ($releases !== null) { + return $releases; + } + + $sqlResult = DB::select($sql); + if (count($sqlResult) > 0) { + $sqlResult[0]->_totalcount = $sqlResult[0]->_totalrows = $filtered['total']; + } + + $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); + Cache::put($cacheKey, $sqlResult, $expiresAt); + + return $sqlResult; + } + /** * Get the passworded releases clause. */ diff --git a/app/Services/Releases/ReleaseSearchService.php b/app/Services/Releases/ReleaseSearchService.php index c56d61ac2..c1d892409 100644 --- a/app/Services/Releases/ReleaseSearchService.php +++ b/app/Services/Releases/ReleaseSearchService.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Services\Releases; +use App\Enums\SecondarySearchIndex; use App\Facades\Search; +use App\Models\AnidbInfo; use App\Models\Category; use App\Models\Release; use App\Models\Settings; @@ -153,12 +155,108 @@ class ReleaseSearchService ]); } + $hasText = $searchName !== -1 && $searchName !== '' && $searchName !== null; + + if (Search::isAvailable()) { + $groupId = null; + if ((int) $groupName !== -1) { + $resolved = UsenetGroup::getIDByName((string) $groupName); + if ($resolved) { + $groupId = (int) $resolved; + } + } + + $categoryIdsRaw = Category::getCategorySearch($cat, null, true); + $categoryIds = null; + if (is_array($categoryIdsRaw)) { + $categoryIds = array_map(static fn ($id): int => (int) $id, $categoryIdsRaw); + } elseif (is_int($categoryIdsRaw) || (is_string($categoryIdsRaw) && ctype_digit((string) $categoryIdsRaw))) { + $categoryIds = [(int) $categoryIdsRaw]; + } + + $criteria = [ + 'phrases' => $hasText ? $searchName : null, + 'category_ids' => $categoryIds, + 'excluded_category_ids' => $excludedCats, + 'min_size' => $minSize, + 'max_age_days' => $maxAge, + 'groups_id' => $groupId, + 'password_allow_rar' => str_contains($this->showPasswords(), '<='), + 'sort_field' => 'postdate_ts', + 'sort_dir' => 'desc', + 'try_fuzzy' => true, + ]; + + $filtered = Search::searchReleasesFiltered($criteria, $limit, $offset); + + if ($filtered['ids'] === [] && $hasText && config('nntmux.mysql_search_fallback', false) === true) { + return $this->apiSearchLegacyMysql($searchName, $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize); + } + + if ($filtered['ids'] === []) { + return collect(); + } + + $ids = array_map(static fn (int|string $id): int => (int) $id, $filtered['ids']); + $idList = implode(',', $ids); + $fieldOrder = implode(',', $ids); + + $whereSql = 'WHERE r.id IN ('.$idList.')'; + + $sql = sprintf( + "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, + cp.title AS parent_category, c.title AS sub_category, + CONCAT(cp.title, ' > ', c.title) AS category_name, + g.name AS group_name, + m.imdbid, m.tmdbid, m.traktid, + v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb, + tve.firstaired, tve.title, tve.series, tve.episode + FROM releases r + INNER JOIN categories c ON c.id = r.categories_id + INNER JOIN root_categories cp ON cp.id = c.root_categories_id + LEFT JOIN usenet_groups g ON g.id = r.groups_id + LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0 + LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0 + LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0 + %s + ORDER BY FIELD(r.id, %s)", + $whereSql, + $fieldOrder + ); + + $cacheKey = md5($this->getCacheVersion().$sql); + $cachedReleases = Cache::get($cacheKey); + if ($cachedReleases !== null) { + return $cachedReleases; + } + + $releases = Release::fromQuery($sql); + + if ($releases->isNotEmpty()) { + $releases[0]->_totalrows = $filtered['total']; + } + + $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); + Cache::put($cacheKey, $releases, $expiresAt); + + return $releases; + } + + return $this->apiSearchLegacyMysql($searchName, $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize); + } + + /** + * Original API search path (SQL filters + optional index ID list from fuzzy only). + * + * @param array $cat + */ + private function apiSearchLegacyMysql(mixed $searchName, mixed $groupName, int $offset, int $limit, int $maxAge, array $excludedCats, array $cat, int $minSize): mixed + { $searchLimit = $this->determineSearchCandidateLimit($offset, $limit); - // Early return if searching with no results $searchResult = []; - if ($searchName !== -1 && $searchName !== '' && $searchName !== null) { - // Use the unified Search facade with fuzzy fallback + $hasText = $searchName !== -1 && $searchName !== '' && $searchName !== null; + if ($hasText) { $fuzzyResult = Search::searchReleasesWithFuzzy($searchName, $searchLimit); $searchResult = $fuzzyResult['ids'] ?? []; @@ -166,15 +264,14 @@ class ReleaseSearchService Log::debug('apiSearch: Using fuzzy search results'); } - // Fall back to MySQL if search engine returned no results (only if enabled) - if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) { + if ($searchResult === [] && config('nntmux.mysql_search_fallback', false) === true) { if (config('app.debug')) { Log::debug('apiSearch: Falling back to MySQL search'); } $searchResult = $this->performMySQLSearch(['searchname' => $searchName], $searchLimit); } - if (empty($searchResult)) { + if ($searchResult === []) { if (config('app.debug')) { Log::debug('apiSearch: No results from any search engine'); } @@ -199,17 +296,17 @@ class ReleaseSearchService } $catQuery = Category::getCategorySearch($cat); - $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery)); + $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim((string) $catQuery)); if (! empty($catQuery) && $catQuery !== '1=1') { $conditions[] = $catQuery; } - if (! empty($excludedCats)) { - $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCats))); + if ($excludedCats !== []) { + $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map(static fn ($id): int => (int) $id, $excludedCats))); } - if (! empty($searchResult)) { - $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))); + if ($searchResult !== []) { + $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map(static fn ($id): int => (int) $id, $searchResult))); } if ($minSize > 0) { @@ -218,7 +315,6 @@ class ReleaseSearchService $whereSql = 'WHERE '.implode(' AND ', $conditions); - // Optimized query: remove unused columns/joins (haspreview, jpgstatus, *_id columns, nfo/video_data/failures) $sql = sprintf( "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, cp.title AS parent_category, c.title AS sub_category, @@ -262,6 +358,157 @@ class ReleaseSearchService return $releases; } + /** + * API: music releases linked to rows from the music metadata index. + * + * @param array $cat + * @param array $excludedCats + */ + public function apiMusicSearch( + string $q, + mixed $groupName, + int $offset, + int $limit, + int $maxAge, + array $excludedCats, + array $cat, + int $minSize + ): mixed { + $q = trim($q); + if ($q === '' || ! Search::isAvailable()) { + return collect(); + } + + $musicInfoIds = Search::searchSecondary(SecondarySearchIndex::Music, $q, 2000)['id']; + + return $this->apiSearchByMetadataForeignKey($musicInfoIds, 'musicinfo_id', $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize); + } + + /** + * API: book releases linked to rows from the books metadata index. + * + * @param array $cat + * @param array $excludedCats + */ + public function apiBookSearch( + string $q, + mixed $groupName, + int $offset, + int $limit, + int $maxAge, + array $excludedCats, + array $cat, + int $minSize + ): mixed { + $q = trim($q); + if ($q === '' || ! Search::isAvailable()) { + return collect(); + } + + $bookIds = Search::searchSecondary(SecondarySearchIndex::Books, $q, 2000)['id']; + + return $this->apiSearchByMetadataForeignKey($bookIds, 'bookinfo_id', $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize); + } + + /** + * @param list $metadataIds + * @param array $cat + * @param array $excludedCats + */ + private function apiSearchByMetadataForeignKey( + array $metadataIds, + string $column, + mixed $groupName, + int $offset, + int $limit, + int $maxAge, + array $excludedCats, + array $cat, + int $minSize + ): mixed { + if ($metadataIds === []) { + return collect(); + } + + if (! in_array($column, ['musicinfo_id', 'bookinfo_id'], true)) { + return collect(); + } + + $conditions = [ + sprintf('r.passwordstatus %s', $this->showPasswords()), + sprintf('r.%s IN (%s)', $column, implode(',', array_map(static fn (int $id): int => $id, $metadataIds))), + ]; + + if ($maxAge > 0) { + $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge); + } + + if ((int) $groupName !== -1) { + $groupId = UsenetGroup::getIDByName($groupName); + if ($groupId) { + $conditions[] = sprintf('r.groups_id = %d', $groupId); + } + } + + $catQuery = Category::getCategorySearch($cat); + $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim((string) $catQuery)); + if (! empty($catQuery) && $catQuery !== '1=1') { + $conditions[] = $catQuery; + } + + if ($excludedCats !== []) { + $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map(static fn ($id): int => (int) $id, $excludedCats))); + } + + if ($minSize > 0) { + $conditions[] = sprintf('r.size >= %d', $minSize); + } + + $whereSql = 'WHERE '.implode(' AND ', $conditions); + + $sql = sprintf( + "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, + cp.title AS parent_category, c.title AS sub_category, + CONCAT(cp.title, ' > ', c.title) AS category_name, + g.name AS group_name, + m.imdbid, m.tmdbid, m.traktid, + v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb, + tve.firstaired, tve.title, tve.series, tve.episode + FROM releases r + INNER JOIN categories c ON c.id = r.categories_id + INNER JOIN root_categories cp ON cp.id = c.root_categories_id + LEFT JOIN usenet_groups g ON g.id = r.groups_id + LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0 + LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0 + LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0 + %s + ORDER BY r.postdate DESC + LIMIT %d OFFSET %d", + $whereSql, + $limit, + $offset + ); + + $cacheKey = md5($this->getCacheVersion().$sql); + $cachedReleases = Cache::get($cacheKey); + if ($cachedReleases !== null) { + return $cachedReleases; + } + + $releases = Release::fromQuery($sql); + + if ($releases->isNotEmpty()) { + $countSql = sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql); + $countResult = Release::fromQuery($countSql); + $releases[0]->_totalrows = $countResult[0]->count ?? 0; + } + + $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); + Cache::put($cacheKey, $releases, $expiresAt); + + return $releases; + } + /** * Search for TV shows via API. * @@ -386,6 +633,19 @@ class ReleaseSearchService // If search index found releases via external IDs, add them to conditions $hasSearchResultFromExternalIds = ! empty($searchResult); if ($hasSearchResultFromExternalIds) { + if (Search::isAvailable()) { + $searchResult = $this->intersectReleaseIdsWithSearchFilters( + $searchResult, + $cat, + 'tv', + $maxAge, + $minSize, + $excludedCategories + ); + if ($searchResult === []) { + return collect(); + } + } $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))); } @@ -427,6 +687,20 @@ class ReleaseSearchService return collect(); } + if (Search::isAvailable()) { + $searchResult = $this->intersectReleaseIdsWithSearchFilters( + $searchResult, + $cat, + 'tv', + $maxAge, + $minSize, + $excludedCategories + ); + if ($searchResult === []) { + return collect(); + } + } + $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))); // Try to add episode conditions if season/episode data is provided and no valid site IDs @@ -467,20 +741,24 @@ class ReleaseSearchService } } + $applySqlCategorySizeExcluded = $needsDatabaseLookup || empty($searchResult) || ! Search::isAvailable(); + $catQuery = Category::getCategorySearch($cat, 'tv'); $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery)); - if (! empty($catQuery) && $catQuery !== '1=1') { - $conditions[] = $catQuery; - } + if ($applySqlCategorySizeExcluded) { + if (! empty($catQuery) && $catQuery !== '1=1') { + $conditions[] = $catQuery; + } - if ($maxAge > 0) { - $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge); - } - if ($minSize > 0) { - $conditions[] = sprintf('r.size >= %d', $minSize); - } - if (! empty($excludedCategories)) { - $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories))); + if ($maxAge > 0) { + $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge); + } + if ($minSize > 0) { + $conditions[] = sprintf('r.size >= %d', $minSize); + } + if (! empty($excludedCategories)) { + $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories))); + } } $whereSql = 'WHERE '.implode(' AND ', $conditions); @@ -648,15 +926,33 @@ class ReleaseSearchService return collect(); } } + + $skipSqlReleaseFilters = false; + if ($searchResult !== [] && Search::isAvailable()) { + $searchResult = $this->intersectReleaseIdsWithSearchFilters( + $searchResult, + $cat, + 'tv', + $maxAge, + $minSize, + $excludedCategories + ); + $skipSqlReleaseFilters = true; + if ($searchResult === []) { + return collect(); + } + } + + $catSearchSql = $skipSqlReleaseFilters ? '' : Category::getCategorySearch($cat, 'tv'); $whereSql = sprintf( 'WHERE r.passwordstatus %s %s %s %s %s %s %s', $this->showPasswords(), $showSql, - (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''), - Category::getCategorySearch($cat, 'tv'), - ($maxAge > 0 ? sprintf('AND r.postdate > NOW() - INTERVAL %d DAY', $maxAge) : ''), - ($minSize > 0 ? sprintf('AND r.size >= %d', $minSize) : ''), - ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : '' + (! empty($searchResult) ? 'AND r.id IN ('.implode(',', array_map('intval', $searchResult)).')' : ''), + $catSearchSql, + ($skipSqlReleaseFilters || $maxAge <= 0 ? '' : sprintf('AND r.postdate > NOW() - INTERVAL %d DAY', $maxAge)), + ($skipSqlReleaseFilters || $minSize <= 0 ? '' : sprintf('AND r.size >= %d', $minSize)), + ($skipSqlReleaseFilters || empty($excludedCategories) ? '' : sprintf('AND r.categories_id NOT IN(%s)', implode(',', array_map('intval', $excludedCategories)))) ); $baseSql = sprintf( "SELECT r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, @@ -696,33 +992,51 @@ class ReleaseSearchService * @param array $excludedCategories * @return Collection|mixed */ - public function animeSearch(mixed $aniDbID, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, array $excludedCategories = []): mixed + public function animeSearch(mixed $aniDbID, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, array $excludedCategories = [], int $anilistId = -1): mixed { + if ($anilistId > 0) { + $resolved = AnidbInfo::query()->where('anilist_id', $anilistId)->value('anidbid'); + if ($resolved !== null) { + $aniDbID = (int) $resolved; + } elseif ($name === '' && (int) $aniDbID <= -1) { + // AniList id was the only selector but nothing in DB maps to it — do not fall through unfiltered. + return collect(); + } + } + $searchLimit = $this->determineSearchCandidateLimit($offset, $limit); $searchResult = []; - if (! empty($name)) { - // Use the unified Search facade with fuzzy fallback - $fuzzyResult = Search::searchReleasesWithFuzzy($name, $searchLimit); - $searchResult = $fuzzyResult['ids'] ?? []; + $anidbIdFilter = ''; + if ($name !== '') { + if (Search::isAvailable()) { + $anidbIds = Search::searchAnimeTitle($name, $searchLimit); + if ($anidbIds === []) { + return collect(); + } + $anidbIdFilter = 'AND r.anidbid IN ('.implode(',', array_map('intval', $anidbIds)).')'; + } else { + $fuzzyResult = Search::searchReleasesWithFuzzy($name, $searchLimit); + $searchResult = $fuzzyResult['ids'] ?? []; - // 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], $searchLimit); - } + if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) { + $searchResult = $this->performMySQLSearch(['searchname' => $name], $searchLimit); + } - if (count($searchResult) === 0) { - return collect(); + if (count($searchResult) === 0) { + return collect(); + } } } $whereSql = sprintf( 'WHERE r.passwordstatus %s - %s %s %s %s %s', + %s %s %s %s %s %s', $this->showPasswords(), ($aniDbID > -1 ? sprintf(' AND r.anidbid = %d ', $aniDbID) : ''), + $anidbIdFilter, (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''), ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : '', - Category::getCategorySearch($cat), + Category::getCategorySearch($cat, 'anime'), ($maxAge > 0 ? sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge) : '') ); $baseSql = sprintf( @@ -814,6 +1128,20 @@ class ReleaseSearchService } } + if (! empty($searchResult) && Search::isAvailable()) { + $searchResult = $this->intersectReleaseIdsWithSearchFilters( + $searchResult, + $cat, + 'movies', + $maxAge, + $minSize, + $excludedCategories + ); + if ($searchResult === []) { + return collect(); + } + } + // Build the base conditions for movie search // Note: we don't have MOVIE_ROOT constant that marks a parent category, // so we'll rely on the category search logic instead @@ -841,20 +1169,24 @@ class ReleaseSearchService } } - if (! empty($excludedCategories)) { - $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories))); - } + $applySqlMovieFilters = $searchResult === [] || ! Search::isAvailable(); - $catQuery = Category::getCategorySearch($cat, 'movies'); - $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery)); - if (! empty($catQuery) && $catQuery !== '1=1') { - $conditions[] = $catQuery; - } - if ($maxAge > 0) { - $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge); - } - if ($minSize > 0) { - $conditions[] = sprintf('r.size >= %d', $minSize); + if ($applySqlMovieFilters) { + if (! empty($excludedCategories)) { + $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories))); + } + + $catQuery = Category::getCategorySearch($cat, 'movies'); + $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery)); + if (! empty($catQuery) && $catQuery !== '1=1') { + $conditions[] = $catQuery; + } + if ($maxAge > 0) { + $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge); + } + if ($minSize > 0) { + $conditions[] = sprintf('r.size >= %d', $minSize); + } } $whereSql = 'WHERE '.implode(' AND ', $conditions); @@ -1017,6 +1349,61 @@ class ReleaseSearchService return min($bufferedRows, self::SEARCH_INDEX_MAX_CANDIDATES); } + /** + * Narrow release IDs using the search index (category, age, size, password). + * + * @param array $cat + * @param array $excludedCategories + * @return list + */ + private function intersectReleaseIdsWithSearchFilters( + array $releaseIds, + array $cat, + ?string $categorySearchType, + int $maxAge, + int $minSize, + array $excludedCategories + ): array { + if ($releaseIds === [] || ! Search::isAvailable()) { + return array_values(array_unique(array_map(static fn ($id): int => (int) $id, $releaseIds))); + } + + $unique = array_values(array_unique(array_filter( + array_map(static fn ($id): int => (int) $id, $releaseIds), + static fn (int $id): bool => $id > 0 + ))); + + if ($unique === []) { + return []; + } + + $categoryIdsRaw = Category::getCategorySearch($cat, $categorySearchType, true); + $categoryIds = null; + if (is_array($categoryIdsRaw)) { + $categoryIds = array_map(static fn ($id): int => (int) $id, $categoryIdsRaw); + } elseif (is_int($categoryIdsRaw) || (is_string($categoryIdsRaw) && ctype_digit((string) $categoryIdsRaw))) { + $categoryIds = [(int) $categoryIdsRaw]; + } + + $maxMatches = (int) config('search.drivers.manticore.max_matches', 10000); + $intersectLimit = min(max(count($unique), 1), max(1000, $maxMatches)); + + $filtered = Search::searchReleasesFiltered([ + 'phrases' => null, + 'release_ids' => $unique, + 'category_ids' => $categoryIds, + 'excluded_category_ids' => array_map(static fn ($id): int => (int) $id, $excludedCategories), + 'min_size' => $minSize, + 'max_age_days' => $maxAge, + 'password_allow_rar' => str_contains($this->showPasswords(), '<='), + 'sort_field' => 'postdate_ts', + 'sort_dir' => 'desc', + 'try_fuzzy' => false, + ], $intersectLimit, 0); + + return $filtered['ids']; + } + /** * Fallback MySQL search when full-text search engines are unavailable * diff --git a/app/Services/Search/Contracts/SearchServiceInterface.php b/app/Services/Search/Contracts/SearchServiceInterface.php index 91ec362d2..bf6664ff8 100644 --- a/app/Services/Search/Contracts/SearchServiceInterface.php +++ b/app/Services/Search/Contracts/SearchServiceInterface.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Services\Search\Contracts; +use App\Enums\SecondarySearchIndex; + /** * Interface for full-text search services (ManticoreSearch, Elasticsearch). * @@ -229,6 +231,14 @@ interface SearchServiceInterface */ public function searchMovieByExternalId(string $field, int|string $value): ?array; + /** + * Search movies index by field-specific terms (title, director, actors, genre). + * + * @param array $fieldTerms + * @return array{imdbids: list, movieinfo_ids: list, data: list>} + */ + public function searchMoviesByFields(array $fieldTerms, int $limit = 5000): array; + /** * Insert a TV show into the tvshows search index. * @@ -306,4 +316,44 @@ interface SearchServiceInterface * @return array Array of release IDs */ public function searchReleasesWithCategoryFilter(string $searchTerm, array $categoryIds = [], int $limit = 1000): array; + + /** + * Filter/sort releases in the search index (category, age, size, group, password) with optional full-text. + * + * @param array $criteria Keys: phrases (string|array|null), category_ids (list|null), + * excluded_category_ids (list), min_size (int), max_age_days (int), + * groups_id (int|null), password_allow_rar (bool), sort_field (string), + * sort_dir (string), try_fuzzy (bool), release_ids (list|null) + * @return array{ids: list, total: int, fuzzy: bool} + */ + public function searchReleasesFiltered(array $criteria, int $limit, int $offset = 0): array; + + /** + * Upsert a metadata document (music, books, games, console, steam, anime). + * + * @param array $document Fields for the index (no id key required) + */ + public function insertSecondary(SecondarySearchIndex $index, int $id, array $document): void; + + public function updateSecondary(SecondarySearchIndex $index, int $id): void; + + public function deleteSecondary(SecondarySearchIndex $index, int $id): void; + + /** + * @param array> $documents Each row must include int 'id' + * @return array + */ + public function bulkInsertSecondary(SecondarySearchIndex $index, array $documents): array; + + /** + * Full-text search on a secondary index. + * + * @return array{id: list, data: list>} + */ + public function searchSecondary(SecondarySearchIndex $index, string $query, int $limit = 100): array; + + /** + * @return list Distinct anidbids from anime index hits + */ + public function searchAnimeTitle(string $query, int $limit = 100): array; } diff --git a/app/Services/Search/Drivers/ElasticSearchDriver.php b/app/Services/Search/Drivers/ElasticSearchDriver.php index 0aaf55f6c..484900ead 100644 --- a/app/Services/Search/Drivers/ElasticSearchDriver.php +++ b/app/Services/Search/Drivers/ElasticSearchDriver.php @@ -4,12 +4,19 @@ declare(strict_types=1); namespace App\Services\Search\Drivers; +use App\Enums\SecondarySearchIndex; +use App\Models\BookInfo; +use App\Models\ConsoleInfo; +use App\Models\GamesInfo; use App\Models\MovieInfo; +use App\Models\MusicInfo; use App\Models\Release; +use App\Models\SteamApp; use App\Models\Video; use App\Services\Search\Contracts\SearchDriverInterface; use App\Services\Search\Support\ElasticsearchClientFactory; use App\Services\Search\Support\ElasticsearchResponseHelper; +use App\Support\SecondaryIndexDocuments; use Elastic\Elasticsearch\Client; use Elastic\Elasticsearch\Exception\ElasticsearchException; use Illuminate\Support\Collection; @@ -1059,10 +1066,39 @@ class ElasticSearchDriver implements SearchDriverInterface 'releases.searchname', 'releases.fromname', 'releases.categories_id', + 'releases.size', + 'releases.postdate', + 'releases.adddate', + 'releases.totalpart', + 'releases.grabs', + 'releases.passwordstatus', + 'releases.groups_id', + 'releases.nzbstatus', + 'releases.haspreview', 'releases.imdbid', + 'releases.videos_id', + 'releases.movieinfo_id', DB::raw('IFNULL(GROUP_CONCAT(rf.name SEPARATOR " "),"") filename'), ]) - ->groupBy('releases.id') + ->groupBy([ + 'releases.id', + 'releases.name', + 'releases.searchname', + 'releases.fromname', + 'releases.categories_id', + 'releases.size', + 'releases.postdate', + 'releases.adddate', + 'releases.totalpart', + 'releases.grabs', + 'releases.passwordstatus', + 'releases.groups_id', + 'releases.nzbstatus', + 'releases.haspreview', + 'releases.imdbid', + 'releases.videos_id', + 'releases.movieinfo_id', + ]) ->first(); if ($release === null) { @@ -1071,27 +1107,8 @@ class ElasticSearchDriver implements SearchDriverInterface return; } - $searchNameDotless = $this->createPlainSearchName($release->searchname); - $data = [ - 'body' => [ - 'doc' => [ - 'id' => $release->id, - 'name' => $release->name, - 'searchname' => $release->searchname, - 'plainsearchname' => $searchNameDotless, - 'fromname' => $release->fromname, - 'categories_id' => $release->categories_id, - 'imdbid' => (string) ($release->imdbid ?? ''), - 'filename' => $release->filename, // @phpstan-ignore property.notFound - ], - 'doc_as_upsert' => true, - ], - 'index' => $this->getReleasesIndex(), - 'id' => $release->id, - ]; - $client = $this->getClient(); - $client->update($data); + $client->index($this->buildReleaseDocument($release->toArray())); } catch (ElasticsearchException $e) { Log::error('ElasticSearch updateRelease error: '.$e->getMessage(), [ @@ -2067,6 +2084,72 @@ class ElasticSearchDriver implements SearchDriverInterface } } + /** + * {@inheritdoc} + */ + public function searchMoviesByFields(array $fieldTerms, int $limit = 5000): array + { + $allowed = ['title' => true, 'director' => true, 'actors' => true, 'genre' => true]; + $filtered = []; + foreach ($fieldTerms as $key => $value) { + $k = (string) $key; + if (isset($allowed[$k]) && is_string($value) && trim($value) !== '') { + $filtered[$k] = trim($value); + } + } + + if ($filtered === [] || ! $this->isElasticsearchAvailable()) { + return ['imdbids' => [], 'movieinfo_ids' => [], 'data' => []]; + } + + $must = []; + foreach ($filtered as $field => $value) { + $must[] = [ + 'match' => [ + $field => [ + 'query' => $value, + 'operator' => 'and', + ], + ], + ]; + } + + try { + $client = $this->getClient(); + $response = $client->search([ + 'index' => $this->getMoviesIndex(), + 'body' => [ + 'query' => ['bool' => ['must' => $must]], + 'size' => min($limit, self::MAX_RESULTS), + ], + ]); + + $imdbids = []; + $movieinfoIds = []; + $data = []; + + foreach ($response['hits']['hits'] ?? [] as $hit) { + $movieinfoIds[] = (int) ($hit['_id'] ?? 0); + $src = $hit['_source'] ?? []; + $data[] = $src; + $imdb = (string) ($src['imdbid'] ?? ''); + if ($imdb !== '') { + $imdbids[] = $imdb; + } + } + + return [ + 'imdbids' => array_values(array_unique($imdbids)), + 'movieinfo_ids' => $movieinfoIds, + 'data' => $data, + ]; + } catch (\Throwable $e) { + Log::error('ElasticSearch searchMoviesByFields error: '.$e->getMessage()); + + return ['imdbids' => [], 'movieinfo_ids' => [], 'data' => []]; + } + } + /** * Search movies by external ID (IMDB, TMDB, Trakt). * @@ -2630,4 +2713,345 @@ class ElasticSearchDriver implements SearchDriverInterface return []; } + + /** + * {@inheritdoc} + */ + public function searchReleasesFiltered(array $criteria, int $limit, int $offset = 0): array + { + if (! $this->isElasticsearchAvailable()) { + return ['ids' => [], 'total' => 0, 'fuzzy' => false]; + } + + $phrases = $criteria['phrases'] ?? null; + $hasText = $phrases !== null && $phrases !== '' && $phrases !== -1; + $tryFuzzy = (bool) ($criteria['try_fuzzy'] ?? true); + + $run = function (bool $useFuzzy) use ($criteria, $limit, $offset, $hasText, $phrases): array { + $filter = $this->buildElasticsearchReleaseFilters($criteria); + $must = []; + + if ($hasText) { + $text = is_string($phrases) + ? $phrases + : implode(' ', array_values(array_filter( + is_array($phrases) ? $phrases : [], + static fn ($v): bool => $v !== null && $v !== '' && $v !== -1 + ))); + if ($text === '') { + return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy]; + } + $multi = [ + 'query' => $text, + 'fields' => ['searchname^3', 'name^2', 'filename', 'plainsearchname'], + 'type' => 'best_fields', + ]; + if ($useFuzzy && $this->isFuzzyEnabled()) { + $multi['fuzziness'] = $this->getFuzzyConfig()['fuzziness'] ?? 'AUTO'; + } + $must[] = ['multi_match' => $multi]; + } + + if ($must !== [] && $filter !== []) { + $query = ['bool' => ['must' => $must, 'filter' => $filter]]; + } elseif ($must !== []) { + $query = ['bool' => ['must' => $must]]; + } elseif ($filter !== []) { + $query = ['bool' => ['filter' => $filter]]; + } else { + $query = ['match_all' => new \stdClass]; + } + + $sortField = (string) ($criteria['sort_field'] ?? 'postdate_ts'); + $sortDir = strtolower((string) ($criteria['sort_dir'] ?? 'desc')); + $allowedSort = ['postdate_ts', 'adddate_ts', 'size', 'totalpart', 'grabs', 'categories_id', 'id']; + if (! in_array($sortField, $allowedSort, true)) { + $sortField = 'postdate_ts'; + } + if ($sortField === 'id') { + $sortField = 'postdate_ts'; + } + $order = $sortDir === 'asc' ? 'asc' : 'desc'; + + try { + $client = $this->getClient(); + $response = $client->search([ + 'index' => $this->getReleasesIndex(), + 'body' => [ + 'query' => $query, + 'sort' => [ + [$sortField => ['order' => $order]], + ], + 'from' => max(0, $offset), + 'size' => max(1, min($limit, self::MAX_RESULTS)), + 'track_total_hits' => true, + '_source' => false, + ], + ]); + + $ids = []; + foreach ($response['hits']['hits'] ?? [] as $hit) { + $ids[] = (int) ($hit['_id'] ?? 0); + } + $total = (int) ($response['hits']['total']['value'] ?? $response['hits']['total'] ?? 0); + + return ['ids' => $ids, 'total' => $total, 'fuzzy' => $useFuzzy]; + } catch (\Throwable $e) { + Log::error('ElasticSearch searchReleasesFiltered error: '.$e->getMessage()); + + return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy]; + } + }; + + if ($hasText) { + $first = $run(false); + if ($first['ids'] !== [] || ! $tryFuzzy || ! $this->isFuzzyEnabled()) { + return $first; + } + + return $run(true); + } + + return $run(false); + } + + /** + * @return list> + */ + private function buildElasticsearchReleaseFilters(array $criteria): array + { + $filter = []; + + $releaseIds = $criteria['release_ids'] ?? null; + if (is_array($releaseIds) && $releaseIds !== []) { + $valid = array_values(array_filter( + array_map(static fn ($id): int => (int) $id, $releaseIds), + static fn (int $id): bool => $id > 0 + )); + if ($valid !== []) { + $filter[] = ['ids' => ['values' => array_map(static fn (int $id): string => (string) $id, $valid)]]; + } + } + + $categoryIds = $criteria['category_ids'] ?? null; + if (is_array($categoryIds) && $categoryIds !== []) { + $valid = array_values(array_filter($categoryIds, static fn ($id): bool => (int) $id > 0)); + if ($valid !== []) { + $filter[] = ['terms' => ['categories_id' => array_map(static fn ($id): int => (int) $id, $valid)]]; + } + } + + $excluded = $criteria['excluded_category_ids'] ?? []; + if ($excluded !== []) { + $filter[] = ['bool' => ['must_not' => [ + ['terms' => ['categories_id' => array_map(static fn ($id): int => (int) $id, $excluded)]], + ]]]; + } + + $minSize = (int) ($criteria['min_size'] ?? 0); + if ($minSize > 0) { + $filter[] = ['range' => ['size' => ['gte' => $minSize]]]; + } + + $maxAge = (int) ($criteria['max_age_days'] ?? 0); + if ($maxAge > 0) { + $cutoff = time() - ($maxAge * 86400); + $filter[] = ['range' => ['postdate_ts' => ['gte' => $cutoff]]]; + } + + $gid = $criteria['groups_id'] ?? null; + if ($gid !== null && (int) $gid > 0) { + $filter[] = ['term' => ['groups_id' => (int) $gid]]; + } + + $allowRar = (bool) ($criteria['password_allow_rar'] ?? false); + if ($allowRar) { + $filter[] = ['range' => ['passwordstatus' => ['lte' => 1]]]; + } else { + $filter[] = ['term' => ['passwordstatus' => 0]]; + } + + return $filter; + } + + public function getSecondaryIndexName(SecondarySearchIndex $index): string + { + $configured = $this->config['indexes'][$index->value] ?? null; + if (is_string($configured) && $configured !== '') { + return $configured; + } + + return match ($index) { + SecondarySearchIndex::Music => 'music', + SecondarySearchIndex::Books => 'books', + SecondarySearchIndex::Games => 'games', + SecondarySearchIndex::Console => 'console', + SecondarySearchIndex::Steam => 'steam', + SecondarySearchIndex::Anime => 'anime', + }; + } + + public function insertSecondary(SecondarySearchIndex $index, int $id, array $document): void + { + if ($id <= 0) { + return; + } + + try { + $this->getClient()->index([ + 'index' => $this->getSecondaryIndexName($index), + 'id' => (string) $id, + 'body' => $document, + ]); + } catch (\Throwable $e) { + Log::error('ElasticSearch insertSecondary error: '.$e->getMessage(), [ + 'secondary' => $index->value, + 'id' => $id, + ]); + } + } + + public function updateSecondary(SecondarySearchIndex $index, int $id): void + { + if ($id <= 0) { + return; + } + + try { + match ($index) { + SecondarySearchIndex::Music => $this->insertSecondary($index, $id, SecondaryIndexDocuments::music(MusicInfo::query()->findOrFail($id))), + SecondarySearchIndex::Books => $this->insertSecondary($index, $id, SecondaryIndexDocuments::book(BookInfo::query()->findOrFail($id))), + SecondarySearchIndex::Games => $this->insertSecondary($index, $id, SecondaryIndexDocuments::games(GamesInfo::query()->findOrFail($id))), + SecondarySearchIndex::Console => $this->insertSecondary($index, $id, SecondaryIndexDocuments::console(ConsoleInfo::query()->findOrFail($id))), + SecondarySearchIndex::Steam => $this->insertSecondary($index, $id, SecondaryIndexDocuments::steam(SteamApp::query()->findOrFail($id))), + SecondarySearchIndex::Anime => null, + }; + } catch (\Throwable $e) { + Log::warning('ElasticSearch updateSecondary skipped: '.$e->getMessage(), [ + 'secondary' => $index->value, + 'id' => $id, + ]); + } + } + + public function deleteSecondary(SecondarySearchIndex $index, int $id): void + { + if ($id <= 0) { + return; + } + + try { + $this->getClient()->delete([ + 'index' => $this->getSecondaryIndexName($index), + 'id' => (string) $id, + ]); + } catch (\Throwable $e) { + Log::debug('ElasticSearch deleteSecondary: '.$e->getMessage(), [ + 'secondary' => $index->value, + 'id' => $id, + ]); + } + } + + /** + * @param array> $documents + * @return array + */ + public function bulkInsertSecondary(SecondarySearchIndex $index, array $documents): array + { + if ($documents === []) { + return ['success' => 0, 'errors' => 0]; + } + + $success = 0; + $errors = 0; + $indexName = $this->getSecondaryIndexName($index); + + foreach ($documents as $doc) { + $docId = (int) ($doc['id'] ?? 0); + if ($docId <= 0) { + $errors++; + + continue; + } + unset($doc['id']); + try { + $this->getClient()->index([ + 'index' => $indexName, + 'id' => (string) $docId, + 'body' => $doc, + ]); + $success++; + } catch (\Throwable $e) { + $errors++; + Log::error('ElasticSearch bulkInsertSecondary row error: '.$e->getMessage()); + } + } + + return ['success' => $success, 'errors' => $errors]; + } + + /** + * @return array{id: list, data: list>} + */ + public function searchSecondary(SecondarySearchIndex $index, string $query, int $limit = 100): array + { + $query = trim($query); + if ($query === '') { + return ['id' => [], 'data' => []]; + } + + $fields = $index->fulltextFields(); + $escapedSearch = self::escapeString($query); + if ($escapedSearch === '') { + return ['id' => [], 'data' => []]; + } + + try { + $response = $this->getClient()->search([ + 'index' => $this->getSecondaryIndexName($index), + 'body' => [ + 'query' => [ + 'multi_match' => [ + 'query' => $escapedSearch, + 'fields' => $fields, + 'operator' => 'and', + ], + ], + 'size' => max(1, min($limit, self::MAX_RESULTS)), + '_source' => true, + ], + ]); + + $ids = []; + $data = []; + foreach ($response['hits']['hits'] ?? [] as $hit) { + $ids[] = (int) ($hit['_id'] ?? 0); + $data[] = (array) ($hit['_source'] ?? []); + } + + return ['id' => $ids, 'data' => $data]; + } catch (\Throwable $e) { + Log::error('ElasticSearch searchSecondary error: '.$e->getMessage(), ['secondary' => $index->value]); + + return ['id' => [], 'data' => []]; + } + } + + /** + * @return list + */ + public function searchAnimeTitle(string $query, int $limit = 100): array + { + $hits = $this->searchSecondary(SecondarySearchIndex::Anime, $query, $limit); + $seen = []; + foreach ($hits['data'] as $row) { + $aid = (int) ($row['anidbid'] ?? 0); + if ($aid > 0) { + $seen[$aid] = true; + } + } + + return array_keys($seen); + } } diff --git a/app/Services/Search/Drivers/ManticoreSearchDriver.php b/app/Services/Search/Drivers/ManticoreSearchDriver.php index 856409f81..c1e2fa3f3 100644 --- a/app/Services/Search/Drivers/ManticoreSearchDriver.php +++ b/app/Services/Search/Drivers/ManticoreSearchDriver.php @@ -4,10 +4,18 @@ declare(strict_types=1); namespace App\Services\Search\Drivers; +use App\Enums\SecondarySearchIndex; +use App\Models\BookInfo; +use App\Models\ConsoleInfo; +use App\Models\GamesInfo; use App\Models\MovieInfo; +use App\Models\MusicInfo; use App\Models\Release; +use App\Models\SteamApp; use App\Models\Video; use App\Services\Search\Contracts\SearchDriverInterface; +use App\Support\ReleaseSearchIndexDocument; +use App\Support\SecondaryIndexDocuments; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -158,22 +166,8 @@ class ManticoreSearchDriver implements SearchDriverInterface } try { - $document = [ - 'name' => $parameters['name'] ?? '', - 'searchname' => $parameters['searchname'] ?? '', - 'fromname' => $parameters['fromname'] ?? '', - 'categories_id' => (int) ($parameters['categories_id'] ?? 0), - 'filename' => $parameters['filename'] ?? '', - // External media IDs for efficient searching - 'imdbid' => (string) ($parameters['imdbid'] ?? ''), - 'tmdbid' => (int) ($parameters['tmdbid'] ?? 0), - 'traktid' => (int) ($parameters['traktid'] ?? 0), - 'tvdb' => (int) ($parameters['tvdb'] ?? 0), - 'tvmaze' => (int) ($parameters['tvmaze'] ?? 0), - 'tvrage' => (int) ($parameters['tvrage'] ?? 0), - 'videos_id' => (int) ($parameters['videos_id'] ?? 0), - 'movieinfo_id' => (int) ($parameters['movieinfo_id'] ?? 0), - ]; + $document = ReleaseSearchIndexDocument::normalize($parameters); + unset($document['id']); $this->manticoreSearch->table($this->config['indexes']['releases']) ->replaceDocument($document, $parameters['id']); @@ -297,22 +291,10 @@ class ManticoreSearchDriver implements SearchDriverInterface continue; } - $documents[] = [ - 'id' => $release['id'], - 'name' => (string) ($release['name'] ?? ''), - 'searchname' => (string) ($release['searchname'] ?? ''), - 'fromname' => (string) ($release['fromname'] ?? ''), - 'categories_id' => (int) ($release['categories_id'] ?? 0), - 'filename' => (string) ($release['filename'] ?? ''), - 'imdbid' => (string) ($release['imdbid'] ?? ''), - 'tmdbid' => (int) ($release['tmdbid'] ?? 0), - 'traktid' => (int) ($release['traktid'] ?? 0), - 'tvdb' => (int) ($release['tvdb'] ?? 0), - 'tvmaze' => (int) ($release['tvmaze'] ?? 0), - 'tvrage' => (int) ($release['tvrage'] ?? 0), - 'videos_id' => (int) ($release['videos_id'] ?? 0), - 'movieinfo_id' => (int) ($release['movieinfo_id'] ?? 0), - ]; + $documents[] = array_merge( + ['id' => $release['id']], + ReleaseSearchIndexDocument::normalize($release) + ); } if (empty($documents)) { @@ -596,9 +578,39 @@ class ManticoreSearchDriver implements SearchDriverInterface 'releases.searchname', 'releases.fromname', 'releases.categories_id', + 'releases.size', + 'releases.postdate', + 'releases.adddate', + 'releases.totalpart', + 'releases.grabs', + 'releases.passwordstatus', + 'releases.groups_id', + 'releases.nzbstatus', + 'releases.haspreview', + 'releases.imdbid', + 'releases.videos_id', + 'releases.movieinfo_id', DB::raw('IFNULL(GROUP_CONCAT(rf.name SEPARATOR " "),"") filename'), ]) - ->groupBy('releases.id') + ->groupBy([ + 'releases.id', + 'releases.name', + 'releases.searchname', + 'releases.fromname', + 'releases.categories_id', + 'releases.size', + 'releases.postdate', + 'releases.adddate', + 'releases.totalpart', + 'releases.grabs', + 'releases.passwordstatus', + 'releases.groups_id', + 'releases.nzbstatus', + 'releases.haspreview', + 'releases.imdbid', + 'releases.videos_id', + 'releases.movieinfo_id', + ]) ->first(); if ($release !== null) { @@ -722,6 +734,15 @@ class ManticoreSearchDriver implements SearchDriverInterface 'tvrage' => ['type' => 'integer'], 'videos_id' => ['type' => 'integer'], 'movieinfo_id' => ['type' => 'integer'], + 'size' => ['type' => 'bigint'], + 'postdate_ts' => ['type' => 'bigint'], + 'adddate_ts' => ['type' => 'bigint'], + 'totalpart' => ['type' => 'integer'], + 'grabs' => ['type' => 'integer'], + 'passwordstatus' => ['type' => 'integer'], + 'groups_id' => ['type' => 'integer'], + 'nzbstatus' => ['type' => 'integer'], + 'haspreview' => ['type' => 'integer'], ], ], ]); @@ -787,6 +808,24 @@ class ManticoreSearchDriver implements SearchDriverInterface ], ]); cli()->info('Created tvshows_rt index with infix search support'); + } else { + foreach (SecondarySearchIndex::cases() as $secondary) { + if ($index === $this->getSecondaryIndexName($secondary)) { + $indices->create([ + 'index' => $index, + 'body' => [ + 'settings' => [ + 'min_prefix_len' => 0, + 'min_infix_len' => 2, + ], + 'columns' => $secondary->manticoreColumns(), + ], + ]); + cli()->info("Created {$index} secondary metadata index"); + + break; + } + } } } catch (\Throwable $e) { cli()->error('Error creating index '.$index.': '.$e->getMessage()); @@ -1754,6 +1793,45 @@ class ManticoreSearchDriver implements SearchDriverInterface return $this->searchIndexes($this->getMoviesIndex(), $searchString, ['title', 'actors', 'director'], []); // @phpstan-ignore argument.type } + /** + * {@inheritdoc} + */ + public function searchMoviesByFields(array $fieldTerms, int $limit = 5000): array + { + $allowed = ['title' => true, 'director' => true, 'actors' => true, 'genre' => true]; + $filtered = []; + foreach ($fieldTerms as $key => $value) { + $k = (string) $key; + if (isset($allowed[$k]) && is_string($value) && trim($value) !== '') { + $filtered[$k] = trim($value); + } + } + + if ($filtered === [] || ! $this->isAvailable()) { + return ['imdbids' => [], 'movieinfo_ids' => [], 'data' => []]; + } + + $raw = $this->searchIndexes($this->getMoviesIndex(), null, [], $filtered, $limit); + $imdbids = []; + $movieinfoIds = []; + $data = $raw['data'] ?? []; + + foreach (($raw['id'] ?? []) as $i => $mid) { + $movieinfoIds[] = (int) $mid; + $row = $data[$i] ?? []; + $imdb = (string) ($row['imdbid'] ?? ''); + if ($imdb !== '') { + $imdbids[] = $imdb; + } + } + + return [ + 'imdbids' => array_values(array_unique($imdbids)), + 'movieinfo_ids' => $movieinfoIds, + 'data' => $data, + ]; + } + /** * Search movies by external ID (IMDB, TMDB, Trakt). * @@ -2182,4 +2260,360 @@ class ManticoreSearchDriver implements SearchDriverInterface return []; } + + /** + * {@inheritdoc} + */ + public function searchReleasesFiltered(array $criteria, int $limit, int $offset = 0): array + { + if (! $this->isAvailable()) { + return ['ids' => [], 'total' => 0, 'fuzzy' => false]; + } + + $phrases = $criteria['phrases'] ?? null; + $hasText = $phrases !== null && $phrases !== '' && $phrases !== -1; + $tryFuzzy = (bool) ($criteria['try_fuzzy'] ?? true); + + $execute = function (bool $useFuzzy) use ($criteria, $limit, $offset, $hasText, $phrases): array { + $query = (new Search($this->manticoreSearch)) + ->setTable($this->getReleasesIndex()) + ->stripBadUtf8(true); + + if ($hasText) { + if ($useFuzzy && $this->isFuzzyEnabled()) { + if (self::queryHasNegation($phrases)) { + return ['ids' => [], 'total' => 0, 'fuzzy' => false]; + } + $searchString = $this->phrasesToFuzzyString($phrases); + if ($searchString === '') { + return ['ids' => [], 'total' => 0, 'fuzzy' => false]; + } + $query->search($searchString) + ->option('fuzzy', true) + ->option('distance', (int) ($this->getFuzzyConfig()['max_distance'] ?? 2)); + } else { + $searchArray = $this->phrasesToSearchArray($phrases); + $terms = []; + foreach ($searchArray as $key => $value) { + if ($value === '' || $value === null) { + continue; + } + $prepared = self::prepareUserSearchQuery((string) $value); + if ($prepared !== '') { + $terms[] = '@@relaxed @'.$key.' '.$prepared; + } + } + if ($terms === []) { + return ['ids' => [], 'total' => 0, 'fuzzy' => false]; + } + $query->search(implode(' ', $terms))->option('ranker', 'sph04'); + } + } + + $this->applyManticoreReleaseIndexFilters($query, $criteria); + + $sortField = (string) ($criteria['sort_field'] ?? 'postdate_ts'); + $sortDir = strtolower((string) ($criteria['sort_dir'] ?? 'desc')); + $allowedSort = ['postdate_ts', 'adddate_ts', 'size', 'totalpart', 'grabs', 'categories_id', 'id']; + if (! in_array($sortField, $allowedSort, true)) { + $sortField = 'postdate_ts'; + } + $query->sort($sortField, $sortDir === 'asc' ? 'asc' : 'desc'); + + $maxNeed = max(1000, $offset + $limit + 100); + $query->maxMatches(min($maxNeed, (int) ($this->config['max_matches'] ?? 10000))); + $query->limit(max(1, $limit)); + if ($offset > 0) { + $query->offset($offset); + } + + try { + $results = $query->get(); + } catch (\Throwable $e) { + Log::error('ManticoreSearch searchReleasesFiltered error: '.$e->getMessage(), [ + 'criteria' => $criteria, + ]); + + return ['ids' => [], 'total' => 0, 'fuzzy' => $useFuzzy]; + } + + $ids = []; + foreach ($results as $doc) { + $ids[] = $doc->getId(); + } + + return [ + 'ids' => $ids, + 'total' => (int) $results->getTotal(), + 'fuzzy' => $useFuzzy, + ]; + }; + + if ($hasText) { + $first = $execute(false); + if ($first['ids'] !== [] || ! $tryFuzzy || ! $this->isFuzzyEnabled() || self::queryHasNegation($phrases)) { + return $first; + } + + return $execute(true); + } + + return $execute(false); + } + + /** + * @param array|string $phrases + */ + private function phrasesToSearchArray(array|string $phrases): array + { + if (is_string($phrases)) { + return ['searchname' => $phrases]; + } + $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0; + if ($isAssociative) { + return $phrases; + } + + return ['searchname' => implode(' ', $phrases)]; + } + + /** + * @param array|string $phrases + */ + private function phrasesToFuzzyString(array|string $phrases): string + { + $searchArray = $this->phrasesToSearchArray($phrases); + $searchTerms = []; + foreach ($searchArray as $value) { + if (! empty($value)) { + $cleanValue = preg_replace('/[^\w\s]/', ' ', (string) $value); + $cleanValue = preg_replace('/\s+/', ' ', trim((string) $cleanValue)); + if ($cleanValue !== '') { + $searchTerms[] = $cleanValue; + } + } + } + + return implode(' ', $searchTerms); + } + + /** + * @param array $criteria + */ + private function applyManticoreReleaseIndexFilters(Search $query, array $criteria): void + { + $releaseIds = $criteria['release_ids'] ?? null; + if (is_array($releaseIds) && $releaseIds !== []) { + $valid = array_values(array_filter( + array_map(static fn ($id): int => (int) $id, $releaseIds), + static fn (int $id): bool => $id > 0 + )); + if (count($valid) === 1) { + $query->filter('id', '=', $valid[0]); + } elseif (count($valid) > 1) { + $query->filter('id', 'in', $valid); + } + } + + $categoryIds = $criteria['category_ids'] ?? null; + if (is_array($categoryIds) && $categoryIds !== []) { + $valid = array_values(array_filter($categoryIds, static fn ($id): bool => (int) $id > 0)); + if (count($valid) === 1) { + $query->filter('categories_id', '=', (int) $valid[0]); + } elseif (count($valid) > 1) { + $query->filter('categories_id', 'in', array_map(static fn ($id): int => (int) $id, $valid)); + } + } + + $excluded = $criteria['excluded_category_ids'] ?? []; + if ($excluded !== []) { + $query->notFilter('categories_id', 'in', array_map(static fn ($id): int => (int) $id, $excluded)); + } + + $minSize = (int) ($criteria['min_size'] ?? 0); + if ($minSize > 0) { + $query->filter('size', 'gte', $minSize); + } + + $maxAge = (int) ($criteria['max_age_days'] ?? 0); + if ($maxAge > 0) { + $cutoff = time() - ($maxAge * 86400); + $query->filter('postdate_ts', 'gte', $cutoff); + } + + $gid = $criteria['groups_id'] ?? null; + if ($gid !== null && (int) $gid > 0) { + $query->filter('groups_id', '=', (int) $gid); + } + + $allowRar = (bool) ($criteria['password_allow_rar'] ?? false); + if ($allowRar) { + $query->filter('passwordstatus', 'lte', 1); + } else { + $query->filter('passwordstatus', '=', 0); + } + } + + public function getSecondaryIndexName(SecondarySearchIndex $index): string + { + $configured = $this->config['indexes'][$index->value] ?? null; + if (is_string($configured) && $configured !== '') { + return $configured; + } + + return match ($index) { + SecondarySearchIndex::Music => 'music_rt', + SecondarySearchIndex::Books => 'books_rt', + SecondarySearchIndex::Games => 'games_rt', + SecondarySearchIndex::Console => 'console_rt', + SecondarySearchIndex::Steam => 'steam_rt', + SecondarySearchIndex::Anime => 'anime_rt', + }; + } + + public function insertSecondary(SecondarySearchIndex $index, int $id, array $document): void + { + if ($id <= 0) { + return; + } + + try { + $this->createIndexIfNotExists($this->getSecondaryIndexName($index)); + $this->manticoreSearch->table($this->getSecondaryIndexName($index)) + ->replaceDocument($document, $id); + } catch (ResponseException $e) { + Log::error('ManticoreSearch insertSecondary ResponseException: '.$e->getMessage(), [ + 'secondary' => $index->value, + 'id' => $id, + ]); + } catch (\Throwable $e) { + Log::error('ManticoreSearch insertSecondary error: '.$e->getMessage(), [ + 'secondary' => $index->value, + 'id' => $id, + ]); + } + } + + public function updateSecondary(SecondarySearchIndex $index, int $id): void + { + if ($id <= 0) { + return; + } + + try { + match ($index) { + SecondarySearchIndex::Music => $this->insertSecondary($index, $id, SecondaryIndexDocuments::music(MusicInfo::query()->findOrFail($id))), + SecondarySearchIndex::Books => $this->insertSecondary($index, $id, SecondaryIndexDocuments::book(BookInfo::query()->findOrFail($id))), + SecondarySearchIndex::Games => $this->insertSecondary($index, $id, SecondaryIndexDocuments::games(GamesInfo::query()->findOrFail($id))), + SecondarySearchIndex::Console => $this->insertSecondary($index, $id, SecondaryIndexDocuments::console(ConsoleInfo::query()->findOrFail($id))), + SecondarySearchIndex::Steam => $this->insertSecondary($index, $id, SecondaryIndexDocuments::steam(SteamApp::query()->findOrFail($id))), + SecondarySearchIndex::Anime => null, + }; + } catch (\Throwable $e) { + Log::warning('ManticoreSearch updateSecondary skipped: '.$e->getMessage(), [ + 'secondary' => $index->value, + 'id' => $id, + ]); + } + } + + public function deleteSecondary(SecondarySearchIndex $index, int $id): void + { + if ($id <= 0) { + return; + } + + try { + $this->manticoreSearch->table($this->getSecondaryIndexName($index)) + ->deleteDocument($id); + } catch (\Throwable $e) { + Log::error('ManticoreSearch deleteSecondary error: '.$e->getMessage(), [ + 'secondary' => $index->value, + 'id' => $id, + ]); + } + } + + /** + * @param array> $documents + * @return array + */ + public function bulkInsertSecondary(SecondarySearchIndex $index, array $documents): array + { + if ($documents === []) { + return ['success' => 0, 'errors' => 0]; + } + + $success = 0; + $errors = 0; + $rows = []; + $table = $this->getSecondaryIndexName($index); + + foreach ($documents as $doc) { + $docId = (int) ($doc['id'] ?? 0); + if ($docId <= 0) { + $errors++; + + continue; + } + unset($doc['id']); + $rows[] = array_merge(['id' => $docId], $doc); + } + + if ($rows === []) { + return ['success' => 0, 'errors' => $errors]; + } + + try { + $this->createIndexIfNotExists($table); + $this->manticoreSearch->table($table)->replaceDocuments($rows); + $success = count($rows); + } catch (\Throwable $e) { + Log::error('ManticoreSearch bulkInsertSecondary error: '.$e->getMessage(), ['secondary' => $index->value]); + $errors += count($rows); + } + + return ['success' => $success, 'errors' => $errors]; + } + + /** + * @return array{id: list, data: list>} + */ + public function searchSecondary(SecondarySearchIndex $index, string $query, int $limit = 100): array + { + $query = trim($query); + if ($query === '') { + return ['id' => [], 'data' => []]; + } + + $result = $this->searchIndexes( + $this->getSecondaryIndexName($index), + $query, + $index->fulltextFields(), + [], + $this->normalizeSearchLimit($limit) + ); + + return [ + 'id' => array_map(static fn ($v): int => (int) $v, $result['id'] ?? []), + 'data' => $result['data'] ?? [], + ]; + } + + /** + * @return list + */ + public function searchAnimeTitle(string $query, int $limit = 100): array + { + $hits = $this->searchSecondary(SecondarySearchIndex::Anime, $query, $limit); + $seen = []; + foreach ($hits['data'] as $row) { + $aid = (int) ($row['anidbid'] ?? 0); + if ($aid > 0) { + $seen[$aid] = true; + } + } + + return array_keys($seen); + } } diff --git a/app/Services/Search/SearchService.php b/app/Services/Search/SearchService.php index 0da516113..1e8b342f1 100644 --- a/app/Services/Search/SearchService.php +++ b/app/Services/Search/SearchService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services\Search; +use App\Enums\SecondarySearchIndex; use App\Services\Search\Contracts\SearchDriverInterface; use App\Services\Search\Contracts\SearchServiceInterface; use App\Services\Search\Drivers\ElasticSearchDriver; @@ -370,6 +371,15 @@ class SearchService extends Manager implements SearchServiceInterface return $this->driver()->searchMovieByExternalId($field, $value); } + /** + * @param array $fieldTerms + * @return array{imdbids: list, movieinfo_ids: list, data: list>} + */ + public function searchMoviesByFields(array $fieldTerms, int $limit = 5000): array + { + return $this->driver()->searchMoviesByFields($fieldTerms, $limit); + } + /** * Insert a TV show into the tvshows search index. * @@ -463,4 +473,46 @@ class SearchService extends Manager implements SearchServiceInterface { return $this->driver()->searchReleasesWithCategoryFilter($searchTerm, $categoryIds, $limit); } + + /** + * @param array $criteria + * @return array{ids: list, total: int, fuzzy: bool} + */ + public function searchReleasesFiltered(array $criteria, int $limit, int $offset = 0): array + { + return $this->driver()->searchReleasesFiltered($criteria, $limit, $offset); + } + + public function insertSecondary(SecondarySearchIndex $index, int $id, array $document): void + { + $this->driver()->insertSecondary($index, $id, $document); + } + + public function updateSecondary(SecondarySearchIndex $index, int $id): void + { + $this->driver()->updateSecondary($index, $id); + } + + public function deleteSecondary(SecondarySearchIndex $index, int $id): void + { + $this->driver()->deleteSecondary($index, $id); + } + + /** + * @param array> $documents + */ + public function bulkInsertSecondary(SecondarySearchIndex $index, array $documents): array + { + return $this->driver()->bulkInsertSecondary($index, $documents); + } + + public function searchSecondary(SecondarySearchIndex $index, string $query, int $limit = 100): array + { + return $this->driver()->searchSecondary($index, $query, $limit); + } + + public function searchAnimeTitle(string $query, int $limit = 100): array + { + return $this->driver()->searchAnimeTitle($query, $limit); + } } diff --git a/app/Services/SteamService.php b/app/Services/SteamService.php index 0900053c9..a6b975e5c 100644 --- a/app/Services/SteamService.php +++ b/app/Services/SteamService.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\SecondarySearchIndex; +use App\Facades\Search; use App\Models\SteamApp; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; @@ -490,29 +492,28 @@ class SteamService $seenAppIds = []; foreach ($variants as $variant) { - // Try Scout full-text search first try { - $results = SteamApp::search($variant)->take($limit)->get(); - foreach ($results as $result) { - $appid = $result->appid ?? null; - $name = $result->name ?? null; - - if ($appid === null || $name === null || isset($seenAppIds[$appid])) { - continue; - } - - $score = $this->scoreTitle($name, $title); - if ($score >= self::RELAXED_MATCH_THRESHOLD) { - $seenAppIds[$appid] = true; - $matches[] = [ - 'appid' => (int) $appid, - 'name' => $name, - 'score' => $score, - ]; + if (Search::isAvailable()) { + $hits = Search::searchSecondary(SecondarySearchIndex::Steam, $variant, $limit); + foreach ($hits['data'] as $row) { + $appid = $row['appid'] ?? null; + $name = $row['name'] ?? null; + if ($appid === null || $name === null || $name === '' || isset($seenAppIds[$appid])) { + continue; + } + $score = $this->scoreTitle((string) $name, $title); + if ($score >= self::RELAXED_MATCH_THRESHOLD) { + $seenAppIds[(int) $appid] = true; + $matches[] = [ + 'appid' => (int) $appid, + 'name' => (string) $name, + 'score' => $score, + ]; + } } } } catch (\Exception $e) { - Log::debug('SteamService: Scout search failed', ['error' => $e->getMessage()]); + Log::debug('SteamService: search index lookup failed', ['error' => $e->getMessage()]); } // LIKE fallback diff --git a/app/Support/MetadataSearchLookup.php b/app/Support/MetadataSearchLookup.php new file mode 100644 index 000000000..3366c4a46 --- /dev/null +++ b/app/Support/MetadataSearchLookup.php @@ -0,0 +1,21 @@ + $row Keys from DB or insert parameters + * @return array + */ + public static function normalize(array $row): array + { + $postdateTs = self::datetimeToUnix($row['postdate'] ?? null); + $adddateTs = self::datetimeToUnix($row['adddate'] ?? null); + + return [ + 'id' => (int) ($row['id'] ?? 0), + 'name' => (string) ($row['name'] ?? ''), + 'searchname' => (string) ($row['searchname'] ?? ''), + 'fromname' => (string) ($row['fromname'] ?? ''), + 'categories_id' => (int) ($row['categories_id'] ?? 0), + 'filename' => (string) ($row['filename'] ?? ''), + 'imdbid' => (string) ($row['imdbid'] ?? ''), + 'tmdbid' => (int) ($row['tmdbid'] ?? 0), + 'traktid' => (int) ($row['traktid'] ?? 0), + 'tvdb' => (int) ($row['tvdb'] ?? 0), + 'tvmaze' => (int) ($row['tvmaze'] ?? 0), + 'tvrage' => (int) ($row['tvrage'] ?? 0), + 'videos_id' => (int) ($row['videos_id'] ?? 0), + 'movieinfo_id' => (int) ($row['movieinfo_id'] ?? 0), + 'size' => (int) ($row['size'] ?? 0), + 'postdate_ts' => $postdateTs, + 'adddate_ts' => $adddateTs, + 'totalpart' => (int) ($row['totalpart'] ?? 0), + 'grabs' => (int) ($row['grabs'] ?? 0), + 'passwordstatus' => (int) ($row['passwordstatus'] ?? 0), + 'groups_id' => (int) ($row['groups_id'] ?? 0), + 'nzbstatus' => (int) ($row['nzbstatus'] ?? 0), + 'haspreview' => (int) ($row['haspreview'] ?? 0), + ]; + } + + private static function datetimeToUnix(mixed $value): int + { + if ($value === null || $value === '') { + return 0; + } + if (is_int($value) || is_float($value)) { + return (int) $value; + } + $ts = strtotime((string) $value); + + return $ts !== false ? $ts : 0; + } +} diff --git a/app/Support/SecondaryIndexDocuments.php b/app/Support/SecondaryIndexDocuments.php new file mode 100644 index 000000000..d54069874 --- /dev/null +++ b/app/Support/SecondaryIndexDocuments.php @@ -0,0 +1,154 @@ + $row + * @return array + */ + public static function musicFromArray(array $row): array + { + return [ + 'title' => (string) ($row['title'] ?? ''), + 'artist' => (string) ($row['artist'] ?? ''), + 'year' => (string) ($row['year'] ?? ''), + 'genres_id' => (int) ($row['genres_id'] ?? 0), + 'cover' => (int) ($row['cover'] ?? 0), + ]; + } + + public static function music(MusicInfo $m): array + { + return self::musicFromArray($m->getAttributes()); + } + + /** + * @param array $row + * @return array + */ + public static function bookFromArray(array $row): array + { + return [ + 'title' => (string) ($row['title'] ?? ''), + 'author' => (string) ($row['author'] ?? ''), + 'publishdate' => (string) ($row['publishdate'] ?? ''), + 'cover' => (int) ($row['cover'] ?? 0), + ]; + } + + public static function book(BookInfo $b): array + { + return self::bookFromArray($b->getAttributes()); + } + + /** + * @param array $row + * @return array + */ + public static function gamesFromArray(array $row): array + { + $ts = self::releasedateToTimestamp($row['releasedate'] ?? null); + + return [ + 'title' => (string) ($row['title'] ?? ''), + 'genres_id' => (int) ($row['genres_id'] ?? 0), + 'releasedate_ts' => $ts, + 'cover' => (int) ($row['cover'] ?? 0), + ]; + } + + public static function games(GamesInfo $g): array + { + return self::gamesFromArray($g->getAttributes()); + } + + /** + * @param array $row + * @return array + */ + public static function consoleFromArray(array $row): array + { + $ts = self::releasedateToTimestamp($row['releasedate'] ?? null); + + return [ + 'title' => (string) ($row['title'] ?? ''), + 'platform' => (string) ($row['platform'] ?? ''), + 'genres_id' => (int) ($row['genres_id'] ?? 0), + 'releasedate_ts' => $ts, + 'cover' => (int) ($row['cover'] ?? 0), + ]; + } + + public static function console(ConsoleInfo $c): array + { + return self::consoleFromArray($c->getAttributes()); + } + + /** + * @param array $row + * @return array + */ + public static function steamFromArray(array $row): array + { + return [ + 'name' => (string) ($row['name'] ?? ''), + 'appid' => (int) ($row['appid'] ?? 0), + ]; + } + + public static function steam(SteamApp $s): array + { + return self::steamFromArray($s->getAttributes()); + } + + /** + * @return array + */ + public static function animeTitle(AnidbTitle $t, ?AnidbInfo $info = null): array + { + $info ??= AnidbInfo::query()->where('anidbid', $t->anidbid)->first(); + + return [ + 'title' => (string) $t->title, + 'anidbid' => (int) $t->anidbid, + 'anilist_id' => (int) ($info?->anilist_id ?? 0), + 'mal_id' => (int) ($info?->mal_id ?? 0), + 'lang' => (string) $t->lang, + 'title_type' => (string) $t->type, + 'media_type' => (string) ($info?->media_type ?? ''), + 'status' => (string) ($info?->status ?? ''), + ]; + } + + private static function releasedateToTimestamp(mixed $rd): int + { + if ($rd instanceof Carbon) { + return $rd->getTimestamp(); + } + if ($rd !== null && $rd !== '') { + try { + return Carbon::parse((string) $rd)->getTimestamp(); + } catch (\Throwable) { + return 0; + } + } + + return 0; + } +} diff --git a/composer.json b/composer.json index 059f3277d..a1fbd319f 100644 --- a/composer.json +++ b/composer.json @@ -59,7 +59,6 @@ "laravel/horizon": "^5.45", "laravel/pulse": "^1.7", "laravel/sanctum": "^4.0", - "laravel/scout": "^11.0", "laravel/tinker": "^3.0", "livewire/blaze": "^1.0", "livewire/livewire": "^3.6", diff --git a/composer.lock b/composer.lock index 1f50d5193..c9a005c54 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8d88899eb120dbdea98d2592c70d67eb", + "content-hash": "8494c99a0997b177f5d9e848ca7c3f66", "packages": [ { "name": "aharen/omdbapi", @@ -2700,86 +2700,6 @@ }, "time": "2026-02-07T17:19:31+00:00" }, - { - "name": "laravel/scout", - "version": "v11.1.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/scout.git", - "reference": "9865fca4129d79c271da6a89afb44359e9ee9020" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/scout/zipball/9865fca4129d79c271da6a89afb44359e9ee9020", - "reference": "9865fca4129d79c271da6a89afb44359e9ee9020", - "shasum": "" - }, - "require": { - "illuminate/bus": "^9.0|^10.0|^11.0|^12.0|^13.0", - "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0|^13.0", - "illuminate/database": "^9.0|^10.0|^11.0|^12.0|^13.0", - "illuminate/http": "^9.0|^10.0|^11.0|^12.0|^13.0", - "illuminate/pagination": "^9.0|^10.0|^11.0|^12.0|^13.0", - "illuminate/queue": "^9.0|^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", - "php": "^8.0", - "symfony/console": "^6.0|^7.0|^8.0" - }, - "conflict": { - "algolia/algoliasearch-client-php": "<3.2.0|>=5.0.0" - }, - "require-dev": { - "algolia/algoliasearch-client-php": "^3.2|^4.0", - "meilisearch/meilisearch-php": "^1.0", - "mockery/mockery": "^1.0", - "orchestra/testbench": "^7.31|^8.36|^9.15|^10.8|^11.0", - "php-http/guzzle7-adapter": "^1.0", - "phpstan/phpstan": "^1.10", - "typesense/typesense-php": "^4.9.3" - }, - "suggest": { - "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", - "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).", - "typesense/typesense-php": "Required to use the Typesense engine (^4.9)." - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Scout\\ScoutServiceProvider" - ] - }, - "branch-alias": { - "dev-master": "11.x-dev" - } - }, - "autoload": { - "psr-4": { - "Laravel\\Scout\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Laravel Scout provides a driver based solution to searching your Eloquent models.", - "keywords": [ - "algolia", - "laravel", - "search" - ], - "support": { - "issues": "https://github.com/laravel/scout/issues", - "source": "https://github.com/laravel/scout" - }, - "time": "2026-03-18T14:50:59+00:00" - }, { "name": "laravel/sentinel", "version": "v1.1.0", diff --git a/config/scout.php b/config/scout.php deleted file mode 100644 index 33035e1ba..000000000 --- a/config/scout.php +++ /dev/null @@ -1,142 +0,0 @@ - env('SCOUT_DRIVER', 'algolia'), - - /* - |-------------------------------------------------------------------------- - | Index Prefix - |-------------------------------------------------------------------------- - | - | Here you may specify a prefix that will be applied to all search index - | names used by Scout. This prefix may be useful if you have multiple - | "tenants" or applications sharing the same search infrastructure. - | - */ - - 'prefix' => env('SCOUT_PREFIX', ''), - - /* - |-------------------------------------------------------------------------- - | Queue Data Syncing - |-------------------------------------------------------------------------- - | - | This option allows you to control if the operations that sync your data - | with your search engines are queued. When this is set to "true" then - | all automatic data syncing will get queued for better performance. - | - */ - - 'queue' => env('SCOUT_QUEUE', false), - - /* - |-------------------------------------------------------------------------- - | Database Transactions - |-------------------------------------------------------------------------- - | - | This configuration option determines if your data will only be synced - | with your search indexes after every open database transaction has - | been committed, thus preventing any discarded data from syncing. - | - */ - - 'after_commit' => false, - - /* - |-------------------------------------------------------------------------- - | Chunk Sizes - |-------------------------------------------------------------------------- - | - | These options allow you to control the maximum chunk size when you are - | mass importing data into the search engine. This allows you to fine - | tune each of these chunk sizes based on the power of the servers. - | - */ - - 'chunk' => [ - 'searchable' => 500, - 'unsearchable' => 500, - ], - - /* - |-------------------------------------------------------------------------- - | Soft Deletes - |-------------------------------------------------------------------------- - | - | This option allows to control whether to keep soft deleted records in - | the search indexes. Maintaining soft deleted records can be useful - | if your application still needs to search for the records later. - | - */ - - 'soft_delete' => false, - - /* - |-------------------------------------------------------------------------- - | Identify User - |-------------------------------------------------------------------------- - | - | This option allows you to control whether to notify the search engine - | of the user performing the search. This is sometimes useful if the - | engine supports any analytics based on this application's users. - | - | Supported engines: "algolia" - | - */ - - 'identify' => env('SCOUT_IDENTIFY', false), - - /* - |-------------------------------------------------------------------------- - | Algolia Configuration - |-------------------------------------------------------------------------- - | - | Here you may configure your Algolia settings. Algolia is a cloud hosted - | search engine which works great with Scout out of the box. Just plug - | in your application ID and admin API key to get started searching. - | - */ - - 'algolia' => [ - 'id' => env('ALGOLIA_APP_ID', ''), - 'secret' => env('ALGOLIA_SECRET', ''), - ], - - /* - |-------------------------------------------------------------------------- - | MeiliSearch Configuration - |-------------------------------------------------------------------------- - | - | Here you may configure your MeiliSearch settings. MeiliSearch is an open - | source search engine with minimal configuration. Below, you can state - | the host and key information for your own MeiliSearch installation. - | - | See: https://docs.meilisearch.com/guides/advanced_guides/configuration.html - | - */ - - 'meilisearch' => [ - 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), - 'key' => env('MEILISEARCH_KEY', null), - 'index-settings' => [ - // 'users' => [ - // 'filterableAttributes'=> ['id', 'name', 'email'], - // ], - ], - ], - -]; diff --git a/config/search.php b/config/search.php index 6ca330c43..427e10bef 100644 --- a/config/search.php +++ b/config/search.php @@ -34,6 +34,12 @@ return [ 'predb' => env('MANTICORESEARCH_INDEX_PREDB', 'predb_rt'), 'movies' => env('MANTICORESEARCH_INDEX_MOVIES', 'movies_rt'), 'tvshows' => env('MANTICORESEARCH_INDEX_TVSHOWS', 'tvshows_rt'), + 'music' => env('MANTICORESEARCH_INDEX_MUSIC', 'music_rt'), + 'books' => env('MANTICORESEARCH_INDEX_BOOKS', 'books_rt'), + 'games' => env('MANTICORESEARCH_INDEX_GAMES', 'games_rt'), + 'console' => env('MANTICORESEARCH_INDEX_CONSOLE', 'console_rt'), + 'steam' => env('MANTICORESEARCH_INDEX_STEAM', 'steam_rt'), + 'anime' => env('MANTICORESEARCH_INDEX_ANIME', 'anime_rt'), ], 'max_matches' => env('MANTICORESEARCH_MAX_MATCHES', 10000), 'cache_minutes' => env('MANTICORESEARCH_CACHE_MINUTES', 5), @@ -73,6 +79,12 @@ return [ 'predb' => 'predb', 'movies' => 'movies', 'tvshows' => 'tvshows', + 'music' => 'music', + 'books' => 'books', + 'games' => 'games', + 'console' => 'console', + 'steam' => 'steam', + 'anime' => 'anime', ], 'cache_minutes' => env('ELASTICSEARCH_CACHE_MINUTES', 5), 'autocomplete' => [ diff --git a/resources/views/api/apidesc.blade.php b/resources/views/api/apidesc.blade.php index 45297869f..547ececa8 100644 --- a/resources/views/api/apidesc.blade.php +++ b/resources/views/api/apidesc.blade.php @@ -27,7 +27,7 @@

Available Functions

-

Use the parameter ?t= to specify the function being called.

+

Use the parameter ?t= to specify the function being called. In addition to search, TV, and movies, this site supports music (t=music or t=audio), books (t=book or t=b), and anime (t=anime). See ?t=caps for capability flags.

@@ -149,6 +149,90 @@ @endauth + + + + + + + + + + + + + + + diff --git a/resources/views/api/apiv2desc.blade.php b/resources/views/api/apiv2desc.blade.php index 9e53961c8..8183744f5 100644 --- a/resources/views/api/apiv2desc.blade.php +++ b/resources/views/api/apiv2desc.blade.php @@ -29,6 +29,9 @@

Available Functions

+

+ Beyond search, tv, and movies, JSON endpoints include audio (music), books, and anime. The capabilities response lists audio-search, book-search, and anime-search. +

Music / Audio + + Returns NZBs in music categories matching artist/title text (q). + Same as general search, you may filter by category, age, size, and group. + +
+ ALIASES +
+ t=music or t=audio +
+
+
+ @auth + + + ?t=music&q=…&cat={{ $catClass::MUSIC_ROOT }} + + @else + + ?t=music&q=<artist or title>&cat={{ $catClass::MUSIC_ROOT }} + + @endauth +
Books + + Returns NZBs in book categories matching author/title text (q). + Optional filters: category, maxage, minsize, group. + +
+ ALIASES +
+ t=book or t=b +
+
+
+ @auth + + + ?t=book&q=…&cat={{ $catClass::BOOKS_ROOT }} + + @else + + ?t=book&q=<author or title>&cat={{ $catClass::BOOKS_ROOT }} + + @endauth +
Anime + + Returns NZBs in anime categories. Provide a title search (q), internal anidbid, and/or anilistid (at least one required). + Optional: cat, maxage. + + +
+ @auth + + + ?t=anime&q=attack on titan + + + + ?t=anime&anilistid=21 + + @else + + ?t=anime&q=<title> + + + ?t=anime&anidbid=<id> or ?t=anime&anilistid=<id> + + @endauth +
+
Details Returns detailed information about an NZB.
@@ -165,6 +168,77 @@ + + + + + + + + + + + + + + + diff --git a/routes/api.php b/routes/api.php index 0d732c29b..5f3eca3e6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -26,6 +26,9 @@ Route::prefix('v2')->group(function () { Route::prefix('v2')->middleware('apiRateLimit')->group(function () { Route::get('movies', [ApiV2Controller::class, 'movie']); + Route::get('audio', [ApiV2Controller::class, 'audio']); + Route::get('books', [ApiV2Controller::class, 'books']); + Route::get('anime', [ApiV2Controller::class, 'anime']); Route::get('search', [ApiV2Controller::class, 'apiSearch']); Route::get('tv', [ApiV2Controller::class, 'tv']); Route::get('getnzb', [ApiV2Controller::class, 'getNzb']);
Audio (music) + + Music search: pass the text query as id (same pattern as search). + Optional: cat, maxage, minsize, group, offset/limit. + + + @auth + + + audio?id=…&cat={{ $catClass::MUSIC_ROOT }} + + @else + + audio?id=<artist or title>&cat={{ $catClass::MUSIC_ROOT }} + + @endauth +
Books + + Book search: query text in id. Optional cat, maxage, minsize, group. + + + @auth + + + books?id=…&cat={{ $catClass::BOOKS_ROOT }} + + @else + + books?id=<author or title>&cat={{ $catClass::BOOKS_ROOT }} + + @endauth +
Anime + + Provide at least one of: id (title search), anidbid, or anilistid. + Optional: cat, maxage. + + +
+ @auth + + + anime?id=attack on titan + + + + anime?anilistid=21 + + @else + + anime?id=<title> + + + anime?anidbid=<id> or anime?anilistid=<id> + + @endauth +
+
Details Returns detailed information about an NZB.