From 93a20beb14279f4adb5319b07e1a423d678186ba Mon Sep 17 00:00:00 2001 From: DariusIII Date: Wed, 31 Dec 2025 22:21:47 +0100 Subject: [PATCH] Add movies and tvshows indexes for faster searches --- .../Commands/CreateManticoreIndexes.php | 46 +- app/Console/Commands/CreateMediaIndexes.php | 317 +++++++++ .../Commands/NntmuxPopulateSearchIndexes.php | 346 +++++++++- app/Console/Commands/NntmuxResetDb.php | 83 ++- app/Http/Controllers/SearchController.php | 4 +- app/Models/Category.php | 11 +- app/Observers/MovieInfoObserver.php | 69 ++ app/Observers/ReleaseObserver.php | 133 ++++ app/Observers/VideoObserver.php | 68 ++ app/Providers/AppServiceProvider.php | 9 + app/Providers/SearchServiceProvider.php | 7 + .../Releases/ReleaseSearchService.php | 221 ++++-- .../Contracts/SearchServiceInterface.php | 116 ++++ .../Search/Drivers/ElasticSearchDriver.php | 653 ++++++++++++++++++ .../Search/Drivers/ManticoreSearchDriver.php | 564 ++++++++++++++- app/Services/Search/MediaSearchService.php | 305 ++++++++ app/Services/Search/SearchService.php | 128 ++++ .../Providers/AbstractTvProvider.php | 15 +- config/manticoresearch.php | 2 + config/search.php | 4 + 20 files changed, 3013 insertions(+), 88 deletions(-) create mode 100644 app/Console/Commands/CreateMediaIndexes.php create mode 100644 app/Observers/MovieInfoObserver.php create mode 100644 app/Observers/ReleaseObserver.php create mode 100644 app/Observers/VideoObserver.php create mode 100644 app/Services/Search/MediaSearchService.php diff --git a/app/Console/Commands/CreateManticoreIndexes.php b/app/Console/Commands/CreateManticoreIndexes.php index 5cf2b8b82..c73cec3f2 100644 --- a/app/Console/Commands/CreateManticoreIndexes.php +++ b/app/Console/Commands/CreateManticoreIndexes.php @@ -73,7 +73,16 @@ class CreateManticoreIndexes extends Command 'searchname' => ['type' => 'text'], 'fromname' => ['type' => 'text'], 'filename' => ['type' => 'text'], - 'categories_id' => ['type' => 'text'], + 'categories_id' => ['type' => 'integer'], + // External media IDs for efficient searching + 'imdbid' => ['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'], ], ], 'predb_rt' => [ @@ -87,6 +96,41 @@ class CreateManticoreIndexes extends Command 'source' => ['type' => 'string', 'attribute' => true], ], ], + 'movies_rt' => [ + 'settings' => [ + 'min_prefix_len' => 0, + 'min_infix_len' => 2, + ], + 'columns' => [ + 'imdbid' => ['type' => 'integer'], + 'tmdbid' => ['type' => 'integer'], + 'traktid' => ['type' => 'integer'], + 'title' => ['type' => 'text'], + 'year' => ['type' => 'text'], + 'genre' => ['type' => 'text'], + 'actors' => ['type' => 'text'], + 'director' => ['type' => 'text'], + 'rating' => ['type' => 'text'], + 'plot' => ['type' => 'text'], + ], + ], + 'tvshows_rt' => [ + 'settings' => [ + 'min_prefix_len' => 0, + 'min_infix_len' => 2, + ], + 'columns' => [ + 'title' => ['type' => 'text'], + 'tvdb' => ['type' => 'integer'], + 'trakt' => ['type' => 'integer'], + 'tvmaze' => ['type' => 'integer'], + 'tvrage' => ['type' => 'integer'], + 'imdb' => ['type' => 'integer'], + 'tmdb' => ['type' => 'integer'], + 'started' => ['type' => 'text'], + 'type' => ['type' => 'integer'], + ], + ], ]; $hasErrors = false; diff --git a/app/Console/Commands/CreateMediaIndexes.php b/app/Console/Commands/CreateMediaIndexes.php new file mode 100644 index 000000000..599356f49 --- /dev/null +++ b/app/Console/Commands/CreateMediaIndexes.php @@ -0,0 +1,317 @@ +info("Creating media search indexes for driver: {$driver}"); + + if ($driver === 'manticore') { + return $this->createManticoreIndexes(); + } elseif ($driver === 'elasticsearch') { + return $this->createElasticsearchIndexes(); + } else { + $this->error("Unsupported search driver: {$driver}"); + return Command::FAILURE; + } + } + + /** + * Create Manticore indexes for movies and tvshows. + */ + private function createManticoreIndexes(): int + { + $host = config('search.drivers.manticore.host', '127.0.0.1'); + $port = config('search.drivers.manticore.port', 9308); + + $client = new Client([ + 'host' => $host, + 'port' => $port, + ]); + + // Test connection + try { + $client->nodes()->status(); + $this->info('Connected to ManticoreSearch successfully.'); + } catch (\Exception $e) { + $this->error('Failed to connect to ManticoreSearch: ' . $e->getMessage()); + return Command::FAILURE; + } + + $dropExisting = $this->option('drop'); + + // Define movies and tvshows indexes + $indexes = [ + 'movies_rt' => [ + 'settings' => [ + 'min_prefix_len' => 0, + 'min_infix_len' => 2, + ], + 'columns' => [ + 'imdbid' => ['type' => 'integer'], + 'tmdbid' => ['type' => 'integer'], + 'traktid' => ['type' => 'integer'], + 'title' => ['type' => 'text'], + 'year' => ['type' => 'text'], + 'genre' => ['type' => 'text'], + 'actors' => ['type' => 'text'], + 'director' => ['type' => 'text'], + 'rating' => ['type' => 'text'], + 'plot' => ['type' => 'text'], + ], + ], + 'tvshows_rt' => [ + 'settings' => [ + 'min_prefix_len' => 0, + 'min_infix_len' => 2, + ], + 'columns' => [ + 'title' => ['type' => 'text'], + 'tvdb' => ['type' => 'integer'], + 'trakt' => ['type' => 'integer'], + 'tvmaze' => ['type' => 'integer'], + 'tvrage' => ['type' => 'integer'], + 'imdb' => ['type' => 'integer'], + 'tmdb' => ['type' => 'integer'], + 'started' => ['type' => 'text'], + 'type' => ['type' => 'integer'], + ], + ], + ]; + + $hasErrors = false; + + foreach ($indexes as $indexName => $schema) { + if (!$this->createManticoreIndex($client, $indexName, $schema, $dropExisting)) { + $hasErrors = true; + } + } + + if ($hasErrors) { + $this->error('Some errors occurred during index creation.'); + return Command::FAILURE; + } + + $this->info('Media indexes created successfully!'); + + // Optionally populate the indexes + if ($this->option('populate')) { + $this->info('Populating indexes...'); + $this->call('nntmux:populate', [ + '--manticore' => true, + '--movies' => true, + ]); + $this->call('nntmux:populate', [ + '--manticore' => true, + '--tvshows' => true, + ]); + } + + return Command::SUCCESS; + } + + /** + * Create a single Manticore index. + */ + private function createManticoreIndex(Client $client, string $indexName, array $schema, bool $dropExisting): bool + { + try { + // Check if index exists + $indexExists = false; + try { + $client->table($indexName)->describe(); + $indexExists = true; + } catch (ResponseException $e) { + // Index doesn't exist + } + + if ($indexExists) { + if ($dropExisting) { + $this->warn("Dropping existing index: {$indexName}"); + $client->table($indexName)->drop(true); + } else { + $this->info("Index {$indexName} already exists. Use --drop to recreate."); + return true; + } + } + + // Create the index using SQL + $this->info("Creating index: {$indexName}"); + + // Build column definitions for CREATE TABLE + $columnDefs = []; + foreach ($schema['columns'] as $columnName => $columnDef) { + $type = match ($columnDef['type']) { + 'text' => 'text', + 'string' => 'string', + 'integer' => 'int', + default => $columnDef['type'], + }; + $columnDefs[] = "{$columnName} {$type}"; + } + + $columnsSQL = implode(', ', $columnDefs); + $minInfixLen = $schema['settings']['min_infix_len'] ?? 2; + + $sql = "CREATE TABLE {$indexName} ({$columnsSQL}) min_infix_len='{$minInfixLen}'"; + + $client->sql(['body' => ['query' => $sql]]); + + $this->info("Index {$indexName} created successfully."); + return true; + + } catch (ResponseException $e) { + $this->error("Failed to create index {$indexName}: " . $e->getMessage()); + return false; + } catch (\Throwable $e) { + $this->error("Unexpected error creating index {$indexName}: " . $e->getMessage()); + return false; + } + } + + /** + * Create Elasticsearch indexes for movies and tvshows. + */ + private function createElasticsearchIndexes(): int + { + $this->info('Creating Elasticsearch media indexes...'); + + $dropExisting = $this->option('drop'); + + // Movies index mapping + $moviesMapping = [ + 'mappings' => [ + 'properties' => [ + 'imdbid' => ['type' => 'integer'], + 'tmdbid' => ['type' => 'integer'], + 'traktid' => ['type' => 'integer'], + 'title' => ['type' => 'text', 'analyzer' => 'standard'], + 'year' => ['type' => 'keyword'], + 'genre' => ['type' => 'text'], + 'actors' => ['type' => 'text'], + 'director' => ['type' => 'text'], + 'rating' => ['type' => 'keyword'], + 'plot' => ['type' => 'text'], + ], + ], + 'settings' => [ + 'number_of_shards' => 1, + 'number_of_replicas' => 0, + ], + ]; + + // TV shows index mapping + $tvshowsMapping = [ + 'mappings' => [ + 'properties' => [ + 'title' => ['type' => 'text', 'analyzer' => 'standard'], + 'tvdb' => ['type' => 'integer'], + 'trakt' => ['type' => 'integer'], + 'tvmaze' => ['type' => 'integer'], + 'tvrage' => ['type' => 'integer'], + 'imdb' => ['type' => 'integer'], + 'tmdb' => ['type' => 'integer'], + 'started' => ['type' => 'keyword'], + 'type' => ['type' => 'integer'], + ], + ], + 'settings' => [ + 'number_of_shards' => 1, + 'number_of_replicas' => 0, + ], + ]; + + try { + // Create Elasticsearch client directly + $esConfig = config('search.drivers.elasticsearch'); + $esClient = \Elasticsearch\ClientBuilder::create() + ->setHosts($esConfig['hosts'] ?? [['host' => 'localhost', 'port' => 9200]]) + ->build(); + + // Create movies index + $moviesIndex = config('search.drivers.elasticsearch.indexes.movies', 'movies'); + if ($dropExisting && $esClient->indices()->exists(['index' => $moviesIndex])) { + $this->warn("Dropping existing index: {$moviesIndex}"); + $esClient->indices()->delete(['index' => $moviesIndex]); + } + + if (!$esClient->indices()->exists(['index' => $moviesIndex])) { + $this->info("Creating index: {$moviesIndex}"); + $esClient->indices()->create([ + 'index' => $moviesIndex, + 'body' => $moviesMapping, + ]); + $this->info("Index {$moviesIndex} created successfully."); + } else { + $this->info("Index {$moviesIndex} already exists. Use --drop to recreate."); + } + + // Create tvshows index + $tvshowsIndex = config('search.drivers.elasticsearch.indexes.tvshows', 'tvshows'); + if ($dropExisting && $esClient->indices()->exists(['index' => $tvshowsIndex])) { + $this->warn("Dropping existing index: {$tvshowsIndex}"); + $esClient->indices()->delete(['index' => $tvshowsIndex]); + } + + if (!$esClient->indices()->exists(['index' => $tvshowsIndex])) { + $this->info("Creating index: {$tvshowsIndex}"); + $esClient->indices()->create([ + 'index' => $tvshowsIndex, + 'body' => $tvshowsMapping, + ]); + $this->info("Index {$tvshowsIndex} created successfully."); + } else { + $this->info("Index {$tvshowsIndex} already exists. Use --drop to recreate."); + } + + $this->info('Elasticsearch media indexes created successfully!'); + + // Optionally populate the indexes + if ($this->option('populate')) { + $this->info('Populating indexes...'); + $this->call('nntmux:populate', [ + '--elastic' => true, + '--movies' => true, + ]); + $this->call('nntmux:populate', [ + '--elastic' => true, + '--tvshows' => true, + ]); + } + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $this->error('Failed to create Elasticsearch indexes: ' . $e->getMessage()); + return Command::FAILURE; + } + } +} + diff --git a/app/Console/Commands/NntmuxPopulateSearchIndexes.php b/app/Console/Commands/NntmuxPopulateSearchIndexes.php index bb6f9be74..e503bf581 100644 --- a/app/Console/Commands/NntmuxPopulateSearchIndexes.php +++ b/app/Console/Commands/NntmuxPopulateSearchIndexes.php @@ -3,8 +3,10 @@ namespace App\Console\Commands; use App\Facades\Search; +use App\Models\MovieInfo; use App\Models\Predb; use App\Models\Release; +use App\Models\Video; use Exception; use Illuminate\Console\Command; use Illuminate\Support\Arr; @@ -22,6 +24,8 @@ class NntmuxPopulateSearchIndexes extends Command {--elastic : Use ElasticSearch} {--releases : Populates the releases index} {--predb : Populates the predb index} + {--movies : Populates the movies index} + {--tvshows : Populates the TV shows index} {--count=50000 : Sets the chunk size} {--parallel=4 : Number of parallel processes} {--batch-size=5000 : Batch size for bulk operations} @@ -33,11 +37,11 @@ class NntmuxPopulateSearchIndexes extends Command * * @var string */ - protected $description = 'Populate Manticore/Elasticsearch indexes with either releases or predb'; + protected $description = 'Populate Manticore/Elasticsearch indexes with releases, predb, movies, or tvshows'; private const SUPPORTED_ENGINES = ['manticore', 'elastic']; - private const SUPPORTED_INDEXES = ['releases', 'predb']; + private const SUPPORTED_INDEXES = ['releases', 'predb', 'movies', 'tvshows']; private const GROUP_CONCAT_MAX_LEN = 16384; @@ -169,12 +173,27 @@ class NntmuxPopulateSearchIndexes extends Command $query = Release::query() ->orderByDesc('releases.id') ->leftJoin('release_files', 'releases.id', '=', 'release_files.releases_id') + ->leftJoin('movieinfo', 'releases.movieinfo_id', '=', 'movieinfo.id') + ->leftJoin('videos', 'releases.videos_id', '=', 'videos.id') ->select([ 'releases.id', 'releases.name', 'releases.searchname', 'releases.fromname', 'releases.categories_id', + 'releases.videos_id', + 'releases.movieinfo_id', + // Movie external IDs + 'movieinfo.imdbid', + 'movieinfo.tmdbid', + 'movieinfo.traktid', + // TV show external IDs + 'videos.tvdb', + 'videos.tvmaze', + 'videos.tvrage', + DB::raw('videos.trakt as video_trakt'), + DB::raw('videos.imdb as video_imdb'), + DB::raw('videos.tmdb as video_tmdb'), ]) ->selectRaw('IFNULL(GROUP_CONCAT(release_files.name SEPARATOR " "),"") AS filename') ->groupBy([ @@ -183,6 +202,17 @@ class NntmuxPopulateSearchIndexes extends Command 'releases.searchname', 'releases.fromname', 'releases.categories_id', + 'releases.videos_id', + 'releases.movieinfo_id', + 'movieinfo.imdbid', + 'movieinfo.tmdbid', + 'movieinfo.traktid', + 'videos.tvdb', + 'videos.tvmaze', + 'videos.tvrage', + 'videos.trakt', + 'videos.imdb', + 'videos.tmdb', ]); return $this->processManticoreData( @@ -195,8 +225,18 @@ class NntmuxPopulateSearchIndexes extends Command 'name' => (string) ($item->name ?: ''), 'searchname' => (string) ($item->searchname ?: ''), 'fromname' => (string) ($item->fromname ?: ''), - 'categories_id' => (string) ($item->categories_id ?: '0'), + 'categories_id' => (int) ($item->categories_id ?: 0), 'filename' => (string) ($item->filename ?: ''), + 'videos_id' => (int) ($item->videos_id ?: 0), + 'movieinfo_id' => (int) ($item->movieinfo_id ?: 0), + // Movie external IDs + 'imdbid' => (int) ($item->imdbid ?: 0), + 'tmdbid' => (int) ($item->tmdbid ?: 0), + 'traktid' => (int) ($item->traktid ?: 0), + // TV show external IDs (use video_* for TV shows, fallback to movie IDs) + 'tvdb' => (int) ($item->tvdb ?: 0), + 'tvmaze' => (int) ($item->tvmaze ?: 0), + 'tvrage' => (int) ($item->tvrage ?: 0), ]; } ); @@ -237,6 +277,112 @@ class NntmuxPopulateSearchIndexes extends Command ); } + /** + * Populate ManticoreSearch movies index + */ + private function manticoreMovies(): int + { + $indexName = 'movies_rt'; + + Search::truncateIndex([$indexName]); + + $total = MovieInfo::count(); + if (! $total) { + $this->warn('MovieInfo table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + + $query = MovieInfo::query() + ->select([ + 'id', + 'imdbid', + 'tmdbid', + 'traktid', + 'title', + 'year', + 'genre', + 'actors', + 'director', + 'rating', + 'plot', + ]) + ->orderBy('id'); + + return $this->processManticoreMoviesData( + $indexName, + $total, + $query, + function ($item) { + return [ + 'id' => $item->id, + 'imdbid' => (int) ($item->imdbid ?? 0), + 'tmdbid' => (int) ($item->tmdbid ?? 0), + 'traktid' => (int) ($item->traktid ?? 0), + 'title' => (string) ($item->title ?? ''), + 'year' => (string) ($item->year ?? ''), + 'genre' => (string) ($item->genre ?? ''), + 'actors' => (string) ($item->actors ?? ''), + 'director' => (string) ($item->director ?? ''), + 'rating' => (string) ($item->rating ?? ''), + 'plot' => (string) ($item->plot ?? ''), + ]; + } + ); + } + + /** + * Populate ManticoreSearch TV shows index + */ + private function manticoreTvshows(): int + { + $indexName = 'tvshows_rt'; + + Search::truncateIndex([$indexName]); + + $total = Video::count(); + if (! $total) { + $this->warn('Videos table is empty. Nothing to do.'); + + return Command::SUCCESS; + } + + $query = Video::query() + ->select([ + 'id', + 'title', + 'tvdb', + 'trakt', + 'tvmaze', + 'tvrage', + 'imdb', + 'tmdb', + 'started', + 'type', + ]) + ->orderBy('id'); + + return $this->processManticoreTvShowsData( + $indexName, + $total, + $query, + function ($item) { + return [ + 'id' => $item->id, + 'title' => (string) ($item->title ?? ''), + 'tvdb' => (int) ($item->tvdb ?? 0), + 'trakt' => (int) ($item->trakt ?? 0), + 'tvmaze' => (int) ($item->tvmaze ?? 0), + 'tvrage' => (int) ($item->tvrage ?? 0), + 'imdb' => (int) ($item->imdb ?? 0), + 'tmdb' => (int) ($item->tmdb ?? 0), + 'started' => (string) ($item->started ?? ''), + 'type' => (int) ($item->type ?? 0), + ]; + } + ); + } + /** * Process data for ManticoreSearch with optimizations */ @@ -313,6 +459,156 @@ class NntmuxPopulateSearchIndexes extends Command } } + /** + * Process data for ManticoreSearch movies index + */ + private function processManticoreMoviesData(string $indexName, int $total, $query, callable $transformer): int + { + $chunkSize = $this->getChunkSize(); + $batchSize = $this->getBatchSize(); + + $this->optimizeDatabase(); + + $this->info(sprintf( + "Populating search index '%s' with %s rows using chunks of %s and batch size of %s.", + $indexName, + number_format($total), + number_format($chunkSize), + number_format($batchSize) + )); + + $bar = $this->output->createProgressBar($total); + $bar->setFormat('verbose'); + $bar->start(); + + $processedCount = 0; + $errorCount = 0; + $batchData = []; + + try { + $query->chunk($chunkSize, function ($items) use ($transformer, $bar, &$processedCount, &$errorCount, $batchSize, &$batchData) { + foreach ($items as $item) { + try { + $batchData[] = $transformer($item); + $processedCount++; + + // Process in optimized batch sizes + if (count($batchData) >= $batchSize) { + $this->processMoviesBatch($batchData); + $batchData = []; + } + } catch (Exception $e) { + $errorCount++; + if ($this->output->isVerbose()) { + $this->error("Error processing item {$item->id}: {$e->getMessage()}"); + } + } + $bar->advance(); + } + }); + + // Process remaining items + if (! empty($batchData)) { + $this->processMoviesBatch($batchData); + } + + $bar->finish(); + $this->newLine(); + + if ($errorCount > 0) { + $this->warn("Completed with {$errorCount} errors out of {$processedCount} processed items."); + } else { + $this->info('Movies index population completed successfully!'); + } + + return Command::SUCCESS; + + } catch (Exception $e) { + $bar->finish(); + $this->newLine(); + $this->error("Failed to populate movies index: {$e->getMessage()}"); + + return Command::FAILURE; + } finally { + $this->restoreDatabase(); + } + } + + /** + * Process data for ManticoreSearch TV shows index + */ + private function processManticoreTvShowsData(string $indexName, int $total, $query, callable $transformer): int + { + $chunkSize = $this->getChunkSize(); + $batchSize = $this->getBatchSize(); + + $this->optimizeDatabase(); + + $this->info(sprintf( + "Populating search index '%s' with %s rows using chunks of %s and batch size of %s.", + $indexName, + number_format($total), + number_format($chunkSize), + number_format($batchSize) + )); + + $bar = $this->output->createProgressBar($total); + $bar->setFormat('verbose'); + $bar->start(); + + $processedCount = 0; + $errorCount = 0; + $batchData = []; + + try { + $query->chunk($chunkSize, function ($items) use ($transformer, $bar, &$processedCount, &$errorCount, $batchSize, &$batchData) { + foreach ($items as $item) { + try { + $batchData[] = $transformer($item); + $processedCount++; + + // Process in optimized batch sizes + if (count($batchData) >= $batchSize) { + $this->processTvShowsBatch($batchData); + $batchData = []; + } + } catch (Exception $e) { + $errorCount++; + if ($this->output->isVerbose()) { + $this->error("Error processing item {$item->id}: {$e->getMessage()}"); + } + } + $bar->advance(); + } + }); + + // Process remaining items + if (! empty($batchData)) { + $this->processTvShowsBatch($batchData); + } + + $bar->finish(); + $this->newLine(); + + if ($errorCount > 0) { + $this->warn("Completed with {$errorCount} errors out of {$processedCount} processed items."); + } else { + $this->info('TV shows index population completed successfully!'); + } + + return Command::SUCCESS; + + } catch (Exception $e) { + $bar->finish(); + $this->newLine(); + $this->error("Failed to populate TV shows index: {$e->getMessage()}"); + + return Command::FAILURE; + } finally { + $this->restoreDatabase(); + } + } + /** * Populate ElasticSearch releases index */ @@ -503,6 +799,50 @@ class NntmuxPopulateSearchIndexes extends Command } } + /** + * Process movies batch with retry logic + */ + private function processMoviesBatch(array $data): void + { + $retries = 3; + $attempt = 0; + + while ($attempt < $retries) { + try { + Search::bulkInsertMovies($data); + break; + } catch (Exception $e) { + $attempt++; + if ($attempt >= $retries) { + throw $e; + } + usleep(100000); // 100ms delay before retry + } + } + } + + /** + * Process TV shows batch with retry logic + */ + private function processTvShowsBatch(array $data): void + { + $retries = 3; + $attempt = 0; + + while ($attempt < $retries) { + try { + Search::bulkInsertTvShows($data); + break; + } catch (Exception $e) { + $attempt++; + if ($attempt >= $retries) { + throw $e; + } + usleep(100000); // 100ms delay before retry + } + } + } + /** * Process ElasticSearch batch with retry logic */ diff --git a/app/Console/Commands/NntmuxResetDb.php b/app/Console/Commands/NntmuxResetDb.php index 744a17114..ccaf718d6 100644 --- a/app/Console/Commands/NntmuxResetDb.php +++ b/app/Console/Commands/NntmuxResetDb.php @@ -169,9 +169,90 @@ class NntmuxResetDb extends Command \Elasticsearch::indices()->create($predb_index); + // Delete and recreate movies index + if (\Elasticsearch::indices()->exists(['index' => 'movies'])) { + \Elasticsearch::indices()->delete(['index' => 'movies']); + } + $movies_index = [ + 'index' => 'movies', + 'body' => [ + 'settings' => [ + 'number_of_shards' => 2, + 'number_of_replicas' => 0, + ], + 'mappings' => [ + 'properties' => [ + 'id' => [ + 'type' => 'long', + 'index' => false, + ], + 'imdbid' => ['type' => 'integer'], + 'tmdbid' => ['type' => 'integer'], + 'traktid' => ['type' => 'integer'], + 'title' => [ + 'type' => 'text', + 'fields' => [ + 'sort' => [ + 'type' => 'keyword', + ], + ], + ], + 'year' => ['type' => 'text'], + 'genre' => ['type' => 'text'], + 'actors' => ['type' => 'text'], + 'director' => ['type' => 'text'], + 'rating' => ['type' => 'text'], + 'plot' => ['type' => 'text'], + ], + ], + ], + ]; + + \Elasticsearch::indices()->create($movies_index); + + // Delete and recreate tvshows index + if (\Elasticsearch::indices()->exists(['index' => 'tvshows'])) { + \Elasticsearch::indices()->delete(['index' => 'tvshows']); + } + $tvshows_index = [ + 'index' => 'tvshows', + 'body' => [ + 'settings' => [ + 'number_of_shards' => 2, + 'number_of_replicas' => 0, + ], + 'mappings' => [ + 'properties' => [ + 'id' => [ + 'type' => 'long', + 'index' => false, + ], + 'title' => [ + 'type' => 'text', + 'fields' => [ + 'sort' => [ + 'type' => 'keyword', + ], + ], + ], + 'tvdb' => ['type' => 'integer'], + 'trakt' => ['type' => 'integer'], + 'tvmaze' => ['type' => 'integer'], + 'tvrage' => ['type' => 'integer'], + 'imdb' => ['type' => 'integer'], + 'tmdb' => ['type' => 'integer'], + 'started' => ['type' => 'text'], + 'type' => ['type' => 'integer'], + ], + ], + ], + ]; + + \Elasticsearch::indices()->create($tvshows_index); + $this->info('All done! ElasticSearch indexes are deleted and recreated.'); } else { - Search::truncateIndex(['releases_rt', 'predb_rt']); + Search::truncateIndex(['releases_rt', 'predb_rt', 'movies_rt', 'tvshows_rt']); } $this->info('Deleting nzbfiles subfolders.'); diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 6e4f4924f..fb9b634f3 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -69,9 +69,9 @@ class SearchController extends BasePageController $searchString['searchname'] = ''; } - $categoryID[] = -1; + $categoryID = [-1]; if ($request->has('t')) { - $categoryID = explode(',', $request->input('t')); + $categoryID = array_map('intval', explode(',', $request->input('t'))); } $orderByUrls = []; diff --git a/app/Models/Category.php b/app/Models/Category.php index 280def4d1..834794c42 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -321,7 +321,7 @@ class Category extends Model } // If multiple categories were sent in a single array position, slice and add them - if (str_contains($cat[0], ',')) { + if (isset($cat[0]) && is_string($cat[0]) && str_contains($cat[0], ',')) { $tmpcats = explode(',', $cat[0]); // Reset the category to the first comma separated value in the string $cat[0] = $tmpcats[0]; @@ -332,11 +332,12 @@ class Category extends Model } foreach ($cat as $category) { - if (is_numeric($category) && $category !== -1 && self::isParent($category)) { - $children = RootCategory::find($category)->categories->pluck('id')->toArray(); + $categoryInt = (int) $category; + if (is_numeric($category) && $categoryInt !== -1 && self::isParent($categoryInt)) { + $children = RootCategory::find($categoryInt)->categories->pluck('id')->toArray(); $categories = array_merge($categories, $children); - } elseif (is_numeric($category) && $category > 0) { - $categories[] = $category; + } elseif (is_numeric($category) && $categoryInt > 0) { + $categories[] = $categoryInt; } } diff --git a/app/Observers/MovieInfoObserver.php b/app/Observers/MovieInfoObserver.php new file mode 100644 index 000000000..f24aafd2f --- /dev/null +++ b/app/Observers/MovieInfoObserver.php @@ -0,0 +1,69 @@ +syncToSearchIndex($movie); + } + + /** + * Handle the MovieInfo "updated" event. + */ + public function updated(MovieInfo $movie): void + { + $this->syncToSearchIndex($movie); + } + + /** + * Handle the MovieInfo "deleted" event. + */ + public function deleted(MovieInfo $movie): void + { + try { + Search::deleteMovie($movie->id); + } catch (\Throwable $e) { + Log::error('MovieInfoObserver: Failed to delete movie from search index', [ + 'movie_id' => $movie->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Sync the movie to the search index. + */ + private function syncToSearchIndex(MovieInfo $movie): void + { + try { + Search::insertMovie([ + 'id' => $movie->id, + 'imdbid' => $movie->imdbid ?? 0, + 'tmdbid' => $movie->tmdbid ?? 0, + 'traktid' => $movie->traktid ?? 0, + 'title' => $movie->title ?? '', + 'year' => $movie->year ?? '', + 'genre' => $movie->genre ?? '', + 'actors' => $movie->actors ?? '', + 'director' => $movie->director ?? '', + 'rating' => $movie->rating ?? '', + 'plot' => $movie->plot ?? '', + ]); + } catch (\Throwable $e) { + Log::error('MovieInfoObserver: Failed to sync movie to search index', [ + 'movie_id' => $movie->id, + 'error' => $e->getMessage(), + ]); + } + } +} + diff --git a/app/Observers/ReleaseObserver.php b/app/Observers/ReleaseObserver.php new file mode 100644 index 000000000..8d82e724d --- /dev/null +++ b/app/Observers/ReleaseObserver.php @@ -0,0 +1,133 @@ +syncToSearchIndex($release); + } + + /** + * Handle the Release "updated" event. + * + * When a release is updated (e.g., during post-processing with movie/TV data), + * update the search index with the new external IDs. + */ + public function updated(Release $release): void + { + // Check if any of the external ID fields were changed + $externalIdFields = [ + 'imdbid', + 'movieinfo_id', + 'videos_id', + 'tv_episodes_id', + 'anidbid', + 'searchname', + 'name', + 'fromname', + 'categories_id', + ]; + + $changed = false; + foreach ($externalIdFields as $field) { + if ($release->isDirty($field)) { + $changed = true; + break; + } + } + + if ($changed) { + $this->syncToSearchIndex($release); + } + } + + /** + * Handle the Release "deleted" event. + */ + public function deleted(Release $release): void + { + try { + Search::deleteRelease($release->id); + } catch (\Throwable $e) { + Log::error('ReleaseObserver: Failed to delete release from search index', [ + 'release_id' => $release->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Sync the release to the search index with all external IDs. + */ + private function syncToSearchIndex(Release $release): void + { + try { + // Load related models if needed + $movieInfo = null; + $video = null; + + if ($release->movieinfo_id > 0) { + $movieInfo = MovieInfo::find($release->movieinfo_id); + } + + if ($release->videos_id > 0) { + $video = Video::find($release->videos_id); + } + + $parameters = [ + 'id' => $release->id, + 'name' => $release->name ?? '', + 'searchname' => $release->searchname ?? '', + 'fromname' => $release->fromname ?? '', + 'categories_id' => $release->categories_id ?? 0, + 'filename' => '', // Not available from model + // Movie external IDs + 'imdbid' => $release->imdbid ?? ($movieInfo?->imdbid ?? 0), + 'tmdbid' => $movieInfo?->tmdbid ?? 0, + 'traktid' => $movieInfo?->traktid ?? 0, + // TV show external IDs + 'tvdb' => $video?->tvdb ?? 0, + 'tvmaze' => $video?->tvmaze ?? 0, + 'tvrage' => $video?->tvrage ?? 0, + 'videos_id' => $release->videos_id ?? 0, + 'movieinfo_id' => $release->movieinfo_id ?? 0, + ]; + + Search::insertRelease($parameters); + + if (config('app.debug')) { + Log::debug('ReleaseObserver: Updated search index for release', [ + 'release_id' => $release->id, + 'imdbid' => $parameters['imdbid'], + 'videos_id' => $parameters['videos_id'], + ]); + } + } catch (\Throwable $e) { + Log::error('ReleaseObserver: Failed to sync release to search index', [ + 'release_id' => $release->id, + 'error' => $e->getMessage(), + ]); + } + } +} + diff --git a/app/Observers/VideoObserver.php b/app/Observers/VideoObserver.php new file mode 100644 index 000000000..c6d79ed33 --- /dev/null +++ b/app/Observers/VideoObserver.php @@ -0,0 +1,68 @@ +syncToSearchIndex($video); + } + + /** + * Handle the Video "updated" event. + */ + public function updated(Video $video): void + { + $this->syncToSearchIndex($video); + } + + /** + * Handle the Video "deleted" event. + */ + public function deleted(Video $video): void + { + try { + Search::deleteTvShow($video->id); + } catch (\Throwable $e) { + Log::error('VideoObserver: Failed to delete TV show from search index', [ + 'video_id' => $video->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Sync the TV show to the search index. + */ + private function syncToSearchIndex(Video $video): void + { + try { + Search::insertTvShow([ + 'id' => $video->id, + 'title' => $video->title ?? '', + 'tvdb' => $video->tvdb ?? 0, + 'trakt' => $video->trakt ?? 0, + 'tvmaze' => $video->tvmaze ?? 0, + 'tvrage' => $video->tvrage ?? 0, + 'imdb' => $video->imdb ?? 0, + 'tmdb' => $video->tmdb ?? 0, + 'started' => $video->started ?? '', + 'type' => $video->type ?? 0, + ]); + } catch (\Throwable $e) { + Log::error('VideoObserver: Failed to sync TV show to search index', [ + 'video_id' => $video->id, + 'error' => $e->getMessage(), + ]); + } + } +} + diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 43ddf33f2..acc82ae33 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,15 @@ namespace App\Providers; +use App\Models\MovieInfo; +use App\Models\Release; use App\Models\RolePromotion; use App\Models\User; +use App\Models\Video; +use App\Observers\MovieInfoObserver; +use App\Observers\ReleaseObserver; use App\Observers\RolePromotionObserver; +use App\Observers\VideoObserver; use Illuminate\Auth\Events\Login; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Event; @@ -31,6 +37,9 @@ class AppServiceProvider extends ServiceProvider // Register observers RolePromotion::observe(RolePromotionObserver::class); + MovieInfo::observe(MovieInfoObserver::class); + Video::observe(VideoObserver::class); + Release::observe(ReleaseObserver::class); } /** diff --git a/app/Providers/SearchServiceProvider.php b/app/Providers/SearchServiceProvider.php index 0084b2bee..caf753250 100644 --- a/app/Providers/SearchServiceProvider.php +++ b/app/Providers/SearchServiceProvider.php @@ -6,6 +6,7 @@ use App\Services\Search\Contracts\SearchDriverInterface; use App\Services\Search\Contracts\SearchServiceInterface; use App\Services\Search\Drivers\ElasticSearchDriver; use App\Services\Search\Drivers\ManticoreSearchDriver; +use App\Services\Search\MediaSearchService; use App\Services\Search\SearchService; use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; @@ -38,6 +39,11 @@ class SearchServiceProvider extends ServiceProvider return $app->make(SearchService::class)->driver(); }); + // Register the MediaSearchService for optimized movie/TV searches + $this->app->singleton(MediaSearchService::class, function (Application $app) { + return new MediaSearchService(); + }); + // Register individual drivers as singletons for direct access $this->app->singleton(ManticoreSearchDriver::class, function (Application $app) { $config = config('search.drivers.manticore', []); @@ -55,6 +61,7 @@ class SearchServiceProvider extends ServiceProvider $this->app->alias(SearchService::class, 'search'); $this->app->alias(ManticoreSearchDriver::class, 'search.manticore'); $this->app->alias(ElasticSearchDriver::class, 'search.elasticsearch'); + $this->app->alias(MediaSearchService::class, 'search.media'); } /** diff --git a/app/Services/Releases/ReleaseSearchService.php b/app/Services/Releases/ReleaseSearchService.php index 3b60add91..0c3656991 100644 --- a/app/Services/Releases/ReleaseSearchService.php +++ b/app/Services/Releases/ReleaseSearchService.php @@ -269,8 +269,45 @@ class ReleaseSearchService $videoJoinCondition = ''; $episodeJoinCondition = ''; $needsEpisodeJoin = false; + $needsDatabaseLookup = false; + // OPTIMIZATION: Try to find releases using search index external IDs first + $externalIds = []; if (! empty($siteIdArr)) { + foreach ($siteIdArr as $column => $id) { + if ($id > 0 && $column !== 'id') { + // Map column names to search index field names + $fieldName = match ($column) { + 'tvdb' => 'tvdb', + 'trakt' => 'traktid', // Note: in releases index we use traktid + 'tvmaze' => 'tvmaze', + 'tvrage' => 'tvrage', + 'imdb' => 'imdbid', + 'tmdb' => 'tmdbid', + default => null, + }; + if ($fieldName) { + $externalIds[$fieldName] = (int) $id; + } + } + } + } + + // Try to get releases directly from search index using external IDs + $searchResult = []; + if (! empty($externalIds)) { + $searchResult = Search::searchReleasesByExternalId($externalIds, $limit * 2); + + if (config('app.debug') && ! empty($searchResult)) { + Log::debug('tvSearch: Found releases via search index by external IDs', [ + 'externalIds' => $externalIds, + 'count' => count($searchResult), + ]); + } + } + + // If search index didn't return results, fall back to database lookup + if (empty($searchResult) && ! empty($siteIdArr)) { $siteConditions = []; foreach ($siteIdArr as $column => $id) { if ($id > 0) { @@ -279,6 +316,7 @@ class ReleaseSearchService } if (! empty($siteConditions)) { + $needsDatabaseLookup = true; $siteUsesVideoIdOnly = count($siteConditions) === 1 && isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0; $seriesFilter = ($series !== '') ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''; @@ -319,8 +357,14 @@ class ReleaseSearchService } } - $searchResult = []; - if (! empty($name)) { + // If search index found releases via external IDs, add them to conditions + $hasSearchResultFromExternalIds = ! empty($searchResult); + if ($hasSearchResultFromExternalIds) { + $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))); + } + + // Only do name-based search if we don't already have results from external IDs + if (! $hasSearchResultFromExternalIds && ! empty($name)) { $searchName = $name; $hasValidSiteIds = false; foreach ($siteIdArr as $column => $id) { @@ -474,39 +518,74 @@ class ReleaseSearchService */ public function apiTvSearch(array $siteIdArr = [], string $series = '', string $episode = '', string $airDate = '', int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, int $minSize = 0, array $excludedCategories = []): mixed { - $siteSQL = []; - $showSql = ''; + // OPTIMIZATION: Try to find releases using search index external IDs first + $externalIds = []; foreach ($siteIdArr as $column => $Id) { - if ($Id > 0) { - $siteSQL[] = sprintf('v.%s = %d', $column, $Id); + if ($Id > 0 && $column !== 'id') { + $fieldName = match ($column) { + 'tvdb' => 'tvdb', + 'trakt' => 'traktid', + 'tvmaze' => 'tvmaze', + 'tvrage' => 'tvrage', + 'imdb' => 'imdbid', + 'tmdb' => 'tmdbid', + default => null, + }; + if ($fieldName) { + $externalIds[$fieldName] = (int) $Id; + } } } - if (\count($siteSQL) > 0) { - $showQry = sprintf( - "\n\t\t\t\tSELECT v.id AS video, GROUP_CONCAT(tve.id SEPARATOR ',') AS episodes FROM videos v LEFT JOIN tv_episodes tve ON v.id = tve.videos_id WHERE (%s) %s %s %s GROUP BY v.id LIMIT 1", - implode(' OR ', $siteSQL), - ($series !== '' ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''), - ($episode !== '' ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''), - ($airDate !== '' ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '') - ); - $show = Release::fromQuery($showQry); - if ($show->isNotEmpty()) { - if ((! empty($episode) && ! empty($series)) && $show[0]->episodes !== '') { - $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series; - } elseif (! empty($episode) && $show[0]->episodes !== '') { - $showSql = sprintf('AND r.tv_episodes_id IN (%s)', $show[0]->episodes); - } elseif (! empty($series) && empty($episode)) { - $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series; - } - if ($show[0]->video > 0) { - $showSql .= ' AND r.videos_id = '.$show[0]->video; - } - } else { - return []; + // Try to get releases directly from search index using external IDs + $indexSearchResult = []; + if (! empty($externalIds)) { + $indexSearchResult = Search::searchReleasesByExternalId($externalIds, $limit * 2); + + if (config('app.debug') && ! empty($indexSearchResult)) { + Log::debug('apiTvSearch: Found releases via search index by external IDs', [ + 'externalIds' => $externalIds, + 'count' => count($indexSearchResult), + ]); } } - if (! empty($name) && $showSql === '') { + + // Fall back to database lookup if index search didn't return results + $siteSQL = []; + $showSql = ''; + if (empty($indexSearchResult)) { + foreach ($siteIdArr as $column => $Id) { + if ($Id > 0) { + $siteSQL[] = sprintf('v.%s = %d', $column, $Id); + } + } + + if (\count($siteSQL) > 0) { + $showQry = sprintf( + "\n\t\t\t\tSELECT v.id AS video, GROUP_CONCAT(tve.id SEPARATOR ',') AS episodes FROM videos v LEFT JOIN tv_episodes tve ON v.id = tve.videos_id WHERE (%s) %s %s %s GROUP BY v.id LIMIT 1", + implode(' OR ', $siteSQL), + ($series !== '' ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''), + ($episode !== '' ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''), + ($airDate !== '' ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '') + ); + $show = Release::fromQuery($showQry); + if ($show->isNotEmpty()) { + if ((! empty($episode) && ! empty($series)) && $show[0]->episodes !== '') { + $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series; + } elseif (! empty($episode) && $show[0]->episodes !== '') { + $showSql = sprintf('AND r.tv_episodes_id IN (%s)', $show[0]->episodes); + } elseif (! empty($series) && empty($episode)) { + $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series; + } + if ($show[0]->video > 0) { + $showSql .= ' AND r.videos_id = '.$show[0]->video; + } + } else { + return []; + } + } + } + if (! empty($name) && $showSql === '' && empty($indexSearchResult)) { if (! empty($series) && (int) $series < 1900) { $name .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT)); if (! empty($episode) && ! str_contains($episode, '/')) { @@ -519,8 +598,8 @@ class ReleaseSearchService $name .= sprintf(' %s', str_replace(['/', '-', '.', '_'], ' ', $airDate)); } } - $searchResult = []; - if (! empty($name)) { + $searchResult = $indexSearchResult; // Use index search result if we have it + if (empty($searchResult) && ! empty($name)) { // Use the unified Search facade with fuzzy fallback $fuzzyResult = Search::searchReleasesWithFuzzy(['searchname' => $name], $limit); $searchResult = $fuzzyResult['ids'] ?? []; @@ -652,7 +731,34 @@ class ReleaseSearchService { // Early return if searching by name yields no results $searchResult = []; - if (! empty($name)) { + + // OPTIMIZATION: If we have external IDs, use the search index to find releases directly + // This avoids expensive database JOINs by using indexed external ID fields in releases_rt + $externalIds = []; + if ($imDbId !== -1 && $imDbId > 0) { + $externalIds['imdbid'] = $imDbId; + } + if ($tmDbId !== -1 && $tmDbId > 0) { + $externalIds['tmdbid'] = $tmDbId; + } + if ($traktId !== -1 && $traktId > 0) { + $externalIds['traktid'] = $traktId; + } + + // Use search index for external ID lookups (much faster than database JOINs) + if (! empty($externalIds)) { + $searchResult = Search::searchReleasesByExternalId($externalIds, $limit * 2); + + if (config('app.debug') && ! empty($searchResult)) { + Log::debug('moviesSearch: Found releases via search index by external IDs', [ + 'externalIds' => $externalIds, + 'count' => count($searchResult), + ]); + } + } + + // If no external IDs provided or index search failed, search by name + if (empty($searchResult) && ! empty($name)) { // Use the unified Search facade with fuzzy fallback $fuzzyResult = Search::searchReleasesWithFuzzy($name, $limit); $searchResult = $fuzzyResult['ids'] ?? []; @@ -667,6 +773,11 @@ class ReleaseSearchService } } + // If we still have no results (no name and no external IDs found anything), return empty + if (empty($searchResult) && empty($externalIds)) { + return collect(); + } + $conditions = [ sprintf('r.categories_id BETWEEN %d AND %d', Category::MOVIE_ROOT, Category::MOVIE_OTHER), sprintf('r.passwordstatus %s', $this->showPasswords()), @@ -676,22 +787,20 @@ class ReleaseSearchService $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))); } - // Optimize: Join on imdbid directly (both tables have indexed imdbid columns) - // This is more efficient than joining through movieinfo_id - // Always join movieinfo to get imdbid, tmdbid, and traktid fields consistently - - if ($imDbId !== -1 && $imDbId) { - // Filter on r.imdbid (uses index ix_releases_imdbid) - // The join on m.imdbid = r.imdbid will also use the index - $conditions[] = sprintf('r.imdbid = %d', $imDbId); - } - - if ($tmDbId !== -1 && $tmDbId) { - $conditions[] = sprintf('m.tmdbid = %d', $tmDbId); - } - - if ($traktId !== -1 && $traktId) { - $conditions[] = sprintf('m.traktid = %d', $traktId); + // When we have external IDs but no index results, fall back to database query + // This handles the case where the index might be empty/out of sync + $needsMovieJoin = false; + if (empty($searchResult) && ! empty($externalIds)) { + $needsMovieJoin = true; + if ($imDbId !== -1 && $imDbId > 0) { + $conditions[] = sprintf('r.imdbid = %d', $imDbId); + } + if ($tmDbId !== -1 && $tmDbId > 0) { + $conditions[] = sprintf('m.tmdbid = %d', $tmDbId); + } + if ($traktId !== -1 && $traktId > 0) { + $conditions[] = sprintf('m.traktid = %d', $traktId); + } } if (! empty($excludedCategories)) { @@ -711,10 +820,10 @@ class ReleaseSearchService } $whereSql = 'WHERE '.implode(' AND ', $conditions); - // Always join on imdbid directly (both tables have indexed imdbid) - more efficient than movieinfo_id - // Both ix_releases_imdbid and ix_movieinfo_imdbid are indexed, making this join very fast - // This ensures we always get imdbid, tmdbid, and traktid fields in results - $joinSql = 'INNER JOIN movieinfo m ON m.imdbid = r.imdbid'; + + // Only join movieinfo if we need to filter by tmdbid/traktid (database fallback) + // When using search index, we already have the release IDs and don't need the join + $joinSql = $needsMovieJoin ? 'INNER JOIN movieinfo m ON m.imdbid = r.imdbid' : 'LEFT JOIN movieinfo m ON m.id = r.movieinfo_id'; // Select only fields required by XML/API transformers $baseSql = sprintf( @@ -745,12 +854,10 @@ class ReleaseSearchService $releases = Release::fromQuery($sql); if ($releases->isNotEmpty()) { - // Optimize: Execute count query using same WHERE clause (uses same indexes) - // The count query is lightweight and can use index-only scans when possible - // Use same join logic as main query (join on imdbid when needed) + // Optimize: Execute count query using same WHERE clause $countSql = sprintf( 'SELECT COUNT(*) as count FROM releases r %s %s', - $joinSql, + $needsMovieJoin ? $joinSql : '', $whereSql ); $countResult = DB::selectOne($countSql); @@ -1009,7 +1116,7 @@ class ReleaseSearchService private function buildSearchBaseSql(string $whereSql): string { return sprintf( - "SELECT r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id, r.size, + "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.jpgstatus, cp.title AS parent_category, c.title AS sub_category, diff --git a/app/Services/Search/Contracts/SearchServiceInterface.php b/app/Services/Search/Contracts/SearchServiceInterface.php index 7d5648e5c..831f532de 100644 --- a/app/Services/Search/Contracts/SearchServiceInterface.php +++ b/app/Services/Search/Contracts/SearchServiceInterface.php @@ -39,6 +39,16 @@ interface SearchServiceInterface */ public function getPredbIndex(): string; + /** + * Get the movies index name. + */ + public function getMoviesIndex(): string; + + /** + * Get the TV shows index name. + */ + public function getTvShowsIndex(): string; + /** * Insert a release into the search index. * @@ -158,5 +168,111 @@ interface SearchServiceInterface * @param int $id Predb ID */ public function deletePreDb(int $id): void; + + /** + * Insert a movie into the movies search index. + * + * @param array $parameters Movie data with id, imdbid, tmdbid, traktid, title, year, genre, actors, director, rating + */ + public function insertMovie(array $parameters): void; + + /** + * Update a movie in the search index. + * + * @param int $movieId Movie ID + */ + public function updateMovie(int $movieId): void; + + /** + * Delete a movie from the search index. + * + * @param int $id Movie ID + */ + public function deleteMovie(int $id): void; + + /** + * Bulk insert multiple movies into the index. + * + * @param array $movies Array of movie data arrays + * @return array Results with 'success' and 'errors' counts + */ + public function bulkInsertMovies(array $movies): array; + + /** + * Search the movies index. + * + * @param array|string $searchTerm Search term(s) + * @param int $limit Maximum number of results + * @return array Array with 'id' (movie IDs) and 'data' (movie data) + */ + public function searchMovies(array|string $searchTerm, int $limit = 1000): array; + + /** + * Search movies by external ID (IMDB, TMDB, Trakt). + * + * @param string $field Field name (imdbid, tmdbid, traktid) + * @param int|string $value The external ID value + * @return array|null Movie data or null if not found + */ + public function searchMovieByExternalId(string $field, int|string $value): ?array; + + /** + * Insert a TV show into the tvshows search index. + * + * @param array $parameters TV show data with id, title, tvdb, trakt, tvmaze, tvrage, imdb, tmdb, started, type + */ + public function insertTvShow(array $parameters): void; + + /** + * Update a TV show in the search index. + * + * @param int $videoId Video/TV show ID + */ + public function updateTvShow(int $videoId): void; + + /** + * Delete a TV show from the search index. + * + * @param int $id TV show ID + */ + public function deleteTvShow(int $id): void; + + /** + * Bulk insert multiple TV shows into the index. + * + * @param array $tvShows Array of TV show data arrays + * @return array Results with 'success' and 'errors' counts + */ + public function bulkInsertTvShows(array $tvShows): array; + + /** + * Search the TV shows index. + * + * @param array|string $searchTerm Search term(s) + * @param int $limit Maximum number of results + * @return array Array with 'id' (TV show IDs) and 'data' (TV show data) + */ + public function searchTvShows(array|string $searchTerm, int $limit = 1000): array; + + /** + * Search TV shows by external ID (TVDB, Trakt, TVMaze, TVRage, IMDB, TMDB). + * + * @param string $field Field name (tvdb, trakt, tvmaze, tvrage, imdb, tmdb) + * @param int|string $value The external ID value + * @return array|null TV show data or null if not found + */ + public function searchTvShowByExternalId(string $field, int|string $value): ?array; + + /** + * Search releases by external media IDs. + * Used to find releases associated with a specific movie or TV show. + * + * @param array $externalIds Associative array of external IDs, e.g., ['imdbid' => 123456, 'tmdbid' => 789] + * @param int $limit Maximum number of results + * @return array Array of release IDs + */ + public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array; } + + diff --git a/app/Services/Search/Drivers/ElasticSearchDriver.php b/app/Services/Search/Drivers/ElasticSearchDriver.php index 95aff18f4..2bb9780e6 100644 --- a/app/Services/Search/Drivers/ElasticSearchDriver.php +++ b/app/Services/Search/Drivers/ElasticSearchDriver.php @@ -2,7 +2,9 @@ namespace App\Services\Search\Drivers; +use App\Models\MovieInfo; use App\Models\Release; +use App\Models\Video; use App\Services\Search\Contracts\SearchDriverInterface; use Elasticsearch\Client; use Elasticsearch\ClientBuilder; @@ -263,6 +265,22 @@ class ElasticSearchDriver implements SearchDriverInterface return $this->config['indexes']['predb'] ?? 'predb'; } + /** + * Get the movies index name. + */ + public function getMoviesIndex(): string + { + return $this->config['indexes']['movies'] ?? 'movies'; + } + + /** + * Get the TV shows index name. + */ + public function getTvShowsIndex(): string + { + return $this->config['indexes']['tvshows'] ?? 'tvshows'; + } + /** * Escape special characters for Elasticsearch. */ @@ -1748,5 +1766,640 @@ class ElasticSearchDriver implements SearchDriverInterface Log::error('ElasticSearch bulk unexpected error: '.$e->getMessage()); } } + + /** + * Insert a movie into the movies search index. + * + * @param array $parameters Movie data + */ + public function insertMovie(array $parameters): void + { + if (empty($parameters['id'])) { + Log::warning('ElasticSearch: Cannot insert movie without ID'); + + return; + } + + try { + $client = $this->getClient(); + + $document = [ + 'id' => $parameters['id'], + 'imdbid' => (int) ($parameters['imdbid'] ?? 0), + 'tmdbid' => (int) ($parameters['tmdbid'] ?? 0), + 'traktid' => (int) ($parameters['traktid'] ?? 0), + 'title' => (string) ($parameters['title'] ?? ''), + 'year' => (string) ($parameters['year'] ?? ''), + 'genre' => (string) ($parameters['genre'] ?? ''), + 'actors' => (string) ($parameters['actors'] ?? ''), + 'director' => (string) ($parameters['director'] ?? ''), + 'rating' => (string) ($parameters['rating'] ?? ''), + 'plot' => (string) ($parameters['plot'] ?? ''), + ]; + + $client->index([ + 'index' => $this->getMoviesIndex(), + 'id' => $parameters['id'], + 'body' => $document, + ]); + + } catch (ElasticsearchException $e) { + Log::error('ElasticSearch insertMovie error: '.$e->getMessage(), [ + 'movie_id' => $parameters['id'], + ]); + } catch (\Throwable $e) { + Log::error('ElasticSearch insertMovie unexpected error: '.$e->getMessage(), [ + 'movie_id' => $parameters['id'], + ]); + } + } + + /** + * Update a movie in the search index. + * + * @param int $movieId Movie ID + */ + public function updateMovie(int $movieId): void + { + if (empty($movieId)) { + Log::warning('ElasticSearch: Cannot update movie without ID'); + + return; + } + + try { + $movie = MovieInfo::find($movieId); + + if ($movie !== null) { + $this->insertMovie($movie->toArray()); + } else { + Log::warning('ElasticSearch: Movie not found for update', ['id' => $movieId]); + } + } catch (\Throwable $e) { + Log::error('ElasticSearch updateMovie error: '.$e->getMessage(), [ + 'movie_id' => $movieId, + ]); + } + } + + /** + * Delete a movie from the search index. + * + * @param int $id Movie ID + */ + public function deleteMovie(int $id): void + { + if (empty($id)) { + Log::warning('ElasticSearch: Cannot delete movie without ID'); + + return; + } + + try { + $client = $this->getClient(); + $client->delete([ + 'index' => $this->getMoviesIndex(), + 'id' => $id, + ]); + } catch (Missing404Exception $e) { + // Document doesn't exist, that's fine + } catch (ElasticsearchException $e) { + Log::error('ElasticSearch deleteMovie error: '.$e->getMessage(), [ + 'id' => $id, + ]); + } + } + + /** + * Bulk insert multiple movies into the index. + * + * @param array $movies Array of movie data arrays + * @return array Results with 'success' and 'errors' counts + */ + public function bulkInsertMovies(array $movies): array + { + if (empty($movies)) { + return ['success' => 0, 'errors' => 0]; + } + + $success = 0; + $errors = 0; + + $params = ['body' => []]; + + foreach ($movies as $movie) { + if (empty($movie['id'])) { + $errors++; + continue; + } + + $params['body'][] = [ + 'index' => [ + '_index' => $this->getMoviesIndex(), + '_id' => $movie['id'], + ], + ]; + + $params['body'][] = [ + 'id' => $movie['id'], + 'imdbid' => (int) ($movie['imdbid'] ?? 0), + 'tmdbid' => (int) ($movie['tmdbid'] ?? 0), + 'traktid' => (int) ($movie['traktid'] ?? 0), + 'title' => (string) ($movie['title'] ?? ''), + 'year' => (string) ($movie['year'] ?? ''), + 'genre' => (string) ($movie['genre'] ?? ''), + 'actors' => (string) ($movie['actors'] ?? ''), + 'director' => (string) ($movie['director'] ?? ''), + 'rating' => (string) ($movie['rating'] ?? ''), + 'plot' => (string) ($movie['plot'] ?? ''), + ]; + + $success++; + } + + if (! empty($params['body'])) { + try { + $this->executeBulk($params); + } catch (\Throwable $e) { + Log::error('ElasticSearch bulkInsertMovies error: '.$e->getMessage()); + $errors += $success; + $success = 0; + } + } + + return ['success' => $success, 'errors' => $errors]; + } + + /** + * Search the movies index. + * + * @param array|string $searchTerm Search term(s) + * @param int $limit Maximum number of results + * @return array Array with 'id' (movie IDs) and 'data' (movie data) + */ + public function searchMovies(array|string $searchTerm, int $limit = 1000): array + { + $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm; + $escapedSearch = self::escapeString($searchString); + + if (empty($escapedSearch)) { + return ['id' => [], 'data' => []]; + } + + $cacheKey = 'es:movies:'.md5($escapedSearch.$limit); + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + try { + $client = $this->getClient(); + + $searchParams = [ + 'index' => $this->getMoviesIndex(), + 'body' => [ + 'query' => [ + 'multi_match' => [ + 'query' => $escapedSearch, + 'fields' => ['title^3', 'actors', 'director', 'genre'], + 'type' => 'best_fields', + 'fuzziness' => 'AUTO', + ], + ], + 'size' => min($limit, self::MAX_RESULTS), + ], + ]; + + $response = $client->search($searchParams); + + $resultIds = []; + $resultData = []; + + if (isset($response['hits']['hits'])) { + foreach ($response['hits']['hits'] as $hit) { + $resultIds[] = $hit['_id']; + $resultData[] = $hit['_source']; + } + } + + $result = ['id' => $resultIds, 'data' => $resultData]; + + Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL_MINUTES)); + + return $result; + + } catch (\Throwable $e) { + Log::error('ElasticSearch searchMovies error: '.$e->getMessage()); + + return ['id' => [], 'data' => []]; + } + } + + /** + * Search movies by external ID (IMDB, TMDB, Trakt). + * + * @param string $field Field name (imdbid, tmdbid, traktid) + * @param int|string $value The external ID value + * @return array|null Movie data or null if not found + */ + public function searchMovieByExternalId(string $field, int|string $value): ?array + { + if (empty($value) || ! in_array($field, ['imdbid', 'tmdbid', 'traktid'])) { + return null; + } + + $cacheKey = 'es:movie:'.$field.':'.$value; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + try { + $client = $this->getClient(); + + $searchParams = [ + 'index' => $this->getMoviesIndex(), + 'body' => [ + 'query' => [ + 'term' => [ + $field => (int) $value, + ], + ], + 'size' => 1, + ], + ]; + + $response = $client->search($searchParams); + + if (isset($response['hits']['hits'][0])) { + $data = $response['hits']['hits'][0]['_source']; + $data['id'] = $response['hits']['hits'][0]['_id']; + Cache::put($cacheKey, $data, now()->addMinutes(self::CACHE_TTL_MINUTES)); + + return $data; + } + + } catch (\Throwable $e) { + Log::error('ElasticSearch searchMovieByExternalId error: '.$e->getMessage(), [ + 'field' => $field, + 'value' => $value, + ]); + } + + return null; + } + + /** + * Insert a TV show into the tvshows search index. + * + * @param array $parameters TV show data + */ + public function insertTvShow(array $parameters): void + { + if (empty($parameters['id'])) { + Log::warning('ElasticSearch: Cannot insert TV show without ID'); + + return; + } + + try { + $client = $this->getClient(); + + $document = [ + 'id' => $parameters['id'], + 'title' => (string) ($parameters['title'] ?? ''), + 'tvdb' => (int) ($parameters['tvdb'] ?? 0), + 'trakt' => (int) ($parameters['trakt'] ?? 0), + 'tvmaze' => (int) ($parameters['tvmaze'] ?? 0), + 'tvrage' => (int) ($parameters['tvrage'] ?? 0), + 'imdb' => (int) ($parameters['imdb'] ?? 0), + 'tmdb' => (int) ($parameters['tmdb'] ?? 0), + 'started' => (string) ($parameters['started'] ?? ''), + 'type' => (int) ($parameters['type'] ?? 0), + ]; + + $client->index([ + 'index' => $this->getTvShowsIndex(), + 'id' => $parameters['id'], + 'body' => $document, + ]); + + } catch (ElasticsearchException $e) { + Log::error('ElasticSearch insertTvShow error: '.$e->getMessage(), [ + 'tvshow_id' => $parameters['id'], + ]); + } catch (\Throwable $e) { + Log::error('ElasticSearch insertTvShow unexpected error: '.$e->getMessage(), [ + 'tvshow_id' => $parameters['id'], + ]); + } + } + + /** + * Update a TV show in the search index. + * + * @param int $videoId Video/TV show ID + */ + public function updateTvShow(int $videoId): void + { + if (empty($videoId)) { + Log::warning('ElasticSearch: Cannot update TV show without ID'); + + return; + } + + try { + $video = Video::find($videoId); + + if ($video !== null) { + $this->insertTvShow($video->toArray()); + } else { + Log::warning('ElasticSearch: TV show not found for update', ['id' => $videoId]); + } + } catch (\Throwable $e) { + Log::error('ElasticSearch updateTvShow error: '.$e->getMessage(), [ + 'tvshow_id' => $videoId, + ]); + } + } + + /** + * Delete a TV show from the search index. + * + * @param int $id TV show ID + */ + public function deleteTvShow(int $id): void + { + if (empty($id)) { + Log::warning('ElasticSearch: Cannot delete TV show without ID'); + + return; + } + + try { + $client = $this->getClient(); + $client->delete([ + 'index' => $this->getTvShowsIndex(), + 'id' => $id, + ]); + } catch (Missing404Exception $e) { + // Document doesn't exist, that's fine + } catch (ElasticsearchException $e) { + Log::error('ElasticSearch deleteTvShow error: '.$e->getMessage(), [ + 'id' => $id, + ]); + } + } + + /** + * Bulk insert multiple TV shows into the index. + * + * @param array $tvShows Array of TV show data arrays + * @return array Results with 'success' and 'errors' counts + */ + public function bulkInsertTvShows(array $tvShows): array + { + if (empty($tvShows)) { + return ['success' => 0, 'errors' => 0]; + } + + $success = 0; + $errors = 0; + + $params = ['body' => []]; + + foreach ($tvShows as $tvShow) { + if (empty($tvShow['id'])) { + $errors++; + continue; + } + + $params['body'][] = [ + 'index' => [ + '_index' => $this->getTvShowsIndex(), + '_id' => $tvShow['id'], + ], + ]; + + $params['body'][] = [ + 'id' => $tvShow['id'], + 'title' => (string) ($tvShow['title'] ?? ''), + 'tvdb' => (int) ($tvShow['tvdb'] ?? 0), + 'trakt' => (int) ($tvShow['trakt'] ?? 0), + 'tvmaze' => (int) ($tvShow['tvmaze'] ?? 0), + 'tvrage' => (int) ($tvShow['tvrage'] ?? 0), + 'imdb' => (int) ($tvShow['imdb'] ?? 0), + 'tmdb' => (int) ($tvShow['tmdb'] ?? 0), + 'started' => (string) ($tvShow['started'] ?? ''), + 'type' => (int) ($tvShow['type'] ?? 0), + ]; + + $success++; + } + + if (! empty($params['body'])) { + try { + $this->executeBulk($params); + } catch (\Throwable $e) { + Log::error('ElasticSearch bulkInsertTvShows error: '.$e->getMessage()); + $errors += $success; + $success = 0; + } + } + + return ['success' => $success, 'errors' => $errors]; + } + + /** + * Search the TV shows index. + * + * @param array|string $searchTerm Search term(s) + * @param int $limit Maximum number of results + * @return array Array with 'id' (TV show IDs) and 'data' (TV show data) + */ + public function searchTvShows(array|string $searchTerm, int $limit = 1000): array + { + $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm; + $escapedSearch = self::escapeString($searchString); + + if (empty($escapedSearch)) { + return ['id' => [], 'data' => []]; + } + + $cacheKey = 'es:tvshows:'.md5($escapedSearch.$limit); + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + try { + $client = $this->getClient(); + + $searchParams = [ + 'index' => $this->getTvShowsIndex(), + 'body' => [ + 'query' => [ + 'match' => [ + 'title' => [ + 'query' => $escapedSearch, + 'fuzziness' => 'AUTO', + ], + ], + ], + 'size' => min($limit, self::MAX_RESULTS), + ], + ]; + + $response = $client->search($searchParams); + + $resultIds = []; + $resultData = []; + + if (isset($response['hits']['hits'])) { + foreach ($response['hits']['hits'] as $hit) { + $resultIds[] = $hit['_id']; + $resultData[] = $hit['_source']; + } + } + + $result = ['id' => $resultIds, 'data' => $resultData]; + + Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL_MINUTES)); + + return $result; + + } catch (\Throwable $e) { + Log::error('ElasticSearch searchTvShows error: '.$e->getMessage()); + + return ['id' => [], 'data' => []]; + } + } + + /** + * Search TV shows by external ID (TVDB, Trakt, TVMaze, TVRage, IMDB, TMDB). + * + * @param string $field Field name (tvdb, trakt, tvmaze, tvrage, imdb, tmdb) + * @param int|string $value The external ID value + * @return array|null TV show data or null if not found + */ + public function searchTvShowByExternalId(string $field, int|string $value): ?array + { + if (empty($value) || ! in_array($field, ['tvdb', 'trakt', 'tvmaze', 'tvrage', 'imdb', 'tmdb'])) { + return null; + } + + $cacheKey = 'es:tvshow:'.$field.':'.$value; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + try { + $client = $this->getClient(); + + $searchParams = [ + 'index' => $this->getTvShowsIndex(), + 'body' => [ + 'query' => [ + 'term' => [ + $field => (int) $value, + ], + ], + 'size' => 1, + ], + ]; + + $response = $client->search($searchParams); + + if (isset($response['hits']['hits'][0])) { + $data = $response['hits']['hits'][0]['_source']; + $data['id'] = $response['hits']['hits'][0]['_id']; + Cache::put($cacheKey, $data, now()->addMinutes(self::CACHE_TTL_MINUTES)); + + return $data; + } + + } catch (\Throwable $e) { + Log::error('ElasticSearch searchTvShowByExternalId error: '.$e->getMessage(), [ + 'field' => $field, + 'value' => $value, + ]); + } + + return null; + } + + /** + * Search releases by external media IDs. + * Used to find releases associated with a specific movie or TV show. + * + * @param array $externalIds Associative array of external IDs + * @param int $limit Maximum number of results + * @return array Array of release IDs + */ + public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array + { + if (empty($externalIds)) { + return []; + } + + $cacheKey = 'es:releases:extid:'.md5(serialize($externalIds)); + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + try { + $client = $this->getClient(); + + $shouldClauses = []; + foreach ($externalIds as $field => $value) { + if (! empty($value) && in_array($field, ['imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze', 'tvrage'])) { + $shouldClauses[] = ['term' => [$field => (int) $value]]; + } + } + + if (empty($shouldClauses)) { + return []; + } + + $searchParams = [ + 'index' => $this->getReleasesIndex(), + 'body' => [ + 'query' => [ + 'bool' => [ + 'should' => $shouldClauses, + 'minimum_should_match' => 1, + ], + ], + 'size' => min($limit, self::MAX_RESULTS), + '_source' => false, + ], + ]; + + $response = $client->search($searchParams); + + $resultIds = []; + if (isset($response['hits']['hits'])) { + foreach ($response['hits']['hits'] as $hit) { + $resultIds[] = $hit['_id']; + } + } + + if (! empty($resultIds)) { + Cache::put($cacheKey, $resultIds, now()->addMinutes(self::CACHE_TTL_MINUTES)); + } + + return $resultIds; + + } catch (\Throwable $e) { + Log::error('ElasticSearch searchReleasesByExternalId error: '.$e->getMessage(), [ + 'externalIds' => $externalIds, + ]); + } + + return []; + } } + diff --git a/app/Services/Search/Drivers/ManticoreSearchDriver.php b/app/Services/Search/Drivers/ManticoreSearchDriver.php index 73a3dc522..dd0cf643a 100644 --- a/app/Services/Search/Drivers/ManticoreSearchDriver.php +++ b/app/Services/Search/Drivers/ManticoreSearchDriver.php @@ -2,7 +2,9 @@ namespace App\Services\Search\Drivers; +use App\Models\MovieInfo; use App\Models\Release; +use App\Models\Video; use App\Services\Search\Contracts\SearchDriverInterface; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -114,6 +116,22 @@ class ManticoreSearchDriver implements SearchDriverInterface return $this->config['indexes']['predb'] ?? 'predb_rt'; } + /** + * Get the movies index name. + */ + public function getMoviesIndex(): string + { + return $this->config['indexes']['movies'] ?? 'movies_rt'; + } + + /** + * Get the TV shows index name. + */ + public function getTvShowsIndex(): string + { + return $this->config['indexes']['tvshows'] ?? 'tvshows_rt'; + } + /** * Insert release into ManticoreSearch releases_rt realtime index */ @@ -130,8 +148,17 @@ class ManticoreSearchDriver implements SearchDriverInterface 'name' => $parameters['name'] ?? '', 'searchname' => $parameters['searchname'] ?? '', 'fromname' => $parameters['fromname'] ?? '', - 'categories_id' => (string) ($parameters['categories_id'] ?? ''), + 'categories_id' => (int) ($parameters['categories_id'] ?? 0), 'filename' => $parameters['filename'] ?? '', + // External media IDs for efficient searching + 'imdbid' => (int) ($parameters['imdbid'] ?? 0), + '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), ]; $this->manticoreSearch->table($this->config['indexes']['releases']) @@ -263,8 +290,17 @@ class ManticoreSearchDriver implements SearchDriverInterface 'name' => $release['name'] ?? '', 'searchname' => $release['searchname'] ?? '', 'fromname' => $release['fromname'] ?? '', - 'categories_id' => (string) ($release['categories_id'] ?? ''), + 'categories_id' => (int) ($release['categories_id'] ?? 0), 'filename' => $release['filename'] ?? '', + // External media IDs for efficient searching + 'imdbid' => (int) ($release['imdbid'] ?? 0), + '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), ]; } @@ -407,15 +443,25 @@ class ManticoreSearchDriver implements SearchDriverInterface $this->manticoreSearch->table($index)->truncate(); cli()->info('Truncating index '.$index.' finished.'); } catch (ResponseException $e) { - if ($e->getMessage() === 'Invalid index') { + // Handle case where index doesn't exist - create it + $message = $e->getMessage(); + if (str_contains($message, 'does not exist') || $message === 'Invalid index') { + cli()->info('Index '.$index.' does not exist, creating it...'); $this->createIndexIfNotExists($index); } else { - cli()->error('Error truncating index '.$index.': '.$e->getMessage()); + cli()->error('Error truncating index '.$index.': '.$message); $success = false; } } catch (\Throwable $e) { - cli()->error('Unexpected error truncating index '.$index.': '.$e->getMessage()); - $success = false; + // Also handle generic exceptions for non-existent tables + $message = $e->getMessage(); + if (str_contains($message, 'does not exist')) { + cli()->info('Index '.$index.' does not exist, creating it...'); + $this->createIndexIfNotExists($index); + } else { + cli()->error('Unexpected error truncating index '.$index.': '.$message); + $success = false; + } } } @@ -442,20 +488,56 @@ class ManticoreSearchDriver implements SearchDriverInterface try { if ($index === 'releases_rt') { $this->manticoreSearch->table($index)->create([ - 'name' => ['type' => 'string'], - 'searchname' => ['type' => 'string'], - 'fromname' => ['type' => 'string'], - 'filename' => ['type' => 'string'], + 'name' => ['type' => 'text'], + 'searchname' => ['type' => 'text'], + 'fromname' => ['type' => 'text'], + 'filename' => ['type' => 'text'], 'categories_id' => ['type' => 'integer'], + // External media IDs for efficient searching + 'imdbid' => ['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'], ]); - cli()->info('Created releases_rt index'); + cli()->info('Created releases_rt index with external ID fields'); } elseif ($index === 'predb_rt') { $this->manticoreSearch->table($index)->create([ - 'title' => ['type' => 'string'], - 'filename' => ['type' => 'string'], - 'source' => ['type' => 'string'], + 'title' => ['type' => 'text'], + 'filename' => ['type' => 'text'], + 'source' => ['type' => 'text'], ]); cli()->info('Created predb_rt index'); + } elseif ($index === 'movies_rt') { + $this->manticoreSearch->table($index)->create([ + 'imdbid' => ['type' => 'integer'], + 'tmdbid' => ['type' => 'integer'], + 'traktid' => ['type' => 'integer'], + 'title' => ['type' => 'text'], + 'year' => ['type' => 'text'], + 'genre' => ['type' => 'text'], + 'actors' => ['type' => 'text'], + 'director' => ['type' => 'text'], + 'rating' => ['type' => 'text'], + 'plot' => ['type' => 'text'], + ]); + cli()->info('Created movies_rt index'); + } elseif ($index === 'tvshows_rt') { + $this->manticoreSearch->table($index)->create([ + 'title' => ['type' => 'text'], + 'tvdb' => ['type' => 'integer'], + 'trakt' => ['type' => 'integer'], + 'tvmaze' => ['type' => 'integer'], + 'tvrage' => ['type' => 'integer'], + 'imdb' => ['type' => 'integer'], + 'tmdb' => ['type' => 'integer'], + 'started' => ['type' => 'text'], + 'type' => ['type' => 'integer'], + ]); + cli()->info('Created tvshows_rt index'); } } catch (\Throwable $e) { cli()->error('Error creating index '.$index.': '.$e->getMessage()); @@ -1206,5 +1288,459 @@ class ManticoreSearchDriver implements SearchDriverInterface return []; } } + + /** + * Insert a movie into the movies search index. + * + * @param array $parameters Movie data + */ + public function insertMovie(array $parameters): void + { + if (empty($parameters['id'])) { + Log::warning('ManticoreSearch: Cannot insert movie without ID'); + + return; + } + + try { + $document = [ + 'imdbid' => (int) ($parameters['imdbid'] ?? 0), + 'tmdbid' => (int) ($parameters['tmdbid'] ?? 0), + 'traktid' => (int) ($parameters['traktid'] ?? 0), + 'title' => (string) ($parameters['title'] ?? ''), + 'year' => (string) ($parameters['year'] ?? ''), + 'genre' => (string) ($parameters['genre'] ?? ''), + 'actors' => (string) ($parameters['actors'] ?? ''), + 'director' => (string) ($parameters['director'] ?? ''), + 'rating' => (string) ($parameters['rating'] ?? ''), + 'plot' => (string) ($parameters['plot'] ?? ''), + ]; + + $this->manticoreSearch->table($this->getMoviesIndex()) + ->replaceDocument($document, $parameters['id']); + + } catch (ResponseException $e) { + Log::error('ManticoreSearch insertMovie ResponseException: '.$e->getMessage(), [ + 'movie_id' => $parameters['id'], + ]); + } catch (\Throwable $e) { + Log::error('ManticoreSearch insertMovie unexpected error: '.$e->getMessage(), [ + 'movie_id' => $parameters['id'], + ]); + } + } + + /** + * Update a movie in the search index. + * + * @param int $movieId Movie ID + */ + public function updateMovie(int $movieId): void + { + if (empty($movieId)) { + Log::warning('ManticoreSearch: Cannot update movie without ID'); + + return; + } + + try { + $movie = MovieInfo::find($movieId); + + if ($movie !== null) { + $this->insertMovie($movie->toArray()); + } else { + Log::warning('ManticoreSearch: Movie not found for update', ['id' => $movieId]); + } + } catch (\Throwable $e) { + Log::error('ManticoreSearch updateMovie error: '.$e->getMessage(), [ + 'movie_id' => $movieId, + ]); + } + } + + /** + * Delete a movie from the search index. + * + * @param int $id Movie ID + */ + public function deleteMovie(int $id): void + { + if (empty($id)) { + Log::warning('ManticoreSearch: Cannot delete movie without ID'); + + return; + } + + try { + $this->manticoreSearch->table($this->getMoviesIndex()) + ->deleteDocument($id); + } catch (ResponseException $e) { + Log::error('ManticoreSearch deleteMovie error: '.$e->getMessage(), [ + 'id' => $id, + ]); + } + } + + /** + * Bulk insert multiple movies into the index. + * + * @param array $movies Array of movie data arrays + * @return array Results with 'success' and 'errors' counts + */ + public function bulkInsertMovies(array $movies): array + { + if (empty($movies)) { + return ['success' => 0, 'errors' => 0]; + } + + $success = 0; + $errors = 0; + + $documents = []; + foreach ($movies as $movie) { + if (empty($movie['id'])) { + $errors++; + continue; + } + + $documents[] = [ + 'id' => $movie['id'], + 'imdbid' => (int) ($movie['imdbid'] ?? 0), + 'tmdbid' => (int) ($movie['tmdbid'] ?? 0), + 'traktid' => (int) ($movie['traktid'] ?? 0), + 'title' => (string) ($movie['title'] ?? ''), + 'year' => (string) ($movie['year'] ?? ''), + 'genre' => (string) ($movie['genre'] ?? ''), + 'actors' => (string) ($movie['actors'] ?? ''), + 'director' => (string) ($movie['director'] ?? ''), + 'rating' => (string) ($movie['rating'] ?? ''), + 'plot' => (string) ($movie['plot'] ?? ''), + ]; + } + + if (! empty($documents)) { + try { + $this->manticoreSearch->table($this->getMoviesIndex()) + ->replaceDocuments($documents); + $success = count($documents); + } catch (\Throwable $e) { + Log::error('ManticoreSearch bulkInsertMovies error: '.$e->getMessage()); + $errors += count($documents); + } + } + + return ['success' => $success, 'errors' => $errors]; + } + + /** + * Search the movies index. + * + * @param array|string $searchTerm Search term(s) + * @param int $limit Maximum number of results + * @return array Array with 'id' (movie IDs) and 'data' (movie data) + */ + public function searchMovies(array|string $searchTerm, int $limit = 1000): array + { + $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm; + + return $this->searchIndexes($this->getMoviesIndex(), $searchString, ['title', 'actors', 'director'], []); + } + + /** + * Search movies by external ID (IMDB, TMDB, Trakt). + * + * @param string $field Field name (imdbid, tmdbid, traktid) + * @param int|string $value The external ID value + * @return array|null Movie data or null if not found + */ + public function searchMovieByExternalId(string $field, int|string $value): ?array + { + if (empty($value) || ! in_array($field, ['imdbid', 'tmdbid', 'traktid'])) { + return null; + } + + $cacheKey = 'manticore:movie:'.$field.':'.$value; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + try { + $query = (new Search($this->manticoreSearch)) + ->setTable($this->getMoviesIndex()) + ->filter($field, '=', (int) $value) + ->limit(1); + + $results = $query->get(); + + foreach ($results as $doc) { + $data = $doc->getData(); + $data['id'] = $doc->getId(); + Cache::put($cacheKey, $data, now()->addMinutes($this->config['cache_minutes'] ?? 5)); + + return $data; + } + } catch (\Throwable $e) { + Log::error('ManticoreSearch searchMovieByExternalId error: '.$e->getMessage(), [ + 'field' => $field, + 'value' => $value, + ]); + } + + return null; + } + + /** + * Insert a TV show into the tvshows search index. + * + * @param array $parameters TV show data + */ + public function insertTvShow(array $parameters): void + { + if (empty($parameters['id'])) { + Log::warning('ManticoreSearch: Cannot insert TV show without ID'); + + return; + } + + try { + $document = [ + 'title' => (string) ($parameters['title'] ?? ''), + 'tvdb' => (int) ($parameters['tvdb'] ?? 0), + 'trakt' => (int) ($parameters['trakt'] ?? 0), + 'tvmaze' => (int) ($parameters['tvmaze'] ?? 0), + 'tvrage' => (int) ($parameters['tvrage'] ?? 0), + 'imdb' => (int) ($parameters['imdb'] ?? 0), + 'tmdb' => (int) ($parameters['tmdb'] ?? 0), + 'started' => (string) ($parameters['started'] ?? ''), + 'type' => (int) ($parameters['type'] ?? 0), + ]; + + $this->manticoreSearch->table($this->getTvShowsIndex()) + ->replaceDocument($document, $parameters['id']); + + } catch (ResponseException $e) { + Log::error('ManticoreSearch insertTvShow ResponseException: '.$e->getMessage(), [ + 'tvshow_id' => $parameters['id'], + ]); + } catch (\Throwable $e) { + Log::error('ManticoreSearch insertTvShow unexpected error: '.$e->getMessage(), [ + 'tvshow_id' => $parameters['id'], + ]); + } + } + + /** + * Update a TV show in the search index. + * + * @param int $videoId Video/TV show ID + */ + public function updateTvShow(int $videoId): void + { + if (empty($videoId)) { + Log::warning('ManticoreSearch: Cannot update TV show without ID'); + + return; + } + + try { + $video = Video::find($videoId); + + if ($video !== null) { + $this->insertTvShow($video->toArray()); + } else { + Log::warning('ManticoreSearch: TV show not found for update', ['id' => $videoId]); + } + } catch (\Throwable $e) { + Log::error('ManticoreSearch updateTvShow error: '.$e->getMessage(), [ + 'tvshow_id' => $videoId, + ]); + } + } + + /** + * Delete a TV show from the search index. + * + * @param int $id TV show ID + */ + public function deleteTvShow(int $id): void + { + if (empty($id)) { + Log::warning('ManticoreSearch: Cannot delete TV show without ID'); + + return; + } + + try { + $this->manticoreSearch->table($this->getTvShowsIndex()) + ->deleteDocument($id); + } catch (ResponseException $e) { + Log::error('ManticoreSearch deleteTvShow error: '.$e->getMessage(), [ + 'id' => $id, + ]); + } + } + + /** + * Bulk insert multiple TV shows into the index. + * + * @param array $tvShows Array of TV show data arrays + * @return array Results with 'success' and 'errors' counts + */ + public function bulkInsertTvShows(array $tvShows): array + { + if (empty($tvShows)) { + return ['success' => 0, 'errors' => 0]; + } + + $success = 0; + $errors = 0; + + $documents = []; + foreach ($tvShows as $tvShow) { + if (empty($tvShow['id'])) { + $errors++; + continue; + } + + $documents[] = [ + 'id' => $tvShow['id'], + 'title' => (string) ($tvShow['title'] ?? ''), + 'tvdb' => (int) ($tvShow['tvdb'] ?? 0), + 'trakt' => (int) ($tvShow['trakt'] ?? 0), + 'tvmaze' => (int) ($tvShow['tvmaze'] ?? 0), + 'tvrage' => (int) ($tvShow['tvrage'] ?? 0), + 'imdb' => (int) ($tvShow['imdb'] ?? 0), + 'tmdb' => (int) ($tvShow['tmdb'] ?? 0), + 'started' => (string) ($tvShow['started'] ?? ''), + 'type' => (int) ($tvShow['type'] ?? 0), + ]; + } + + if (! empty($documents)) { + try { + $this->manticoreSearch->table($this->getTvShowsIndex()) + ->replaceDocuments($documents); + $success = count($documents); + } catch (\Throwable $e) { + Log::error('ManticoreSearch bulkInsertTvShows error: '.$e->getMessage()); + $errors += count($documents); + } + } + + return ['success' => $success, 'errors' => $errors]; + } + + /** + * Search the TV shows index. + * + * @param array|string $searchTerm Search term(s) + * @param int $limit Maximum number of results + * @return array Array with 'id' (TV show IDs) and 'data' (TV show data) + */ + public function searchTvShows(array|string $searchTerm, int $limit = 1000): array + { + $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm; + + return $this->searchIndexes($this->getTvShowsIndex(), $searchString, ['title'], []); + } + + /** + * Search TV shows by external ID (TVDB, Trakt, TVMaze, TVRage, IMDB, TMDB). + * + * @param string $field Field name (tvdb, trakt, tvmaze, tvrage, imdb, tmdb) + * @param int|string $value The external ID value + * @return array|null TV show data or null if not found + */ + public function searchTvShowByExternalId(string $field, int|string $value): ?array + { + if (empty($value) || ! in_array($field, ['tvdb', 'trakt', 'tvmaze', 'tvrage', 'imdb', 'tmdb'])) { + return null; + } + + $cacheKey = 'manticore:tvshow:'.$field.':'.$value; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + try { + $query = (new Search($this->manticoreSearch)) + ->setTable($this->getTvShowsIndex()) + ->filter($field, '=', (int) $value) + ->limit(1); + + $results = $query->get(); + + foreach ($results as $doc) { + $data = $doc->getData(); + $data['id'] = $doc->getId(); + Cache::put($cacheKey, $data, now()->addMinutes($this->config['cache_minutes'] ?? 5)); + + return $data; + } + } catch (\Throwable $e) { + Log::error('ManticoreSearch searchTvShowByExternalId error: '.$e->getMessage(), [ + 'field' => $field, + 'value' => $value, + ]); + } + + return null; + } + + /** + * Search releases by external media IDs. + * Used to find releases associated with a specific movie or TV show. + * + * @param array $externalIds Associative array of external IDs + * @param int $limit Maximum number of results + * @return array Array of release IDs + */ + public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array + { + if (empty($externalIds)) { + return []; + } + + $cacheKey = 'manticore:releases:extid:'.md5(serialize($externalIds)); + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + try { + $query = (new Search($this->manticoreSearch)) + ->setTable($this->getReleasesIndex()) + ->limit(min($limit, 10000)); + + // Add filters for each external ID provided + foreach ($externalIds as $field => $value) { + if (! empty($value) && in_array($field, ['imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze', 'tvrage'])) { + $query->filter($field, '=', (int) $value); + } + } + + $results = $query->get(); + + $resultIds = []; + foreach ($results as $doc) { + $resultIds[] = $doc->getId(); + } + + if (! empty($resultIds)) { + Cache::put($cacheKey, $resultIds, now()->addMinutes($this->config['cache_minutes'] ?? 5)); + } + + return $resultIds; + } catch (\Throwable $e) { + Log::error('ManticoreSearch searchReleasesByExternalId error: '.$e->getMessage(), [ + 'externalIds' => $externalIds, + ]); + } + + return []; + } } + diff --git a/app/Services/Search/MediaSearchService.php b/app/Services/Search/MediaSearchService.php new file mode 100644 index 000000000..b467bce24 --- /dev/null +++ b/app/Services/Search/MediaSearchService.php @@ -0,0 +1,305 @@ +addMinutes(self::CACHE_TTL)); + } + + return $allReleaseIds; + } + + /** + * Search for TV show releases by show title. + * + * First searches the tvshows index for matching titles, then finds + * releases with matching external IDs - reducing database JOINs. + * + * @param string $title TV show title to search + * @param int $limit Maximum results + * @return array Array of release IDs + */ + public function searchTvShowReleases(string $title, int $limit = 1000): array + { + $cacheKey = 'media:tvshow_releases:'.md5($title.$limit); + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + // First, search tvshows index for matching titles + $tvResults = Search::searchTvShows($title, 50); + + if (empty($tvResults['id'])) { + return []; + } + + // Collect all external IDs from matched TV shows + $allReleaseIds = []; + + foreach ($tvResults['data'] ?? [] as $tvShow) { + $externalIds = []; + + if (! empty($tvShow['tvdb'])) { + $externalIds['tvdb'] = $tvShow['tvdb']; + } + if (! empty($tvShow['trakt'])) { + $externalIds['traktid'] = $tvShow['trakt']; + } + if (! empty($tvShow['tvmaze'])) { + $externalIds['tvmaze'] = $tvShow['tvmaze']; + } + if (! empty($tvShow['tvrage'])) { + $externalIds['tvrage'] = $tvShow['tvrage']; + } + + if (! empty($externalIds)) { + $releaseIds = Search::searchReleasesByExternalId($externalIds, $limit); + $allReleaseIds = array_merge($allReleaseIds, $releaseIds); + } + } + + // Remove duplicates and limit results + $allReleaseIds = array_unique($allReleaseIds); + $allReleaseIds = array_slice($allReleaseIds, 0, $limit); + + if (! empty($allReleaseIds)) { + Cache::put($cacheKey, $allReleaseIds, now()->addMinutes(self::CACHE_TTL)); + } + + return $allReleaseIds; + } + + /** + * Get releases for a specific IMDB ID directly from the index. + * + * @param int|string $imdbId IMDB ID (without 'tt' prefix) + * @param int $limit Maximum results + * @return array Array of release IDs + */ + public function getReleasesByImdbId(int|string $imdbId, int $limit = 1000): array + { + return Search::searchReleasesByExternalId(['imdbid' => (int) $imdbId], $limit); + } + + /** + * Get releases for a specific TMDB ID directly from the index. + * + * @param int|string $tmdbId TMDB ID + * @param int $limit Maximum results + * @return array Array of release IDs + */ + public function getReleasesByTmdbId(int|string $tmdbId, int $limit = 1000): array + { + return Search::searchReleasesByExternalId(['tmdbid' => (int) $tmdbId], $limit); + } + + /** + * Get releases for a specific TVDB ID directly from the index. + * + * @param int|string $tvdbId TVDB ID + * @param int $limit Maximum results + * @return array Array of release IDs + */ + public function getReleasesByTvdbId(int|string $tvdbId, int $limit = 1000): array + { + return Search::searchReleasesByExternalId(['tvdb' => (int) $tvdbId], $limit); + } + + /** + * Get releases for a specific Trakt ID directly from the index. + * + * @param int|string $traktId Trakt ID + * @param int $limit Maximum results + * @return array Array of release IDs + */ + public function getReleasesByTraktId(int|string $traktId, int $limit = 1000): array + { + return Search::searchReleasesByExternalId(['traktid' => (int) $traktId], $limit); + } + + /** + * Combined search: search by title and optionally by external IDs. + * + * @param string $title Media title + * @param array $externalIds Optional external IDs to narrow search + * @param string $type 'movie' or 'tv' + * @param int $limit Maximum results + * @return array Array of release IDs + */ + public function searchMedia(string $title, array $externalIds = [], string $type = 'movie', int $limit = 1000): array + { + // If we have external IDs, use them directly + if (! empty($externalIds)) { + return Search::searchReleasesByExternalId($externalIds, $limit); + } + + // Otherwise search by title + if ($type === 'movie') { + return $this->searchMovieReleases($title, $limit); + } + + return $this->searchTvShowReleases($title, $limit); + } + + /** + * Get movie info from the search index (faster than DB query). + * + * @param int $movieInfoId MovieInfo ID + * @return array|null Movie data or null + */ + public function getMovieFromIndex(int $movieInfoId): ?array + { + $cacheKey = 'media:movie:'.$movieInfoId; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + // Search movies index by ID + $result = Search::searchMovieByExternalId('id', $movieInfoId); + + if ($result) { + Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL)); + } + + return $result; + } + + /** + * Get TV show info from the search index (faster than DB query). + * + * @param int $videoId Video/TV show ID + * @return array|null TV show data or null + */ + public function getTvShowFromIndex(int $videoId): ?array + { + $cacheKey = 'media:tvshow:'.$videoId; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + // Search tvshows index by ID + $result = Search::searchTvShowByExternalId('id', $videoId); + + if ($result) { + Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL)); + } + + return $result; + } +} + diff --git a/app/Services/Search/SearchService.php b/app/Services/Search/SearchService.php index b036b4719..259f89781 100644 --- a/app/Services/Search/SearchService.php +++ b/app/Services/Search/SearchService.php @@ -263,5 +263,133 @@ class SearchService extends Manager implements SearchServiceInterface { $this->driver()->deletePreDb($id); } + + /** + * Get the movies index name. + */ + public function getMoviesIndex(): string + { + return $this->driver()->getMoviesIndex(); + } + + /** + * Get the TV shows index name. + */ + public function getTvShowsIndex(): string + { + return $this->driver()->getTvShowsIndex(); + } + + /** + * Insert a movie into the movies search index. + */ + public function insertMovie(array $parameters): void + { + $this->driver()->insertMovie($parameters); + } + + /** + * Update a movie in the search index. + */ + public function updateMovie(int $movieId): void + { + $this->driver()->updateMovie($movieId); + } + + /** + * Delete a movie from the search index. + */ + public function deleteMovie(int $id): void + { + $this->driver()->deleteMovie($id); + } + + /** + * Bulk insert multiple movies into the index. + * + * @param array $movies Array of movie data arrays + * @return array Results with 'success' and 'errors' counts + */ + public function bulkInsertMovies(array $movies): array + { + return $this->driver()->bulkInsertMovies($movies); + } + + /** + * Search the movies index. + */ + public function searchMovies(array|string $searchTerm, int $limit = 1000): array + { + return $this->driver()->searchMovies($searchTerm, $limit); + } + + /** + * Search movies by external ID (IMDB, TMDB, Trakt). + */ + public function searchMovieByExternalId(string $field, int|string $value): ?array + { + return $this->driver()->searchMovieByExternalId($field, $value); + } + + /** + * Insert a TV show into the tvshows search index. + */ + public function insertTvShow(array $parameters): void + { + $this->driver()->insertTvShow($parameters); + } + + /** + * Update a TV show in the search index. + */ + public function updateTvShow(int $videoId): void + { + $this->driver()->updateTvShow($videoId); + } + + /** + * Delete a TV show from the search index. + */ + public function deleteTvShow(int $id): void + { + $this->driver()->deleteTvShow($id); + } + + /** + * Bulk insert multiple TV shows into the index. + * + * @param array $tvShows Array of TV show data arrays + * @return array Results with 'success' and 'errors' counts + */ + public function bulkInsertTvShows(array $tvShows): array + { + return $this->driver()->bulkInsertTvShows($tvShows); + } + + /** + * Search the TV shows index. + */ + public function searchTvShows(array|string $searchTerm, int $limit = 1000): array + { + return $this->driver()->searchTvShows($searchTerm, $limit); + } + + /** + * Search TV shows by external ID (TVDB, Trakt, TVMaze, TVRage, IMDB, TMDB). + */ + public function searchTvShowByExternalId(string $field, int|string $value): ?array + { + return $this->driver()->searchTvShowByExternalId($field, $value); + } + + /** + * Search releases by external media IDs. + * Used to find releases associated with a specific movie or TV show. + */ + public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array + { + return $this->driver()->searchReleasesByExternalId($externalIds, $limit); + } } + diff --git a/app/Services/TvProcessing/Providers/AbstractTvProvider.php b/app/Services/TvProcessing/Providers/AbstractTvProvider.php index 351feda52..54326fedd 100644 --- a/app/Services/TvProcessing/Providers/AbstractTvProvider.php +++ b/app/Services/TvProcessing/Providers/AbstractTvProvider.php @@ -150,9 +150,13 @@ abstract class AbstractTvProvider extends BaseVideoProvider public function setVideoIdFound(int $videoId, int $releaseId, int $episodeId): void { - Release::query() - ->where('id', $releaseId) - ->update(['videos_id' => $videoId, 'tv_episodes_id' => $episodeId]); + // Use Eloquent model update to trigger the ReleaseObserver + $release = Release::find($releaseId); + if ($release) { + $release->videos_id = $videoId; + $release->tv_episodes_id = $episodeId; + $release->save(); + } ReleaseBrowseService::bumpCacheVersion(); } @@ -194,8 +198,8 @@ abstract class AbstractTvProvider extends BaseVideoProvider if ($videoId === false) { $title = Video::query()->where('title', $show['title'])->first(['title']); if ($title === null) { - // Insert the Show - $videoId = Video::query()->insertGetId([ + // Insert the Show using Eloquent create() to trigger VideoObserver + $video = Video::create([ 'type' => $show['type'], 'title' => $show['title'], 'countries_id' => $show['country'] ?? '', @@ -208,6 +212,7 @@ abstract class AbstractTvProvider extends BaseVideoProvider 'imdb' => $show['imdb'], 'tmdb' => $show['tmdb'], ]); + $videoId = $video->id; // Insert the supplementary show info TvInfo::query()->insertOrIgnore([ 'videos_id' => $videoId, diff --git a/config/manticoresearch.php b/config/manticoresearch.php index 569484f3b..4babff68c 100644 --- a/config/manticoresearch.php +++ b/config/manticoresearch.php @@ -36,6 +36,8 @@ return [ 'indexes' => [ 'releases' => env('MANTICORESEARCH_INDEX_RELEASES', 'releases_rt'), 'predb' => env('MANTICORESEARCH_INDEX_PREDB', 'predb_rt'), + 'movies' => env('MANTICORESEARCH_INDEX_MOVIES', 'movies_rt'), + 'tvshows' => env('MANTICORESEARCH_INDEX_TVSHOWS', 'tvshows_rt'), ], /* diff --git a/config/search.php b/config/search.php index d95a8a501..436ac46c9 100644 --- a/config/search.php +++ b/config/search.php @@ -32,6 +32,8 @@ return [ 'indexes' => [ 'releases' => env('MANTICORESEARCH_INDEX_RELEASES', 'releases_rt'), 'predb' => env('MANTICORESEARCH_INDEX_PREDB', 'predb_rt'), + 'movies' => env('MANTICORESEARCH_INDEX_MOVIES', 'movies_rt'), + 'tvshows' => env('MANTICORESEARCH_INDEX_TVSHOWS', 'tvshows_rt'), ], 'max_matches' => env('MANTICORESEARCH_MAX_MATCHES', 10000), 'cache_minutes' => env('MANTICORESEARCH_CACHE_MINUTES', 5), @@ -69,6 +71,8 @@ return [ 'indexes' => [ 'releases' => 'releases', 'predb' => 'predb', + 'movies' => 'movies', + 'tvshows' => 'tvshows', ], 'cache_minutes' => env('ELASTICSEARCH_CACHE_MINUTES', 5), 'autocomplete' => [