UpdateReleasesIndexSchema::prepareMediaData()   B
last analyzed

Complexity

Conditions 11
Paths 1

Size

Total Lines 11
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 9
c 1
b 0
f 0
dl 0
loc 11
rs 7.3166
cc 11
nc 1
nop 1

How to fix   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\Facades\Search;
6
use App\Models\Release;
7
use Illuminate\Console\Command;
8
use Illuminate\Support\Facades\DB;
9
use Illuminate\Support\Facades\Log;
10
use Manticoresearch\Client;
11
use Manticoresearch\Exceptions\ResponseException;
12
13
class UpdateReleasesIndexSchema extends Command
14
{
15
    /**
16
     * The name and signature of the console command.
17
     *
18
     * @var string
19
     */
20
    protected $signature = 'nntmux:update-releases-index
21
                                        {--add-fields : Add new media-related fields to releases_rt index}
22
                                        {--update-media-ids : Update existing indexed releases with media IDs from database}
23
                                        {--batch-size=5000 : Batch size for bulk update operations}
24
                                        {--movies-only : Only update releases with movie info}
25
                                        {--tv-only : Only update releases with TV show info}
26
                                        {--missing-only : Only update releases that have zero media IDs in index}
27
                                        {--force : Force schema update even if fields exist}';
28
29
    /**
30
     * The console command description.
31
     *
32
     * @var string
33
     */
34
    protected $description = 'Update releases_rt search index schema with new media fields and/or populate media IDs for existing releases';
35
36
    /**
37
     * The expected schema fields for releases_rt
38
     */
39
    private array $expectedFields = [
40
        'name' => ['type' => 'text'],
41
        'searchname' => ['type' => 'text'],
42
        'fromname' => ['type' => 'text'],
43
        'filename' => ['type' => 'text'],
44
        'categories_id' => ['type' => 'int'],
45
        // External media IDs for efficient searching
46
        'imdbid' => ['type' => 'int'],
47
        'tmdbid' => ['type' => 'int'],
48
        'traktid' => ['type' => 'int'],
49
        'tvdb' => ['type' => 'int'],
50
        'tvmaze' => ['type' => 'int'],
51
        'tvrage' => ['type' => 'int'],
52
        'videos_id' => ['type' => 'int'],
53
        'movieinfo_id' => ['type' => 'int'],
54
    ];
55
56
    protected Client $client;
57
58
    /**
59
     * Execute the console command.
60
     */
61
    public function handle(): int
62
    {
63
        $driver = config('search.default', 'manticore');
64
65
        if ($driver !== 'manticore') {
66
            $this->error("This command currently only supports ManticoreSearch. Current driver: {$driver}");
67
68
            return Command::FAILURE;
69
        }
70
71
        $this->info('Releases index schema update utility');
72
        $this->newLine();
73
74
        // Initialize ManticoreSearch client
75
        $host = config('search.drivers.manticore.host', '127.0.0.1');
76
        $port = config('search.drivers.manticore.port', 9308);
77
78
        $this->client = new Client([
79
            'host' => $host,
80
            'port' => $port,
81
        ]);
82
83
        // Test connection
84
        try {
85
            $this->client->nodes()->status();
86
            $this->info('Connected to ManticoreSearch successfully.');
87
        } catch (\Exception $e) {
88
            $this->error('Failed to connect to ManticoreSearch: '.$e->getMessage());
89
90
            return Command::FAILURE;
91
        }
92
93
        $result = Command::SUCCESS;
94
95
        // Handle add-fields option
96
        if ($this->option('add-fields')) {
97
            $result = $this->addNewFields();
98
            if ($result !== Command::SUCCESS) {
99
                return $result;
100
            }
101
        }
102
103
        // Handle update-media-ids option
104
        if ($this->option('update-media-ids')) {
105
            $result = $this->updateMediaIds();
106
        }
107
108
        // If no options specified, show current schema info
109
        if (! $this->option('add-fields') && ! $this->option('update-media-ids')) {
110
            $this->showSchemaInfo();
111
        }
112
113
        return $result;
114
    }
115
116
    /**
117
     * Show current index schema information
118
     */
119
    private function showSchemaInfo(): void
120
    {
121
        $this->info('Current releases_rt index schema:');
122
        $this->newLine();
123
124
        try {
125
            // Use the table describe method instead of raw SQL
126
            $columns = $this->client->table('releases_rt')->describe();
127
128
            if (empty($columns)) {
129
                $this->warn('Index releases_rt does not exist or has no columns.');
130
                $this->info('Run `php artisan manticore:create-indexes` to create the index first.');
131
132
                return;
133
            }
134
135
            $headers = ['Field', 'Type', 'Properties'];
136
            $rows = [];
137
            $existingFields = [];
138
139
            foreach ($columns as $field => $props) {
140
                $type = $props['Type'] ?? 'unknown';
141
                $properties = isset($props['Properties']) ? implode(', ', (array) $props['Properties']) : '';
142
                $rows[] = [$field, $type, $properties];
143
                $existingFields[$field] = strtolower($type);
144
            }
145
146
            $this->table($headers, $rows);
147
            $this->newLine();
148
149
            // Check for missing fields
150
            $missingFields = [];
151
            foreach ($this->expectedFields as $field => $config) {
152
                if (! isset($existingFields[$field]) && $field !== 'id') {
153
                    $missingFields[] = $field;
154
                }
155
            }
156
157
            if (! empty($missingFields)) {
158
                $this->warn('Missing fields that should be added:');
159
                foreach ($missingFields as $field) {
160
                    $this->line("  - {$field} ({$this->expectedFields[$field]['type']})");
161
                }
162
                $this->newLine();
163
                $this->info('Run with --add-fields to add missing fields.');
164
            } else {
165
                $this->info('All expected fields are present in the index.');
166
            }
167
168
            // Show usage hints
169
            $this->newLine();
170
            $this->info('Available options:');
171
            $this->line('  --add-fields         Add missing media-related fields to the index');
172
            $this->line('  --update-media-ids   Update indexed releases with media IDs from database');
173
            $this->line('  --movies-only        Only update releases with movie info');
174
            $this->line('  --tv-only            Only update releases with TV show info');
175
            $this->line('  --missing-only       Only update releases with zero media IDs');
176
            $this->line('  --batch-size=N       Set batch size for updates (default: 5000)');
177
178
        } catch (\Throwable $e) {
179
            $this->error('Failed to describe index: '.$e->getMessage());
180
            $this->info('The index may not exist. Run `php artisan manticore:create-indexes` first.');
181
        }
182
    }
183
184
    /**
185
     * Add new fields to the releases_rt index
186
     *
187
     * Note: ManticoreSearch RT indexes support ALTER TABLE for adding columns
188
     */
189
    private function addNewFields(): int
190
    {
191
        $this->info('Checking for missing fields in releases_rt index...');
192
193
        try {
194
            // Get current schema using table describe method
195
            $columns = $this->client->table('releases_rt')->describe();
196
197
            if (empty($columns)) {
198
                $this->error('Index releases_rt does not exist. Create it first with: php artisan manticore:create-indexes');
199
200
                return Command::FAILURE;
201
            }
202
203
            $existingFields = [];
204
            foreach ($columns as $field => $props) {
205
                $existingFields[$field] = strtolower($props['Type'] ?? 'unknown');
206
            }
207
208
            // Media fields that should exist
209
            $mediaFields = [
210
                'imdbid' => 'int',
211
                'tmdbid' => 'int',
212
                'traktid' => 'int',
213
                'tvdb' => 'int',
214
                'tvmaze' => 'int',
215
                'tvrage' => 'int',
216
                'videos_id' => 'int',
217
                'movieinfo_id' => 'int',
218
            ];
219
220
            $fieldsToAdd = [];
221
            foreach ($mediaFields as $field => $type) {
222
                if (! isset($existingFields[$field]) || $this->option('force')) {
223
                    $fieldsToAdd[$field] = $type;
224
                }
225
            }
226
227
            if (empty($fieldsToAdd)) {
228
                $this->info('All media fields already exist in the index.');
229
230
                return Command::SUCCESS;
231
            }
232
233
            $this->warn('The following fields will be added:');
234
            foreach ($fieldsToAdd as $field => $type) {
235
                $this->line("  - {$field} ({$type})");
236
            }
237
238
            if (! $this->confirm('Do you want to proceed with adding these fields?', true)) {
239
                $this->info('Operation cancelled.');
240
241
                return Command::SUCCESS;
242
            }
243
244
            // Add each field using the ManticoreSearch PHP client alter method
245
            foreach ($fieldsToAdd as $field => $type) {
246
                try {
247
                    $this->client->table('releases_rt')->alter('add', $field, $type);
248
                    $this->info("Added field: {$field}");
249
                } catch (ResponseException $e) {
250
                    if (str_contains($e->getMessage(), 'already exists')) {
251
                        $this->warn("Field {$field} already exists, skipping.");
252
                    } else {
253
                        $this->error("Failed to add field {$field}: ".$e->getMessage());
254
255
                        return Command::FAILURE;
256
                    }
257
                }
258
            }
259
260
            $this->info('Schema update completed successfully!');
261
            $this->newLine();
262
            $this->info('Now you can update existing releases with media IDs using:');
263
            $this->line('  php artisan nntmux:update-releases-index --update-media-ids');
264
265
            return Command::SUCCESS;
266
267
        } catch (\Throwable $e) {
268
            $this->error('Failed to update schema: '.$e->getMessage());
269
270
            return Command::FAILURE;
271
        }
272
    }
273
274
    /**
275
     * Update existing indexed releases with media IDs from database
276
     */
277
    private function updateMediaIds(): int
278
    {
279
        $this->info('Updating indexed releases with media IDs from database...');
280
281
        $batchSize = (int) $this->option('batch-size');
282
        $moviesOnly = $this->option('movies-only');
283
        $tvOnly = $this->option('tv-only');
284
        $missingOnly = $this->option('missing-only');
285
286
        // Build query based on options
287
        $query = Release::query()
288
            ->leftJoin('movieinfo', 'releases.movieinfo_id', '=', 'movieinfo.id')
289
            ->leftJoin('videos', 'releases.videos_id', '=', 'videos.id')
290
            ->select([
291
                'releases.id',
292
                'releases.name',
293
                'releases.searchname',
294
                'releases.fromname',
295
                'releases.categories_id',
296
                'releases.videos_id',
297
                'releases.movieinfo_id',
298
                // Movie external IDs
299
                'movieinfo.imdbid',
300
                'movieinfo.tmdbid',
301
                'movieinfo.traktid',
302
                // TV show external IDs
303
                'videos.tvdb',
304
                'videos.tvmaze',
305
                'videos.tvrage',
306
                DB::raw('videos.trakt as video_trakt'),
307
                DB::raw('videos.imdb as video_imdb'),
308
                DB::raw('videos.tmdb as video_tmdb'),
309
            ]);
310
311
        // Apply filters based on options
312
        if ($moviesOnly) {
313
            $query->whereNotNull('releases.movieinfo_id')
314
                ->where('releases.movieinfo_id', '>', 0);
315
            $this->info('Filtering: Movies only (releases with movieinfo_id)');
316
        } elseif ($tvOnly) {
317
            $query->whereNotNull('releases.videos_id')
318
                ->where('releases.videos_id', '>', 0);
319
            $this->info('Filtering: TV shows only (releases with videos_id)');
320
        } else {
321
            // Get releases that have either movie or TV info
322
            $query->where(function ($q) {
323
                $q->where(function ($subq) {
324
                    $subq->whereNotNull('releases.movieinfo_id')
325
                        ->where('releases.movieinfo_id', '>', 0);
326
                })->orWhere(function ($subq) {
327
                    $subq->whereNotNull('releases.videos_id')
328
                        ->where('releases.videos_id', '>', 0);
329
                });
330
            });
331
            $this->info('Filtering: Releases with either movie or TV info');
332
        }
333
334
        $total = $query->count();
335
336
        if ($total === 0) {
337
            $this->warn('No releases found matching the criteria.');
338
339
            return Command::SUCCESS;
340
        }
341
342
        $this->info("Found {$total} releases to update.");
343
344
        if (! $this->confirm('Do you want to proceed with the update?', true)) {
345
            $this->info('Operation cancelled.');
346
347
            return Command::SUCCESS;
348
        }
349
350
        $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

350
        $bar = $this->output->createProgressBar(/** @scrutinizer ignore-type */ $total);
Loading history...
351
        $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%');
352
        $bar->start();
353
354
        $updated = 0;
355
        $errors = 0;
356
        $indexName = config('search.drivers.manticore.indexes.releases', 'releases_rt');
357
358
        // Process in batches using chunk
359
        $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

359
        $query->orderBy(/** @scrutinizer ignore-type */ 'releases.id')
Loading history...
360
            ->chunk($batchSize, function ($releases) use (&$updated, &$errors, $indexName, $bar, $missingOnly) {
361
                $batch = [];
362
363
                foreach ($releases as $release) {
364
                    // Prepare the update data
365
                    $mediaData = $this->prepareMediaData($release);
366
367
                    // Skip if all media IDs are zero and we're not forcing update
368
                    $hasMediaIds = array_filter($mediaData, fn ($v) => $v > 0);
369
                    if (empty($hasMediaIds)) {
370
                        $bar->advance();
371
372
                        continue;
373
                    }
374
375
                    // If missing-only, check if the document already has media IDs
376
                    if ($missingOnly && $this->documentHasMediaIds($indexName, $release->id)) {
377
                        $bar->advance();
378
379
                        continue;
380
                    }
381
382
                    $batch[] = [
383
                        'id' => $release->id,
384
                        'data' => $mediaData,
385
                    ];
386
387
                    // Process batch when it reaches the threshold
388
                    if (count($batch) >= 1000) {
389
                        $result = $this->processBatch($indexName, $batch);
390
                        $updated += $result['updated'];
391
                        $errors += $result['errors'];
392
                        $batch = [];
393
                    }
394
395
                    $bar->advance();
396
                }
397
398
                // Process remaining batch
399
                if (! empty($batch)) {
400
                    $result = $this->processBatch($indexName, $batch);
401
                    $updated += $result['updated'];
402
                    $errors += $result['errors'];
403
                }
404
            });
405
406
        $bar->finish();
407
        $this->newLine(2);
408
409
        $this->info('Update completed!');
410
        $this->line("  - Updated: {$updated}");
411
        if ($errors > 0) {
412
            $this->warn("  - Errors: {$errors}");
413
        }
414
415
        return Command::SUCCESS;
416
    }
417
418
    /**
419
     * Prepare media data for a release
420
     */
421
    private function prepareMediaData($release): array
422
    {
423
        return [
424
            'imdbid' => (int) ($release->imdbid ?: 0),
425
            'tmdbid' => (int) ($release->tmdbid ?: ($release->video_tmdb ?: 0)),
426
            'traktid' => (int) ($release->traktid ?: ($release->video_trakt ?: 0)),
427
            'tvdb' => (int) ($release->tvdb ?: 0),
428
            'tvmaze' => (int) ($release->tvmaze ?: 0),
429
            'tvrage' => (int) ($release->tvrage ?: 0),
430
            'videos_id' => (int) ($release->videos_id ?: 0),
431
            'movieinfo_id' => (int) ($release->movieinfo_id ?: 0),
432
        ];
433
    }
434
435
    /**
436
     * Check if a document already has media IDs in the index
437
     */
438
    private function documentHasMediaIds(string $indexName, int $id): bool
439
    {
440
        try {
441
            $doc = $this->client->table($indexName)->getDocumentById($id);
442
443
            if (! $doc) {
444
                return false;
445
            }
446
447
            $data = $doc->getData();
448
449
            // Check if any media ID is non-zero
450
            return ($data['imdbid'] ?? 0) > 0
451
                || ($data['tmdbid'] ?? 0) > 0
452
                || ($data['traktid'] ?? 0) > 0
453
                || ($data['tvdb'] ?? 0) > 0
454
                || ($data['tvmaze'] ?? 0) > 0
455
                || ($data['tvrage'] ?? 0) > 0;
456
457
        } catch (\Throwable $e) {
458
            // Document might not exist
459
            return false;
460
        }
461
    }
462
463
    /**
464
     * Process a batch of updates
465
     */
466
    private function processBatch(string $indexName, array $batch): array
467
    {
468
        $updated = 0;
469
        $errors = 0;
470
471
        foreach ($batch as $item) {
472
            try {
473
                // Use updateDocument to modify existing documents
474
                $this->client->table($indexName)->updateDocument($item['data'], $item['id']);
475
                $updated++;
476
            } catch (\Throwable $e) {
477
                // If update fails, try replace (document might not exist in index yet)
478
                try {
479
                    $this->insertOrReplaceDocument($indexName, $item['id'], $item['data']);
480
                    $updated++;
481
                } catch (\Throwable $e2) {
482
                    $errors++;
483
                    if ($errors <= 5) {
484
                        // Log first few errors
485
                        Log::warning("Failed to update release {$item['id']} in search index: ".$e2->getMessage());
486
                    }
487
                }
488
            }
489
        }
490
491
        return ['updated' => $updated, 'errors' => $errors];
492
    }
493
494
    /**
495
     * Insert or replace a document in the index
496
     * This is used as a fallback when UPDATE fails
497
     */
498
    private function insertOrReplaceDocument(string $indexName, int $id, array $mediaData): void
499
    {
500
        // First, try to get the existing document
501
        try {
502
            $doc = $this->client->table($indexName)->getDocumentById($id);
503
504
            if ($doc) {
505
                // Merge existing data with new media data
506
                $existingData = $doc->getData();
507
                $document = array_merge($existingData, $mediaData);
508
                unset($document['id']); // ID is passed separately
509
510
                $this->client->table($indexName)->replaceDocument($document, $id);
511
            } else {
512
                // Document doesn't exist in index, we need full data from database
513
                $release = Release::with(['movieinfo', 'video'])->find($id);
514
                if ($release) {
515
                    Search::insertRelease([
516
                        'id' => $release->id,
517
                        'name' => $release->name ?? '',
0 ignored issues
show
Bug introduced by
The property name does not seem to exist on App\Models\Release. Are you sure there is no database migration missing?

Checks if undeclared accessed properties appear in database migrations and if the creating migration is correct.

Loading history...
518
                        'searchname' => $release->searchname ?? '',
0 ignored issues
show
Bug introduced by
The property searchname does not seem to exist on App\Models\Release. Are you sure there is no database migration missing?

Checks if undeclared accessed properties appear in database migrations and if the creating migration is correct.

Loading history...
519
                        'fromname' => $release->fromname ?? '',
0 ignored issues
show
Bug introduced by
The property fromname does not seem to exist on App\Models\Release. Are you sure there is no database migration missing?

Checks if undeclared accessed properties appear in database migrations and if the creating migration is correct.

Loading history...
520
                        'categories_id' => $release->categories_id ?? 0,
0 ignored issues
show
Bug introduced by
The property categories_id does not exist on App\Models\Release. Did you mean category_ids?
Loading history...
521
                        'filename' => '',
522
                        'imdbid' => $mediaData['imdbid'],
523
                        'tmdbid' => $mediaData['tmdbid'],
524
                        'traktid' => $mediaData['traktid'],
525
                        'tvdb' => $mediaData['tvdb'],
526
                        'tvmaze' => $mediaData['tvmaze'],
527
                        'tvrage' => $mediaData['tvrage'],
528
                        'videos_id' => $mediaData['videos_id'],
529
                        'movieinfo_id' => $mediaData['movieinfo_id'],
530
                    ]);
531
                }
532
            }
533
        } catch (\Throwable $e) {
534
            throw $e;
535
        }
536
    }
537
}
538