UpdateReleasesIndexSchemaES::handle()   C
last analyzed

Complexity

Conditions 12
Paths 43

Size

Total Lines 53
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 27
c 1
b 0
f 0
dl 0
loc 53
rs 6.9666
cc 12
nc 43
nop 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace App\Console\Commands;
4
5
use App\Models\Release;
6
use Elasticsearch;
7
use Elasticsearch\Common\Exceptions\Missing404Exception;
8
use Illuminate\Console\Command;
9
use Illuminate\Support\Facades\DB;
10
use Illuminate\Support\Facades\Log;
11
12
class UpdateReleasesIndexSchemaES extends Command
13
{
14
    /**
15
     * The name and signature of the console command.
16
     *
17
     * @var string
18
     */
19
    protected $signature = 'nntmux:update-releases-index-es
20
                                        {--add-fields : Add new media-related fields to releases index}
21
                                        {--update-media-ids : Update existing indexed releases with media IDs from database}
22
                                        {--batch-size=1000 : Batch size for bulk update operations}
23
                                        {--movies-only : Only update releases with movie info}
24
                                        {--tv-only : Only update releases with TV show info}
25
                                        {--missing-only : Only update releases that have zero media IDs in index}
26
                                        {--force : Force schema update even if fields exist}
27
                                        {--recreate-index : Recreate the index with new schema (will delete existing data)}';
28
29
    /**
30
     * The console command description.
31
     *
32
     * @var string
33
     */
34
    protected $description = 'Update ElasticSearch releases index schema with new media fields and/or populate media IDs for existing releases';
35
36
    /**
37
     * The media fields that should exist in the releases index
38
     */
39
    private array $mediaFields = [
40
        'imdbid' => ['type' => 'integer'],
41
        'tmdbid' => ['type' => 'integer'],
42
        'traktid' => ['type' => 'integer'],
43
        'tvdb' => ['type' => 'integer'],
44
        'tvmaze' => ['type' => 'integer'],
45
        'tvrage' => ['type' => 'integer'],
46
        'videos_id' => ['type' => 'integer'],
47
        'movieinfo_id' => ['type' => 'integer'],
48
    ];
49
50
    /**
51
     * Execute the console command.
52
     */
53
    public function handle(): int
54
    {
55
        $driver = config('search.default', 'manticore');
56
57
        if ($driver !== 'elasticsearch') {
58
            $this->warn("Current search driver is '{$driver}'. This command is for ElasticSearch.");
59
            if (! $this->confirm('Do you want to continue anyway?', false)) {
60
                return Command::SUCCESS;
61
            }
62
        }
63
64
        $this->info('ElasticSearch releases index schema update utility');
65
        $this->newLine();
66
67
        // Test connection
68
        try {
69
            $health = Elasticsearch::cluster()->health();
70
            $this->info("Connected to ElasticSearch. Cluster status: {$health['status']}");
71
        } catch (\Exception $e) {
72
            $this->error('Failed to connect to ElasticSearch: '.$e->getMessage());
73
74
            return Command::FAILURE;
75
        }
76
77
        $result = Command::SUCCESS;
78
79
        // Handle recreate-index option
80
        if ($this->option('recreate-index')) {
81
            $result = $this->recreateIndexWithNewSchema();
82
            if ($result !== Command::SUCCESS) {
83
                return $result;
84
            }
85
        }
86
87
        // Handle add-fields option
88
        if ($this->option('add-fields')) {
89
            $result = $this->addNewFields();
90
            if ($result !== Command::SUCCESS) {
91
                return $result;
92
            }
93
        }
94
95
        // Handle update-media-ids option
96
        if ($this->option('update-media-ids')) {
97
            $result = $this->updateMediaIds();
98
        }
99
100
        // If no options specified, show current schema info
101
        if (! $this->option('add-fields') && ! $this->option('update-media-ids') && ! $this->option('recreate-index')) {
102
            $this->showSchemaInfo();
103
        }
104
105
        return $result;
106
    }
107
108
    /**
109
     * Show current index schema information
110
     */
111
    private function showSchemaInfo(): void
112
    {
113
        $this->info('Current releases index schema:');
114
        $this->newLine();
115
116
        try {
117
            $indexName = config('search.drivers.elasticsearch.indexes.releases', 'releases');
118
119
            if (! Elasticsearch::indices()->exists(['index' => $indexName])) {
120
                $this->warn("Index '{$indexName}' does not exist.");
121
                $this->info('Run `php artisan nntmux:create-es-indexes` to create the index first.');
122
123
                return;
124
            }
125
126
            $mapping = Elasticsearch::indices()->getMapping(['index' => $indexName]);
127
            $properties = $mapping[$indexName]['mappings']['properties'] ?? [];
128
129
            if (empty($properties)) {
130
                $this->warn('Index has no mapped properties.');
131
132
                return;
133
            }
134
135
            $headers = ['Field', 'Type', 'Properties'];
136
            $rows = [];
137
            $existingFields = [];
138
139
            foreach ($properties as $field => $config) {
140
                $type = $config['type'] ?? 'unknown';
141
                $props = [];
142
                if (isset($config['analyzer'])) {
143
                    $props[] = "analyzer: {$config['analyzer']}";
144
                }
145
                if (isset($config['index']) && ! $config['index']) {
146
                    $props[] = 'not indexed';
147
                }
148
                if (isset($config['fields'])) {
149
                    $props[] = 'multi-field';
150
                }
151
                $rows[] = [$field, $type, implode(', ', $props)];
152
                $existingFields[$field] = $type;
153
            }
154
155
            $this->table($headers, $rows);
156
            $this->newLine();
157
158
            // Check for missing media fields
159
            $missingFields = [];
160
            foreach ($this->mediaFields as $field => $config) {
161
                if (! isset($existingFields[$field])) {
162
                    $missingFields[] = $field;
163
                }
164
            }
165
166
            if (! empty($missingFields)) {
167
                $this->warn('Missing media fields that should be added:');
168
                foreach ($missingFields as $field) {
169
                    $this->line("  - {$field} ({$this->mediaFields[$field]['type']})");
170
                }
171
                $this->newLine();
172
                $this->info('Run with --add-fields to add missing fields, or --recreate-index to rebuild with new schema.');
173
            } else {
174
                $this->info('All expected media fields are present in the index.');
175
            }
176
177
            // Show usage hints
178
            $this->newLine();
179
            $this->info('Available options:');
180
            $this->line('  --add-fields         Add missing media-related fields to the index');
181
            $this->line('  --recreate-index     Recreate index with new schema (data will be lost)');
182
            $this->line('  --update-media-ids   Update indexed releases with media IDs from database');
183
            $this->line('  --movies-only        Only update releases with movie info');
184
            $this->line('  --tv-only            Only update releases with TV show info');
185
            $this->line('  --missing-only       Only update releases with zero media IDs');
186
            $this->line('  --batch-size=N       Set batch size for updates (default: 1000)');
187
188
        } catch (\Throwable $e) {
189
            $this->error('Failed to get index mapping: '.$e->getMessage());
190
            $this->info('The index may not exist. Run `php artisan nntmux:create-es-indexes` first.');
191
        }
192
    }
193
194
    /**
195
     * Add new fields to the releases index using mapping update
196
     *
197
     * Note: Elasticsearch only allows adding new fields, not modifying existing ones
198
     */
199
    private function addNewFields(): int
200
    {
201
        $this->info('Checking for missing fields in releases index...');
202
203
        try {
204
            $indexName = config('search.drivers.elasticsearch.indexes.releases', 'releases');
205
206
            if (! Elasticsearch::indices()->exists(['index' => $indexName])) {
207
                $this->error("Index '{$indexName}' does not exist. Create it first with: php artisan nntmux:create-es-indexes");
208
209
                return Command::FAILURE;
210
            }
211
212
            // Get current mapping
213
            $mapping = Elasticsearch::indices()->getMapping(['index' => $indexName]);
214
            $existingProperties = $mapping[$indexName]['mappings']['properties'] ?? [];
215
216
            // Find fields to add
217
            $fieldsToAdd = [];
218
            foreach ($this->mediaFields as $field => $config) {
219
                if (! isset($existingProperties[$field]) || $this->option('force')) {
220
                    $fieldsToAdd[$field] = $config;
221
                }
222
            }
223
224
            if (empty($fieldsToAdd)) {
225
                $this->info('All media fields already exist in the index.');
226
227
                return Command::SUCCESS;
228
            }
229
230
            $this->warn('The following fields will be added:');
231
            foreach ($fieldsToAdd as $field => $config) {
232
                $this->line("  - {$field} ({$config['type']})");
233
            }
234
235
            if (! $this->confirm('Do you want to proceed with adding these fields?', true)) {
236
                $this->info('Operation cancelled.');
237
238
                return Command::SUCCESS;
239
            }
240
241
            // Add new fields via PUT mapping
242
            $newProperties = [];
243
            foreach ($fieldsToAdd as $field => $config) {
244
                $newProperties[$field] = $config;
245
            }
246
247
            Elasticsearch::indices()->putMapping([
248
                'index' => $indexName,
249
                'body' => [
250
                    'properties' => $newProperties,
251
                ],
252
            ]);
253
254
            $this->info('Schema update completed successfully!');
255
            $this->newLine();
256
            $this->info('Now you can update existing releases with media IDs using:');
257
            $this->line('  php artisan nntmux:update-releases-index-es --update-media-ids');
258
259
            return Command::SUCCESS;
260
261
        } catch (\Throwable $e) {
262
            $this->error('Failed to update schema: '.$e->getMessage());
263
264
            return Command::FAILURE;
265
        }
266
    }
267
268
    /**
269
     * Recreate the index with the new schema including media fields
270
     * WARNING: This will delete all existing data in the index
271
     */
272
    private function recreateIndexWithNewSchema(): int
273
    {
274
        $indexName = config('search.drivers.elasticsearch.indexes.releases', 'releases');
275
276
        $this->warn("WARNING: This will DELETE all data in the '{$indexName}' index and recreate it with the new schema!");
277
        $this->warn('You will need to re-populate the index after this operation.');
278
279
        if (! $this->confirm('Are you sure you want to proceed?', false)) {
280
            $this->info('Operation cancelled.');
281
282
            return Command::SUCCESS;
283
        }
284
285
        try {
286
            // Delete existing index if it exists
287
            if (Elasticsearch::indices()->exists(['index' => $indexName])) {
288
                $this->info("Deleting existing '{$indexName}' index...");
289
                Elasticsearch::indices()->delete(['index' => $indexName]);
290
            }
291
292
            // Create new index with updated schema
293
            $this->info("Creating '{$indexName}' index with new schema...");
294
295
            $releasesIndex = [
296
                'index' => $indexName,
297
                'body' => [
298
                    'settings' => [
299
                        'number_of_shards' => 2,
300
                        'number_of_replicas' => 0,
301
                        'analysis' => [
302
                            'analyzer' => [
303
                                'release_analyzer' => [
304
                                    'type' => 'custom',
305
                                    'tokenizer' => 'standard',
306
                                    'filter' => ['lowercase', 'asciifolding'],
307
                                ],
308
                            ],
309
                        ],
310
                    ],
311
                    'mappings' => [
312
                        'properties' => [
313
                            'id' => [
314
                                'type' => 'long',
315
                                'index' => false,
316
                            ],
317
                            'name' => [
318
                                'type' => 'text',
319
                                'analyzer' => 'release_analyzer',
320
                            ],
321
                            'searchname' => [
322
                                'type' => 'text',
323
                                'analyzer' => 'release_analyzer',
324
                                'fields' => [
325
                                    'keyword' => [
326
                                        'type' => 'keyword',
327
                                        'ignore_above' => 256,
328
                                    ],
329
                                    'sort' => [
330
                                        'type' => 'keyword',
331
                                    ],
332
                                ],
333
                            ],
334
                            'plainsearchname' => [
335
                                'type' => 'text',
336
                                'analyzer' => 'release_analyzer',
337
                                'fields' => [
338
                                    'keyword' => [
339
                                        'type' => 'keyword',
340
                                        'ignore_above' => 256,
341
                                    ],
342
                                    'sort' => [
343
                                        'type' => 'keyword',
344
                                    ],
345
                                ],
346
                            ],
347
                            'categories_id' => [
348
                                'type' => 'integer',
349
                            ],
350
                            'fromname' => [
351
                                'type' => 'text',
352
                                'analyzer' => 'release_analyzer',
353
                            ],
354
                            'filename' => [
355
                                'type' => 'text',
356
                                'analyzer' => 'release_analyzer',
357
                            ],
358
                            'add_date' => [
359
                                'type' => 'date',
360
                                'format' => 'yyyy-MM-dd HH:mm:ss',
361
                            ],
362
                            'post_date' => [
363
                                'type' => 'date',
364
                                'format' => 'yyyy-MM-dd HH:mm:ss',
365
                            ],
366
                            // New media-related fields
367
                            'imdbid' => ['type' => 'integer'],
368
                            'tmdbid' => ['type' => 'integer'],
369
                            'traktid' => ['type' => 'integer'],
370
                            'tvdb' => ['type' => 'integer'],
371
                            'tvmaze' => ['type' => 'integer'],
372
                            'tvrage' => ['type' => 'integer'],
373
                            'videos_id' => ['type' => 'integer'],
374
                            'movieinfo_id' => ['type' => 'integer'],
375
                        ],
376
                    ],
377
                ],
378
            ];
379
380
            Elasticsearch::indices()->create($releasesIndex);
381
382
            $this->info("Index '{$indexName}' created successfully with new schema!");
383
            $this->newLine();
384
            $this->warn('Remember to re-populate the index:');
385
            $this->line('  php artisan nntmux:populate-search-indexes --elastic --releases');
386
387
            return Command::SUCCESS;
388
389
        } catch (\Throwable $e) {
390
            $this->error('Failed to recreate index: '.$e->getMessage());
391
392
            return Command::FAILURE;
393
        }
394
    }
395
396
    /**
397
     * Update existing indexed releases with media IDs from database
398
     */
399
    private function updateMediaIds(): int
400
    {
401
        $this->info('Updating indexed releases with media IDs from database...');
402
403
        $batchSize = (int) $this->option('batch-size');
404
        $moviesOnly = $this->option('movies-only');
405
        $tvOnly = $this->option('tv-only');
406
        $missingOnly = $this->option('missing-only');
407
408
        $indexName = config('search.drivers.elasticsearch.indexes.releases', 'releases');
409
410
        // Check if index exists
411
        if (! Elasticsearch::indices()->exists(['index' => $indexName])) {
412
            $this->error("Index '{$indexName}' does not exist. Create it first.");
413
414
            return Command::FAILURE;
415
        }
416
417
        // Build query based on options
418
        $query = Release::query()
419
            ->leftJoin('movieinfo', 'releases.movieinfo_id', '=', 'movieinfo.id')
420
            ->leftJoin('videos', 'releases.videos_id', '=', 'videos.id')
421
            ->select([
422
                'releases.id',
423
                'releases.videos_id',
424
                'releases.movieinfo_id',
425
                // Movie external IDs
426
                'movieinfo.imdbid',
427
                'movieinfo.tmdbid',
428
                'movieinfo.traktid',
429
                // TV show external IDs
430
                'videos.tvdb',
431
                'videos.tvmaze',
432
                'videos.tvrage',
433
                DB::raw('videos.trakt as video_trakt'),
434
                DB::raw('videos.imdb as video_imdb'),
435
                DB::raw('videos.tmdb as video_tmdb'),
436
            ]);
437
438
        // Apply filters based on options
439
        if ($moviesOnly) {
440
            $query->whereNotNull('releases.movieinfo_id')
441
                ->where('releases.movieinfo_id', '>', 0);
442
            $this->info('Filtering: Movies only (releases with movieinfo_id)');
443
        } elseif ($tvOnly) {
444
            $query->whereNotNull('releases.videos_id')
445
                ->where('releases.videos_id', '>', 0);
446
            $this->info('Filtering: TV shows only (releases with videos_id)');
447
        } else {
448
            // Get releases that have either movie or TV info
449
            $query->where(function ($q) {
450
                $q->where(function ($subq) {
451
                    $subq->whereNotNull('releases.movieinfo_id')
452
                        ->where('releases.movieinfo_id', '>', 0);
453
                })->orWhere(function ($subq) {
454
                    $subq->whereNotNull('releases.videos_id')
455
                        ->where('releases.videos_id', '>', 0);
456
                });
457
            });
458
            $this->info('Filtering: Releases with either movie or TV info');
459
        }
460
461
        $total = $query->count();
462
463
        if ($total === 0) {
464
            $this->warn('No releases found matching the criteria.');
465
466
            return Command::SUCCESS;
467
        }
468
469
        $this->info("Found {$total} releases to update.");
470
471
        if (! $this->confirm('Do you want to proceed with the update?', true)) {
472
            $this->info('Operation cancelled.');
473
474
            return Command::SUCCESS;
475
        }
476
477
        $bar = $this->output->createProgressBar($total);
0 ignored issues
show
Bug introduced by
It seems like $total can also be of type Illuminate\Database\Eloquent\Builder and Illuminate\Database\Query\Builder; however, parameter $max of Symfony\Component\Consol...le::createProgressBar() 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

477
        $bar = $this->output->createProgressBar(/** @scrutinizer ignore-type */ $total);
Loading history...
478
        $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%');
479
        $bar->start();
480
481
        $updated = 0;
482
        $errors = 0;
483
        $skipped = 0;
484
485
        // Process in batches using chunk
486
        $query->orderBy('releases.id')
0 ignored issues
show
Bug introduced by
'releases.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

486
        $query->orderBy(/** @scrutinizer ignore-type */ 'releases.id')
Loading history...
487
            ->chunk($batchSize, function ($releases) use (&$updated, &$errors, &$skipped, $indexName, $bar, $missingOnly) {
488
                $bulkParams = ['body' => []];
489
490
                foreach ($releases as $release) {
491
                    // Prepare the update data
492
                    $mediaData = $this->prepareMediaData($release);
493
494
                    // Skip if all media IDs are zero
495
                    $hasMediaIds = array_filter($mediaData, fn ($v) => $v > 0);
496
                    if (empty($hasMediaIds)) {
497
                        $skipped++;
498
                        $bar->advance();
499
500
                        continue;
501
                    }
502
503
                    // If missing-only, check if the document already has media IDs
504
                    if ($missingOnly && $this->documentHasMediaIds($indexName, $release->id)) {
505
                        $skipped++;
506
                        $bar->advance();
507
508
                        continue;
509
                    }
510
511
                    // Add to bulk update
512
                    $bulkParams['body'][] = [
513
                        'update' => [
514
                            '_index' => $indexName,
515
                            '_id' => $release->id,
516
                        ],
517
                    ];
518
                    $bulkParams['body'][] = [
519
                        'doc' => $mediaData,
520
                        'doc_as_upsert' => false, // Don't create if doesn't exist
521
                    ];
522
523
                    $bar->advance();
524
                }
525
526
                // Execute bulk update
527
                if (! empty($bulkParams['body'])) {
528
                    try {
529
                        $response = Elasticsearch::bulk($bulkParams);
530
531
                        if (isset($response['errors']) && $response['errors']) {
532
                            foreach ($response['items'] as $item) {
533
                                if (isset($item['update']['error'])) {
534
                                    $errors++;
535
                                    if ($errors <= 5) {
536
                                        Log::warning('Failed to update release in ES: '.json_encode($item['update']['error']));
537
                                    }
538
                                } else {
539
                                    $updated++;
540
                                }
541
                            }
542
                        } else {
543
                            $updated += count($response['items']);
544
                        }
545
                    } catch (\Throwable $e) {
546
                        $errors += count($bulkParams['body']) / 2;
547
                        if ($errors <= 5) {
548
                            Log::error('Bulk update failed: '.$e->getMessage());
549
                        }
550
                    }
551
                }
552
            });
553
554
        $bar->finish();
555
        $this->newLine(2);
556
557
        $this->info('Update completed!');
558
        $this->line("  - Updated: {$updated}");
559
        $this->line("  - Skipped: {$skipped}");
560
        if ($errors > 0) {
561
            $this->warn("  - Errors: {$errors}");
562
        }
563
564
        // Refresh index to make changes visible
565
        $this->info('Refreshing index...');
566
        Elasticsearch::indices()->refresh(['index' => $indexName]);
567
        $this->info('Done!');
568
569
        return Command::SUCCESS;
570
    }
571
572
    /**
573
     * Prepare media data for a release
574
     */
575
    private function prepareMediaData($release): array
576
    {
577
        return [
578
            'imdbid' => (int) ($release->imdbid ?: 0),
579
            'tmdbid' => (int) ($release->tmdbid ?: ($release->video_tmdb ?: 0)),
580
            'traktid' => (int) ($release->traktid ?: ($release->video_trakt ?: 0)),
581
            'tvdb' => (int) ($release->tvdb ?: 0),
582
            'tvmaze' => (int) ($release->tvmaze ?: 0),
583
            'tvrage' => (int) ($release->tvrage ?: 0),
584
            'videos_id' => (int) ($release->videos_id ?: 0),
585
            'movieinfo_id' => (int) ($release->movieinfo_id ?: 0),
586
        ];
587
    }
588
589
    /**
590
     * Check if a document already has media IDs in the index
591
     */
592
    private function documentHasMediaIds(string $indexName, int $id): bool
593
    {
594
        try {
595
            $doc = Elasticsearch::get([
596
                'index' => $indexName,
597
                'id' => $id,
598
                '_source' => ['imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze', 'tvrage'],
599
            ]);
600
601
            $source = $doc['_source'] ?? [];
602
603
            // Check if any media ID is non-zero
604
            return ($source['imdbid'] ?? 0) > 0
605
                || ($source['tmdbid'] ?? 0) > 0
606
                || ($source['traktid'] ?? 0) > 0
607
                || ($source['tvdb'] ?? 0) > 0
608
                || ($source['tvmaze'] ?? 0) > 0
609
                || ($source['tvrage'] ?? 0) > 0;
610
611
        } catch (Missing404Exception $e) {
612
            // Document doesn't exist
613
            return false;
614
        } catch (\Throwable $e) {
615
            // Other error, assume no media IDs
616
            return false;
617
        }
618
    }
619
}
620