NntmuxPopulateSearchIndexes::handleOptimize()   A
last analyzed

Complexity

Conditions 2
Paths 3

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 8
dl 0
loc 13
rs 10
c 0
b 0
f 0
cc 2
nc 3
nop 0
1
<?php
2
3
namespace App\Console\Commands;
4
5
use App\Facades\Search;
6
use App\Models\MovieInfo;
7
use App\Models\Predb;
8
use App\Models\Release;
9
use App\Models\Video;
10
use Exception;
11
use Illuminate\Console\Command;
12
use Illuminate\Support\Facades\DB;
13
14
class NntmuxPopulateSearchIndexes extends Command
15
{
16
    /**
17
     * The name and signature of the console command.
18
     *
19
     * @var string
20
     */
21
    protected $signature = 'nntmux:populate
22
                                       {--manticore : Use ManticoreSearch}
23
                                       {--elastic : Use ElasticSearch}
24
                                       {--releases : Populates the releases index}
25
                                       {--predb : Populates the predb index}
26
                                       {--movies : Populates the movies index}
27
                                       {--tvshows : Populates the TV shows index}
28
                                       {--count=50000 : Sets the chunk size}
29
                                       {--parallel=4 : Number of parallel processes}
30
                                       {--batch-size=5000 : Batch size for bulk operations}
31
                                       {--disable-keys : Disable database keys during population}
32
                                       {--optimize : Optimize ManticoreSearch indexes}';
33
34
    /**
35
     * The console command description.
36
     *
37
     * @var string
38
     */
39
    protected $description = 'Populate Manticore/Elasticsearch indexes with releases, predb, movies, or tvshows';
40
41
    private const SUPPORTED_ENGINES = ['manticore', 'elastic'];
42
43
    private const SUPPORTED_INDEXES = ['releases', 'predb', 'movies', 'tvshows'];
44
45
    private const GROUP_CONCAT_MAX_LEN = 16384;
46
47
    private const DEFAULT_CHUNK_SIZE = 50000;
48
49
    private const DEFAULT_PARALLEL_PROCESSES = 4;
50
51
    private const DEFAULT_BATCH_SIZE = 5000;
52
53
    /**
54
     * Execute the console command.
55
     */
56
    public function handle(): int
57
    {
58
        try {
59
            if ($this->option('optimize')) {
60
                return $this->handleOptimize();
61
            }
62
63
            $engine = $this->getSelectedEngine();
64
            $index = $this->getSelectedIndex();
65
66
            if (! $engine || ! $index) {
67
                $this->error('You must specify both an engine (--manticore or --elastic) and an index (--releases or --predb).');
68
                $this->info('Use --help to see all available options.');
69
70
                return Command::FAILURE;
71
            }
72
73
            return $this->populateIndex($engine, $index);
74
75
        } catch (Exception $e) {
76
            $this->error("An error occurred: {$e->getMessage()}");
77
78
            if ($this->output->isVerbose()) {
79
                $this->error($e->getTraceAsString());
80
            }
81
82
            return Command::FAILURE;
83
        }
84
    }
85
86
    /**
87
     * Get the selected search engine from options
88
     */
89
    private function getSelectedEngine(): ?string
90
    {
91
        foreach (self::SUPPORTED_ENGINES as $engine) {
92
            if ($this->option($engine)) {
93
                return $engine;
94
            }
95
        }
96
97
        return null;
98
    }
99
100
    /**
101
     * Get the selected index from options
102
     */
103
    private function getSelectedIndex(): ?string
104
    {
105
        foreach (self::SUPPORTED_INDEXES as $index) {
106
            if ($this->option($index)) {
107
                return $index;
108
            }
109
        }
110
111
        return null;
112
    }
113
114
    /**
115
     * Handle the optimize command
116
     */
117
    private function handleOptimize(): int
118
    {
119
        $this->info('Optimizing search indexes...');
120
121
        try {
122
            Search::optimizeIndex();
123
            $this->info('Optimization completed successfully!');
124
125
            return Command::SUCCESS;
126
        } catch (Exception $e) {
127
            $this->error("Optimization failed: {$e->getMessage()}");
128
129
            return Command::FAILURE;
130
        }
131
    }
132
133
    /**
134
     * Populate the specified index with the specified engine
135
     */
136
    private function populateIndex(string $engine, string $index): int
137
    {
138
        $methodName = "{$engine}".ucfirst($index);
139
140
        if (! method_exists($this, $methodName)) {
141
            $this->error("Method {$methodName} not implemented.");
142
143
            return Command::FAILURE;
144
        }
145
146
        $this->info("Starting {$engine} {$index} population...");
147
148
        $startTime = microtime(true);
149
        $result = $this->{$methodName}();
150
        $executionTime = round(microtime(true) - $startTime, 2);
151
152
        if ($result === Command::SUCCESS) {
153
            $this->info("Population completed in {$executionTime} seconds.");
154
        }
155
156
        return $result;
157
    }
158
159
    private function manticoreReleases(): int
160
    {
161
        $indexName = 'releases_rt';
162
163
        Search::truncateIndex([$indexName]);
164
165
        $total = Release::count();
166
        if (! $total) {
167
            $this->warn('Releases table is empty. Nothing to do.');
168
169
            return Command::SUCCESS;
170
        }
171
172
        // Optimized query: avoid GROUP_CONCAT and complex joins for faster population
173
        // External media IDs can be populated separately if needed
174
        $query = Release::query()
175
            ->orderByDesc('releases.id')
176
            ->select([
177
                'releases.id',
178
                'releases.name',
179
                'releases.searchname',
180
                'releases.fromname',
181
                'releases.categories_id',
182
                'releases.videos_id',
183
                'releases.movieinfo_id',
184
                'releases.imdbid',
185
            ]);
186
187
        return $this->processManticoreData(
188
            $indexName,
189
            $total,
0 ignored issues
show
Bug introduced by
It seems like $total can also be of type Illuminate\Database\Eloquent\Builder and Illuminate\Database\Eloq...gHasThroughRelationship; however, parameter $total of App\Console\Commands\Nnt...:processManticoreData() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

189
            /** @scrutinizer ignore-type */ $total,
Loading history...
190
            $query,
191
            function ($item) {
192
                return [
193
                    'id' => (int) $item->id,
194
                    'name' => (string) ($item->name ?? ''),
195
                    'searchname' => (string) ($item->searchname ?? ''),
196
                    'fromname' => (string) ($item->fromname ?? ''),
197
                    'categories_id' => (int) ($item->categories_id ?? 0),
198
                    'filename' => '',
199
                    'videos_id' => (int) ($item->videos_id ?? 0),
200
                    'movieinfo_id' => (int) ($item->movieinfo_id ?? 0),
201
                    'imdbid' => (int) ($item->imdbid ?? 0),
202
                    'tmdbid' => 0,
203
                    'traktid' => 0,
204
                    'tvdb' => 0,
205
                    'tvmaze' => 0,
206
                    'tvrage' => 0,
207
                ];
208
            }
209
        );
210
    }
211
212
    /**
213
     * Populate ManticoreSearch predb index
214
     */
215
    private function manticorePredb(): int
216
    {
217
        $indexName = 'predb_rt';
218
219
        Search::truncateIndex([$indexName]);
220
221
        $total = Predb::count();
222
        if (! $total) {
223
            $this->warn('PreDB table is empty. Nothing to do.');
224
225
            return Command::SUCCESS;
226
        }
227
228
        $query = Predb::query()
229
            ->select(['id', 'title', 'filename', 'source'])
230
            ->orderBy('id');
0 ignored issues
show
Bug introduced by
'id' of type string is incompatible with the type Closure|Illuminate\Datab...\Database\Query\Builder expected by parameter $column of Illuminate\Database\Query\Builder::orderBy(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

230
            ->orderBy(/** @scrutinizer ignore-type */ 'id');
Loading history...
231
232
        return $this->processManticoreData(
233
            $indexName,
234
            $total,
0 ignored issues
show
Bug introduced by
It seems like $total can also be of type Illuminate\Database\Eloquent\Builder and Illuminate\Database\Eloq...gHasThroughRelationship; however, parameter $total of App\Console\Commands\Nnt...:processManticoreData() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

234
            /** @scrutinizer ignore-type */ $total,
Loading history...
235
            $query,
236
            function ($item) {
237
                return [
238
                    'id' => $item->id,
239
                    'title' => (string) ($item->title ?? ''),
240
                    'filename' => (string) ($item->filename ?? ''),
241
                    'source' => (string) ($item->source ?? ''),
242
                ];
243
            }
244
        );
245
    }
246
247
    /**
248
     * Populate ManticoreSearch movies index
249
     */
250
    private function manticoreMovies(): int
251
    {
252
        $indexName = 'movies_rt';
253
254
        Search::truncateIndex([$indexName]);
255
256
        $total = MovieInfo::count();
257
        if (! $total) {
258
            $this->warn('MovieInfo table is empty. Nothing to do.');
259
260
            return Command::SUCCESS;
261
        }
262
263
        $query = MovieInfo::query()
264
            ->select([
265
                'id',
266
                'imdbid',
267
                'tmdbid',
268
                'traktid',
269
                'title',
270
                'year',
271
                'genre',
272
                'actors',
273
                'director',
274
                'rating',
275
                'plot',
276
            ])
277
            ->orderBy('id');
0 ignored issues
show
Bug introduced by
'id' of type string is incompatible with the type Closure|Illuminate\Datab...\Database\Query\Builder expected by parameter $column of Illuminate\Database\Query\Builder::orderBy(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

277
            ->orderBy(/** @scrutinizer ignore-type */ 'id');
Loading history...
278
279
        return $this->processManticoreMoviesData(
280
            $indexName,
281
            $total,
0 ignored issues
show
Bug introduced by
It seems like $total can also be of type Illuminate\Database\Eloquent\Builder and Illuminate\Database\Eloq...gHasThroughRelationship; however, parameter $total of App\Console\Commands\Nnt...ssManticoreMoviesData() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

281
            /** @scrutinizer ignore-type */ $total,
Loading history...
282
            $query,
283
            function ($item) {
284
                return [
285
                    'id' => $item->id,
286
                    'imdbid' => (int) ($item->imdbid ?? 0),
287
                    'tmdbid' => (int) ($item->tmdbid ?? 0),
288
                    'traktid' => (int) ($item->traktid ?? 0),
289
                    'title' => (string) ($item->title ?? ''),
290
                    'year' => (string) ($item->year ?? ''),
291
                    'genre' => (string) ($item->genre ?? ''),
292
                    'actors' => (string) ($item->actors ?? ''),
293
                    'director' => (string) ($item->director ?? ''),
294
                    'rating' => (string) ($item->rating ?? ''),
295
                    'plot' => (string) ($item->plot ?? ''),
296
                ];
297
            }
298
        );
299
    }
300
301
    /**
302
     * Populate ManticoreSearch TV shows index
303
     */
304
    private function manticoreTvshows(): int
305
    {
306
        $indexName = 'tvshows_rt';
307
308
        Search::truncateIndex([$indexName]);
309
310
        $total = Video::count();
311
        if (! $total) {
312
            $this->warn('Videos table is empty. Nothing to do.');
313
314
            return Command::SUCCESS;
315
        }
316
317
        $query = Video::query()
318
            ->select([
319
                'id',
320
                'title',
321
                'tvdb',
322
                'trakt',
323
                'tvmaze',
324
                'tvrage',
325
                'imdb',
326
                'tmdb',
327
                'started',
328
                'type',
329
            ])
330
            ->orderBy('id');
0 ignored issues
show
Bug introduced by
'id' of type string is incompatible with the type Closure|Illuminate\Datab...\Database\Query\Builder expected by parameter $column of Illuminate\Database\Query\Builder::orderBy(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

330
            ->orderBy(/** @scrutinizer ignore-type */ 'id');
Loading history...
331
332
        return $this->processManticoreTvShowsData(
333
            $indexName,
334
            $total,
0 ignored issues
show
Bug introduced by
It seems like $total can also be of type Illuminate\Database\Eloquent\Builder and Illuminate\Database\Eloq...gHasThroughRelationship; however, parameter $total of App\Console\Commands\Nnt...sManticoreTvShowsData() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

334
            /** @scrutinizer ignore-type */ $total,
Loading history...
335
            $query,
336
            function ($item) {
337
                return [
338
                    'id' => $item->id,
339
                    'title' => (string) ($item->title ?? ''),
340
                    'tvdb' => (int) ($item->tvdb ?? 0),
341
                    'trakt' => (int) ($item->trakt ?? 0),
342
                    'tvmaze' => (int) ($item->tvmaze ?? 0),
343
                    'tvrage' => (int) ($item->tvrage ?? 0),
344
                    'imdb' => (int) ($item->imdb ?? 0),
345
                    'tmdb' => (int) ($item->tmdb ?? 0),
346
                    'started' => (string) ($item->started ?? ''),
347
                    'type' => (int) ($item->type ?? 0),
348
                ];
349
            }
350
        );
351
    }
352
353
    /**
354
     * Process data for ManticoreSearch with optimizations
355
     */
356
    private function processManticoreData(string $indexName, int $total, $query, callable $transformer): int
357
    {
358
        $chunkSize = $this->getChunkSize();
359
        $batchSize = $this->getBatchSize();
360
361
        $this->optimizeDatabase();
362
        $this->setGroupConcatMaxLen();
363
364
        $this->info(sprintf(
365
            "Populating search index '%s' with %s rows using chunks of %s and batch size of %s.",
366
            $indexName,
367
            number_format($total),
368
            number_format($chunkSize),
369
            number_format($batchSize)
370
        ));
371
372
        $bar = $this->output->createProgressBar($total);
373
        $bar->setFormat('verbose');
374
        $bar->start();
375
376
        $processedCount = 0;
377
        $errorCount = 0;
378
        $batchData = [];
379
380
        try {
381
            $query->chunk($chunkSize, function ($items) use ($indexName, $transformer, $bar, &$processedCount, &$errorCount, $batchSize, &$batchData) {
382
                foreach ($items as $item) {
383
                    try {
384
                        $batchData[] = $transformer($item);
385
                        $processedCount++;
386
387
                        // Process in optimized batch sizes
388
                        if (count($batchData) >= $batchSize) {
389
                            $this->processBatch($indexName, $batchData);
390
                            $batchData = [];
391
                        }
392
                    } catch (Exception $e) {
393
                        $errorCount++;
394
                        if ($this->output->isVerbose()) {
395
                            $this->error("Error processing item {$item->id}: {$e->getMessage()}");
396
                        }
397
                    }
398
                    $bar->advance();
399
                }
400
            });
401
402
            // Process remaining items
403
            if (! empty($batchData)) {
404
                $this->processBatch($indexName, $batchData);
405
            }
406
407
            $bar->finish();
408
            $this->newLine();
409
410
            if ($errorCount > 0) {
411
                $this->warn("Completed with {$errorCount} errors out of {$processedCount} processed items.");
412
            } else {
413
                $this->info('Search index population completed successfully!');
414
            }
415
416
            return Command::SUCCESS;
417
418
        } catch (Exception $e) {
419
            $bar->finish();
420
            $this->newLine();
421
            $this->error("Failed to populate ManticoreSearch: {$e->getMessage()}");
422
423
            return Command::FAILURE;
424
        } finally {
425
            $this->restoreDatabase();
426
        }
427
    }
428
429
    /**
430
     * Process data for ManticoreSearch movies index
431
     */
432
    private function processManticoreMoviesData(string $indexName, int $total, $query, callable $transformer): int
433
    {
434
        $chunkSize = $this->getChunkSize();
435
        $batchSize = $this->getBatchSize();
436
437
        $this->optimizeDatabase();
438
439
        $this->info(sprintf(
440
            "Populating search index '%s' with %s rows using chunks of %s and batch size of %s.",
441
            $indexName,
442
            number_format($total),
443
            number_format($chunkSize),
444
            number_format($batchSize)
445
        ));
446
447
        $bar = $this->output->createProgressBar($total);
448
        $bar->setFormat('verbose');
449
        $bar->start();
450
451
        $processedCount = 0;
452
        $errorCount = 0;
453
        $batchData = [];
454
455
        try {
456
            $query->chunk($chunkSize, function ($items) use ($transformer, $bar, &$processedCount, &$errorCount, $batchSize, &$batchData) {
457
                foreach ($items as $item) {
458
                    try {
459
                        $batchData[] = $transformer($item);
460
                        $processedCount++;
461
462
                        // Process in optimized batch sizes
463
                        if (count($batchData) >= $batchSize) {
464
                            $this->processMoviesBatch($batchData);
465
                            $batchData = [];
466
                        }
467
                    } catch (Exception $e) {
468
                        $errorCount++;
469
                        if ($this->output->isVerbose()) {
470
                            $this->error("Error processing item {$item->id}: {$e->getMessage()}");
471
                        }
472
                    }
473
                    $bar->advance();
474
                }
475
            });
476
477
            // Process remaining items
478
            if (! empty($batchData)) {
479
                $this->processMoviesBatch($batchData);
480
            }
481
482
            $bar->finish();
483
            $this->newLine();
484
485
            if ($errorCount > 0) {
486
                $this->warn("Completed with {$errorCount} errors out of {$processedCount} processed items.");
487
            } else {
488
                $this->info('Movies index population completed successfully!');
489
            }
490
491
            return Command::SUCCESS;
492
493
        } catch (Exception $e) {
494
            $bar->finish();
495
            $this->newLine();
496
            $this->error("Failed to populate movies index: {$e->getMessage()}");
497
498
            return Command::FAILURE;
499
        } finally {
500
            $this->restoreDatabase();
501
        }
502
    }
503
504
    /**
505
     * Process data for ManticoreSearch TV shows index
506
     */
507
    private function processManticoreTvShowsData(string $indexName, int $total, $query, callable $transformer): int
508
    {
509
        $chunkSize = $this->getChunkSize();
510
        $batchSize = $this->getBatchSize();
511
512
        $this->optimizeDatabase();
513
514
        $this->info(sprintf(
515
            "Populating search index '%s' with %s rows using chunks of %s and batch size of %s.",
516
            $indexName,
517
            number_format($total),
518
            number_format($chunkSize),
519
            number_format($batchSize)
520
        ));
521
522
        $bar = $this->output->createProgressBar($total);
523
        $bar->setFormat('verbose');
524
        $bar->start();
525
526
        $processedCount = 0;
527
        $errorCount = 0;
528
        $batchData = [];
529
530
        try {
531
            $query->chunk($chunkSize, function ($items) use ($transformer, $bar, &$processedCount, &$errorCount, $batchSize, &$batchData) {
532
                foreach ($items as $item) {
533
                    try {
534
                        $batchData[] = $transformer($item);
535
                        $processedCount++;
536
537
                        // Process in optimized batch sizes
538
                        if (count($batchData) >= $batchSize) {
539
                            $this->processTvShowsBatch($batchData);
540
                            $batchData = [];
541
                        }
542
                    } catch (Exception $e) {
543
                        $errorCount++;
544
                        if ($this->output->isVerbose()) {
545
                            $this->error("Error processing item {$item->id}: {$e->getMessage()}");
546
                        }
547
                    }
548
                    $bar->advance();
549
                }
550
            });
551
552
            // Process remaining items
553
            if (! empty($batchData)) {
554
                $this->processTvShowsBatch($batchData);
555
            }
556
557
            $bar->finish();
558
            $this->newLine();
559
560
            if ($errorCount > 0) {
561
                $this->warn("Completed with {$errorCount} errors out of {$processedCount} processed items.");
562
            } else {
563
                $this->info('TV shows index population completed successfully!');
564
            }
565
566
            return Command::SUCCESS;
567
568
        } catch (Exception $e) {
569
            $bar->finish();
570
            $this->newLine();
571
            $this->error("Failed to populate TV shows index: {$e->getMessage()}");
572
573
            return Command::FAILURE;
574
        } finally {
575
            $this->restoreDatabase();
576
        }
577
    }
578
579
    /**
580
     * Populate ElasticSearch releases index
581
     */
582
    private function elasticReleases(): int
583
    {
584
        $total = Release::count();
585
        if (! $total) {
586
            $this->warn('Releases table is empty. Nothing to do.');
587
588
            return Command::SUCCESS;
589
        }
590
591
        $query = Release::query()
592
            ->orderByDesc('releases.id')
593
            ->leftJoin('release_files', 'releases.id', '=', 'release_files.releases_id')
594
            ->select([
595
                'releases.id',
596
                'releases.name',
597
                'releases.searchname',
598
                'releases.fromname',
599
                'releases.categories_id',
600
                'releases.postdate',
601
            ])
602
            ->selectRaw('IFNULL(GROUP_CONCAT(release_files.name SEPARATOR " "),"") AS filename')
603
            ->groupBy([
604
                'releases.id',
605
                'releases.name',
606
                'releases.searchname',
607
                'releases.fromname',
608
                'releases.categories_id',
609
                'releases.postdate',
610
            ]);
611
612
        return $this->processElasticData(
613
            'releases',
614
            $total,
0 ignored issues
show
Bug introduced by
It seems like $total can also be of type Illuminate\Database\Eloquent\Builder and Illuminate\Database\Eloq...gHasThroughRelationship; however, parameter $total of App\Console\Commands\Nnt...s::processElasticData() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

614
            /** @scrutinizer ignore-type */ $total,
Loading history...
615
            $query,
616
            function ($item) {
617
                $searchName = str_replace(['.', '-'], ' ', $item->searchname ?? '');
618
619
                return [
620
                    'id' => $item->id,
621
                    'name' => $item->name,
622
                    'searchname' => $item->searchname,
623
                    'plainsearchname' => $searchName,
624
                    'fromname' => $item->fromname,
625
                    'categories_id' => $item->categories_id,
626
                    'filename' => $item->filename ?? '',
627
                    'postdate' => $item->postdate,
628
                ];
629
            }
630
        );
631
    }
632
633
    /**
634
     * Populate ElasticSearch predb index
635
     */
636
    private function elasticPredb(): int
637
    {
638
        $total = Predb::count();
639
        if (! $total) {
640
            $this->warn('PreDB table is empty. Nothing to do.');
641
642
            return Command::SUCCESS;
643
        }
644
645
        $query = Predb::query()
646
            ->select(['id', 'title', 'filename', 'source'])
647
            ->orderBy('id');
0 ignored issues
show
Bug introduced by
'id' of type string is incompatible with the type Closure|Illuminate\Datab...\Database\Query\Builder expected by parameter $column of Illuminate\Database\Query\Builder::orderBy(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

647
            ->orderBy(/** @scrutinizer ignore-type */ 'id');
Loading history...
648
649
        return $this->processElasticData(
650
            'predb',
651
            $total,
0 ignored issues
show
Bug introduced by
It seems like $total can also be of type Illuminate\Database\Eloquent\Builder and Illuminate\Database\Eloq...gHasThroughRelationship; however, parameter $total of App\Console\Commands\Nnt...s::processElasticData() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

651
            /** @scrutinizer ignore-type */ $total,
Loading history...
652
            $query,
653
            function ($item) {
654
                return [
655
                    'id' => $item->id,
656
                    'title' => $item->title,
657
                    'filename' => $item->filename,
658
                    'source' => $item->source,
659
                ];
660
            }
661
        );
662
    }
663
664
    /**
665
     * Process data for ElasticSearch with optimizations
666
     */
667
    private function processElasticData(string $indexName, int $total, $query, callable $transformer): int
668
    {
669
        $chunkSize = $this->getChunkSize();
670
        $batchSize = $this->getBatchSize();
671
672
        $this->optimizeDatabase();
673
        $this->setGroupConcatMaxLen();
674
675
        $this->info(sprintf(
676
            "Populating ElasticSearch index '%s' with %s rows using chunks of %s and batch size of %s.",
677
            $indexName,
678
            number_format($total),
679
            number_format($chunkSize),
680
            number_format($batchSize)
681
        ));
682
683
        $bar = $this->output->createProgressBar($total);
684
        $bar->setFormat('verbose');
685
        $bar->start();
686
687
        $processedCount = 0;
688
        $errorCount = 0;
689
690
        try {
691
            $query->chunk($chunkSize, function ($items) use ($indexName, $transformer, $bar, &$processedCount, &$errorCount, $batchSize) {
692
                // Process in optimized batches for ElasticSearch
693
                foreach ($items->chunk($batchSize) as $batch) {
694
                    $data = ['body' => []];
695
696
                    foreach ($batch as $item) {
697
                        try {
698
                            $transformedData = $transformer($item);
699
700
                            $data['body'][] = [
701
                                'index' => [
702
                                    '_index' => $indexName,
703
                                    '_id' => $item->id,
704
                                ],
705
                            ];
706
                            $data['body'][] = $transformedData;
707
708
                            $processedCount++;
709
                        } catch (Exception $e) {
710
                            $errorCount++;
711
                            if ($this->output->isVerbose()) {
712
                                $this->error("Error processing item {$item->id}: {$e->getMessage()}");
713
                            }
714
                        }
715
716
                        $bar->advance();
717
                    }
718
719
                    if (! empty($data['body'])) {
720
                        $this->processElasticBatch($data, $errorCount);
721
                    }
722
                }
723
            });
724
725
            $bar->finish();
726
            $this->newLine();
727
728
            if ($errorCount > 0) {
729
                $this->warn("Completed with {$errorCount} errors out of {$processedCount} processed items.");
730
            } else {
731
                $this->info('ElasticSearch population completed successfully!');
732
            }
733
734
            return Command::SUCCESS;
735
736
        } catch (Exception $e) {
737
            $bar->finish();
738
            $this->newLine();
739
            $this->error("Failed to populate ElasticSearch: {$e->getMessage()}");
740
741
            return Command::FAILURE;
742
        } finally {
743
            $this->restoreDatabase();
744
        }
745
    }
746
747
    /**
748
     * Process search index batch with retry logic
749
     */
750
    private function processBatch(string $indexName, array $data): void
751
    {
752
        $retries = 3;
753
        $attempt = 0;
754
755
        while ($attempt < $retries) {
756
            try {
757
                // Use the correct bulk insert method based on index name
758
                if ($indexName === 'releases_rt') {
759
                    Search::bulkInsertReleases($data);
760
                } elseif ($indexName === 'predb_rt') {
761
                    Search::bulkInsertPredb($data);
762
                } else {
763
                    throw new Exception("Unknown index: {$indexName}");
764
                }
765
                break;
766
            } catch (Exception $e) {
767
                $attempt++;
768
                if ($attempt >= $retries) {
769
                    throw $e;
770
                }
771
                usleep(100000); // 100ms delay before retry
772
            }
773
        }
774
    }
775
776
    /**
777
     * Process movies batch with retry logic
778
     */
779
    private function processMoviesBatch(array $data): void
780
    {
781
        $retries = 3;
782
        $attempt = 0;
783
784
        while ($attempt < $retries) {
785
            try {
786
                Search::bulkInsertMovies($data);
787
                break;
788
            } catch (Exception $e) {
789
                $attempt++;
790
                if ($attempt >= $retries) {
791
                    throw $e;
792
                }
793
                usleep(100000); // 100ms delay before retry
794
            }
795
        }
796
    }
797
798
    /**
799
     * Process TV shows batch with retry logic
800
     */
801
    private function processTvShowsBatch(array $data): void
802
    {
803
        $retries = 3;
804
        $attempt = 0;
805
806
        while ($attempt < $retries) {
807
            try {
808
                Search::bulkInsertTvShows($data);
809
                break;
810
            } catch (Exception $e) {
811
                $attempt++;
812
                if ($attempt >= $retries) {
813
                    throw $e;
814
                }
815
                usleep(100000); // 100ms delay before retry
816
            }
817
        }
818
    }
819
820
    /**
821
     * Process ElasticSearch batch with retry logic
822
     */
823
    private function processElasticBatch(array $data, int &$errorCount): void
824
    {
825
        $retries = 3;
826
        $attempt = 0;
827
828
        while ($attempt < $retries) {
829
            try {
830
                $response = \Elasticsearch::bulk($data);
831
832
                // Check for errors in bulk response
833
                if (isset($response['errors']) && $response['errors']) {
834
                    foreach ($response['items'] as $item) {
835
                        if (isset($item['index']['error'])) {
836
                            $errorCount++;
837
                            if ($this->output->isVerbose()) {
838
                                $this->error('ElasticSearch error: '.json_encode($item['index']['error']));
839
                            }
840
                        }
841
                    }
842
                }
843
                break;
844
            } catch (Exception $e) {
845
                $attempt++;
846
                if ($attempt >= $retries) {
847
                    throw $e;
848
                }
849
                usleep(100000); // 100ms delay before retry
850
            }
851
        }
852
    }
853
854
    /**
855
     * Optimize database settings for bulk operations
856
     */
857
    private function optimizeDatabase(): void
858
    {
859
        if ($this->option('disable-keys')) {
860
            $this->info('Disabling database keys for faster bulk operations...');
861
862
            try {
863
                // Disable foreign key checks
864
                DB::statement('SET FOREIGN_KEY_CHECKS = 0');
865
                DB::statement('SET UNIQUE_CHECKS = 0');
866
                DB::statement('SET AUTOCOMMIT = 0');
867
868
                // Increase buffer sizes
869
                DB::statement('SET SESSION innodb_buffer_pool_size = 1073741824'); // 1GB
870
                DB::statement('SET SESSION bulk_insert_buffer_size = 268435456'); // 256MB
871
                DB::statement('SET SESSION read_buffer_size = 2097152'); // 2MB
872
                DB::statement('SET SESSION sort_buffer_size = 16777216'); // 16MB
873
874
            } catch (Exception $e) {
875
                $this->warn("Could not optimize database settings: {$e->getMessage()}");
876
            }
877
        }
878
    }
879
880
    /**
881
     * Restore database settings after bulk operations
882
     */
883
    private function restoreDatabase(): void
884
    {
885
        if ($this->option('disable-keys')) {
886
            $this->info('Restoring database settings...');
887
888
            try {
889
                DB::statement('SET FOREIGN_KEY_CHECKS = 1');
890
                DB::statement('SET UNIQUE_CHECKS = 1');
891
                DB::statement('SET AUTOCOMMIT = 1');
892
                DB::statement('COMMIT');
893
            } catch (Exception $e) {
894
                $this->warn("Could not restore database settings: {$e->getMessage()}");
895
            }
896
        }
897
    }
898
899
    /**
900
     * Get the chunk size from options
901
     */
902
    private function getChunkSize(): int
903
    {
904
        $chunkSize = (int) $this->option('count');
905
906
        return $chunkSize > 0 ? $chunkSize : self::DEFAULT_CHUNK_SIZE;
907
    }
908
909
    /**
910
     * Get the batch size from options
911
     */
912
    private function getBatchSize(): int
913
    {
914
        $batchSize = (int) $this->option('batch-size');
915
916
        return $batchSize > 0 ? $batchSize : self::DEFAULT_BATCH_SIZE;
917
    }
918
919
    /**
920
     * Set the GROUP_CONCAT max length for the session
921
     */
922
    private function setGroupConcatMaxLen(): void
923
    {
924
        DB::statement('SET SESSION group_concat_max_len = ?', [self::GROUP_CONCAT_MAX_LEN]);
925
    }
926
}
927