ManticoreSearchDriver   F
last analyzed

Complexity

Total Complexity 258

Size/Duplication

Total Lines 1954
Duplicated Lines 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
wmc 258
eloc 965
c 3
b 0
f 0
dl 0
loc 1954
rs 1.635

51 Methods

Rating   Name   Duplication   Size   Complexity  
A insertPredb() 0 29 5
A getDriverName() 0 3 1
A isSuggestEnabled() 0 3 1
A isFuzzyEnabled() 0 3 1
A isAutocompleteEnabled() 0 3 1
A getFuzzyConfig() 0 6 1
A getMoviesIndex() 0 3 1
A deleteRelease() 0 14 3
A __construct() 0 6 2
A getPredbIndex() 0 3 1
A getReleasesIndex() 0 3 1
A isAvailable() 0 12 3
A deletePreDb() 0 14 3
A getTvShowsIndex() 0 3 1
A insertRelease() 0 42 5
A optimizeRTIndex() 0 19 4
A searchReleasesWithFuzzy() 0 27 5
C suggest() 0 66 13
A updateMovie() 0 19 4
B truncateRTIndex() 0 44 9
A escapeString() 0 15 3
C autocomplete() 0 86 13
B bulkInsertReleases() 0 43 6
A searchReleases() 0 23 5
F searchIndexes() 0 175 25
A deleteMovie() 0 14 3
B bulkInsertPredb() 0 33 6
A deleteReleaseByGuid() 0 26 6
A extractSuggestion() 0 40 5
C suggestFallback() 0 60 12
A truncateIndex() 0 4 2
B createIndexIfNotExists() 0 97 6
A searchPredb() 0 7 2
A updatePreDb() 0 9 2
A updateRelease() 0 31 4
A insertMovie() 0 32 4
A optimizeIndex() 0 3 1
A searchMovies() 0 5 2
D fuzzySearchIndexes() 0 128 17
A fuzzySearchReleases() 0 22 6
B searchReleasesByExternalId() 0 43 9
A searchTvShows() 0 5 2
A updateTvShow() 0 19 4
B searchReleasesByCategory() 0 49 8
B bulkInsertTvShows() 0 43 6
B bulkInsertMovies() 0 44 6
A deleteTvShow() 0 14 3
B searchMovieByExternalId() 0 35 6
B searchReleasesWithCategoryFilter() 0 60 9
A insertTvShow() 0 31 4
B searchTvShowByExternalId() 0 35 6

How to fix   Complexity   

Complex Class

Complex classes like ManticoreSearchDriver often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ManticoreSearchDriver, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace App\Services\Search\Drivers;
4
5
use App\Models\MovieInfo;
6
use App\Models\Release;
7
use App\Models\Video;
8
use App\Services\Search\Contracts\SearchDriverInterface;
9
use Illuminate\Support\Facades\Cache;
10
use Illuminate\Support\Facades\DB;
11
use Illuminate\Support\Facades\Log;
12
use Manticoresearch\Client;
13
use Manticoresearch\Exceptions\ResponseException;
14
use Manticoresearch\Exceptions\RuntimeException;
15
use Manticoresearch\Search;
16
17
/**
18
 * ManticoreSearch driver for full-text search functionality.
19
 */
20
class ManticoreSearchDriver implements SearchDriverInterface
21
{
22
    protected array $config;
23
24
    protected array $connection;
25
26
    public Client $manticoreSearch;
27
28
    public Search $search;
29
30
    /**
31
     * Establishes a connection to ManticoreSearch HTTP port.
32
     */
33
    public function __construct(array $config = [])
34
    {
35
        $this->config = ! empty($config) ? $config : config('search.drivers.manticore');
36
        $this->connection = ['host' => $this->config['host'], 'port' => $this->config['port']];
37
        $this->manticoreSearch = new Client($this->connection);
38
        $this->search = new Search($this->manticoreSearch);
39
    }
40
41
    /**
42
     * Get the driver name.
43
     */
44
    public function getDriverName(): string
45
    {
46
        return 'manticore';
47
    }
48
49
    /**
50
     * Check if ManticoreSearch is available.
51
     */
52
    public function isAvailable(): bool
53
    {
54
        try {
55
            $status = $this->manticoreSearch->nodes()->status();
56
57
            return ! empty($status);
58
        } catch (\Throwable $e) {
59
            if (config('app.debug')) {
60
                Log::debug('ManticoreSearch not available: '.$e->getMessage());
61
            }
62
63
            return false;
64
        }
65
    }
66
67
    /**
68
     * Check if autocomplete is enabled.
69
     */
70
    public function isAutocompleteEnabled(): bool
71
    {
72
        return ($this->config['autocomplete']['enabled'] ?? true) === true;
73
    }
74
75
    /**
76
     * Check if suggest is enabled.
77
     */
78
    public function isSuggestEnabled(): bool
79
    {
80
        return ($this->config['suggest']['enabled'] ?? true) === true;
81
    }
82
83
    /**
84
     * Check if fuzzy search is enabled.
85
     */
86
    public function isFuzzyEnabled(): bool
87
    {
88
        return ($this->config['fuzzy']['enabled'] ?? true) === true;
89
    }
90
91
    /**
92
     * Get fuzzy search configuration.
93
     */
94
    public function getFuzzyConfig(): array
95
    {
96
        return $this->config['fuzzy'] ?? [
97
            'enabled' => true,
98
            'max_distance' => 2,
99
            'layouts' => 'us',
100
        ];
101
    }
102
103
    /**
104
     * Get the releases index name.
105
     */
106
    public function getReleasesIndex(): string
107
    {
108
        return $this->config['indexes']['releases'] ?? 'releases_rt';
109
    }
110
111
    /**
112
     * Get the predb index name.
113
     */
114
    public function getPredbIndex(): string
115
    {
116
        return $this->config['indexes']['predb'] ?? 'predb_rt';
117
    }
118
119
    /**
120
     * Get the movies index name.
121
     */
122
    public function getMoviesIndex(): string
123
    {
124
        return $this->config['indexes']['movies'] ?? 'movies_rt';
125
    }
126
127
    /**
128
     * Get the TV shows index name.
129
     */
130
    public function getTvShowsIndex(): string
131
    {
132
        return $this->config['indexes']['tvshows'] ?? 'tvshows_rt';
133
    }
134
135
    /**
136
     * Insert release into ManticoreSearch releases_rt realtime index
137
     */
138
    public function insertRelease(array $parameters): void
139
    {
140
        if (empty($parameters['id'])) {
141
            Log::warning('ManticoreSearch: Cannot insert release without ID');
142
143
            return;
144
        }
145
146
        try {
147
            $document = [
148
                'name' => $parameters['name'] ?? '',
149
                'searchname' => $parameters['searchname'] ?? '',
150
                'fromname' => $parameters['fromname'] ?? '',
151
                'categories_id' => (int) ($parameters['categories_id'] ?? 0),
152
                'filename' => $parameters['filename'] ?? '',
153
                // External media IDs for efficient searching
154
                'imdbid' => (int) ($parameters['imdbid'] ?? 0),
155
                'tmdbid' => (int) ($parameters['tmdbid'] ?? 0),
156
                'traktid' => (int) ($parameters['traktid'] ?? 0),
157
                'tvdb' => (int) ($parameters['tvdb'] ?? 0),
158
                'tvmaze' => (int) ($parameters['tvmaze'] ?? 0),
159
                'tvrage' => (int) ($parameters['tvrage'] ?? 0),
160
                'videos_id' => (int) ($parameters['videos_id'] ?? 0),
161
                'movieinfo_id' => (int) ($parameters['movieinfo_id'] ?? 0),
162
            ];
163
164
            $this->manticoreSearch->table($this->config['indexes']['releases'])
165
                ->replaceDocument($document, $parameters['id']);
166
167
        } catch (ResponseException $e) {
168
            Log::error('ManticoreSearch insertRelease ResponseException: '.$e->getMessage(), [
169
                'release_id' => $parameters['id'],
170
                'index' => $this->config['indexes']['releases'],
171
            ]);
172
        } catch (RuntimeException $e) {
173
            Log::error('ManticoreSearch insertRelease RuntimeException: '.$e->getMessage(), [
174
                'release_id' => $parameters['id'],
175
            ]);
176
        } catch (\Throwable $e) {
177
            Log::error('ManticoreSearch insertRelease unexpected error: '.$e->getMessage(), [
178
                'release_id' => $parameters['id'],
179
                'trace' => $e->getTraceAsString(),
180
            ]);
181
        }
182
    }
183
184
    /**
185
     * Insert release into Manticore RT table.
186
     */
187
    public function insertPredb(array $parameters): void
188
    {
189
        if (empty($parameters['id'])) {
190
            Log::warning('ManticoreSearch: Cannot insert predb without ID');
191
192
            return;
193
        }
194
195
        try {
196
            $document = [
197
                'title' => $parameters['title'] ?? '',
198
                'filename' => $parameters['filename'] ?? '',
199
                'source' => $parameters['source'] ?? '',
200
            ];
201
202
            $this->manticoreSearch->table($this->config['indexes']['predb'])
203
                ->replaceDocument($document, $parameters['id']);
204
205
        } catch (ResponseException $e) {
206
            Log::error('ManticoreSearch insertPredb ResponseException: '.$e->getMessage(), [
207
                'predb_id' => $parameters['id'],
208
            ]);
209
        } catch (RuntimeException $e) {
210
            Log::error('ManticoreSearch insertPredb RuntimeException: '.$e->getMessage(), [
211
                'predb_id' => $parameters['id'],
212
            ]);
213
        } catch (\Throwable $e) {
214
            Log::error('ManticoreSearch insertPredb unexpected error: '.$e->getMessage(), [
215
                'predb_id' => $parameters['id'],
216
            ]);
217
        }
218
    }
219
220
    /**
221
     * Delete release from Manticore RT tables.
222
     *
223
     * @param  int  $id  Release ID
224
     */
225
    public function deleteRelease(int $id): void
226
    {
227
        if (empty($id)) {
228
            Log::warning('ManticoreSearch: Cannot delete release without ID');
229
230
            return;
231
        }
232
233
        try {
234
            $this->manticoreSearch->table($this->config['indexes']['releases'])
235
                ->deleteDocument($id);
236
        } catch (ResponseException $e) {
237
            Log::error('ManticoreSearch deleteRelease error: '.$e->getMessage(), [
238
                'id' => $id,
239
            ]);
240
        }
241
    }
242
243
    /**
244
     * Delete a predb record from the index.
245
     *
246
     * @param  int  $id  Predb ID
247
     */
248
    public function deletePreDb(int $id): void
249
    {
250
        if (empty($id)) {
251
            Log::warning('ManticoreSearch: Cannot delete predb without ID');
252
253
            return;
254
        }
255
256
        try {
257
            $this->manticoreSearch->table($this->config['indexes']['predb'])
258
                ->deleteDocument($id);
259
        } catch (ResponseException $e) {
260
            Log::error('ManticoreSearch deletePreDb error: '.$e->getMessage(), [
261
                'id' => $id,
262
            ]);
263
        }
264
    }
265
266
    /**
267
     * Bulk insert multiple releases into the index.
268
     *
269
     * @param  array  $releases  Array of release data arrays
270
     * @return array Results with 'success' and 'errors' counts
271
     */
272
    public function bulkInsertReleases(array $releases): array
273
    {
274
        if (empty($releases)) {
275
            return ['success' => 0, 'errors' => 0];
276
        }
277
278
        $documents = [];
279
        foreach ($releases as $release) {
280
            if (empty($release['id'])) {
281
                continue;
282
            }
283
284
            $documents[] = [
285
                'id' => $release['id'],
286
                'name' => (string) ($release['name'] ?? ''),
287
                'searchname' => (string) ($release['searchname'] ?? ''),
288
                'fromname' => (string) ($release['fromname'] ?? ''),
289
                'categories_id' => (int) ($release['categories_id'] ?? 0),
290
                'filename' => (string) ($release['filename'] ?? ''),
291
                'imdbid' => (int) ($release['imdbid'] ?? 0),
292
                'tmdbid' => (int) ($release['tmdbid'] ?? 0),
293
                'traktid' => (int) ($release['traktid'] ?? 0),
294
                'tvdb' => (int) ($release['tvdb'] ?? 0),
295
                'tvmaze' => (int) ($release['tvmaze'] ?? 0),
296
                'tvrage' => (int) ($release['tvrage'] ?? 0),
297
                'videos_id' => (int) ($release['videos_id'] ?? 0),
298
                'movieinfo_id' => (int) ($release['movieinfo_id'] ?? 0),
299
            ];
300
        }
301
302
        if (empty($documents)) {
303
            return ['success' => 0, 'errors' => 0];
304
        }
305
306
        try {
307
            $this->manticoreSearch->table($this->config['indexes']['releases'])
308
                ->replaceDocuments($documents);
309
310
            return ['success' => count($documents), 'errors' => 0];
311
        } catch (\Throwable $e) {
312
            Log::error('ManticoreSearch bulkInsertReleases error: '.$e->getMessage());
313
314
            return ['success' => 0, 'errors' => count($documents)];
315
        }
316
    }
317
318
    /**
319
     * Bulk insert multiple predb records into the index.
320
     *
321
     * @param  array  $predbRecords  Array of predb data arrays
322
     * @return array Results with 'success' and 'errors' counts
323
     */
324
    public function bulkInsertPredb(array $predbRecords): array
325
    {
326
        if (empty($predbRecords)) {
327
            return ['success' => 0, 'errors' => 0];
328
        }
329
330
        $documents = [];
331
        foreach ($predbRecords as $predb) {
332
            if (empty($predb['id'])) {
333
                continue;
334
            }
335
336
            $documents[] = [
337
                'id' => $predb['id'],
338
                'title' => (string) ($predb['title'] ?? ''),
339
                'filename' => (string) ($predb['filename'] ?? ''),
340
                'source' => (string) ($predb['source'] ?? ''),
341
            ];
342
        }
343
344
        if (empty($documents)) {
345
            return ['success' => 0, 'errors' => 0];
346
        }
347
348
        try {
349
            $this->manticoreSearch->table($this->config['indexes']['predb'])
350
                ->replaceDocuments($documents);
351
352
            return ['success' => count($documents), 'errors' => 0];
353
        } catch (\Throwable $e) {
354
            Log::error('ManticoreSearch bulkInsertPredb error: '.$e->getMessage());
355
356
            return ['success' => 0, 'errors' => count($documents)];
357
        }
358
    }
359
360
    /**
361
     * Delete release from Manticore RT tables by GUID.
362
     *
363
     * @param  array  $identifiers  ['g' => Release GUID(mandatory), 'id' => ReleaseID(optional, pass false)]
364
     */
365
    public function deleteReleaseByGuid(array $identifiers): void
366
    {
367
        if (empty($identifiers['g'])) {
368
            Log::warning('ManticoreSearch: Cannot delete release without GUID');
369
370
            return;
371
        }
372
373
        try {
374
            if ($identifiers['i'] === false || empty($identifiers['i'])) {
375
                $release = Release::query()->where('guid', $identifiers['g'])->first(['id']);
376
                $identifiers['i'] = $release?->id;
377
            }
378
379
            if (! empty($identifiers['i'])) {
380
                $this->manticoreSearch->table($this->config['indexes']['releases'])
381
                    ->deleteDocument($identifiers['i']);
382
            } else {
383
                Log::warning('ManticoreSearch: Could not find release ID for deletion', [
384
                    'guid' => $identifiers['g'],
385
                ]);
386
            }
387
        } catch (ResponseException $e) {
388
            Log::error('ManticoreSearch deleteRelease error: '.$e->getMessage(), [
389
                'guid' => $identifiers['g'],
390
                'id' => $identifiers['i'] ?? null,
391
            ]);
392
        }
393
    }
394
395
    /**
396
     * Escapes characters that are treated as special operators by the query language parser.
397
     */
398
    public static function escapeString(string $string): string
399
    {
400
        if ($string === '*' || empty($string)) {
401
            return '';
402
        }
403
404
        $from = ['\\', '(', ')', '@', '~', '"', '&', '/', '$', '=', "'", '--', '[', ']', '!', '-'];
405
        $to = ['\\\\', '\(', '\)', '\@', '\~', '\"', '\&', '\/', '\$', '\=', "\'", '\--', '\[', '\]', '\!', '\-'];
406
407
        $string = str_replace($from, $to, $string);
408
409
        // Clean up trailing special characters
410
        $string = rtrim($string, '-!');
411
412
        return trim($string);
413
    }
414
415
    public function updateRelease(int|string $releaseID): void
416
    {
417
        if (empty($releaseID)) {
418
            Log::warning('ManticoreSearch: Cannot update release without ID');
419
420
            return;
421
        }
422
423
        try {
424
            $release = Release::query()
425
                ->where('releases.id', $releaseID)
426
                ->leftJoin('release_files as rf', 'releases.id', '=', 'rf.releases_id')
427
                ->select([
428
                    'releases.id',
429
                    'releases.name',
430
                    'releases.searchname',
431
                    'releases.fromname',
432
                    'releases.categories_id',
433
                    DB::raw('IFNULL(GROUP_CONCAT(rf.name SEPARATOR " "),"") filename'),
434
                ])
435
                ->groupBy('releases.id')
436
                ->first();
437
438
            if ($release !== null) {
439
                $this->insertRelease($release->toArray());
440
            } else {
441
                Log::warning('ManticoreSearch: Release not found for update', ['id' => $releaseID]);
442
            }
443
        } catch (\Throwable $e) {
444
            Log::error('ManticoreSearch updateRelease error: '.$e->getMessage(), [
445
                'release_id' => $releaseID,
446
            ]);
447
        }
448
    }
449
450
    /**
451
     * Update Manticore Predb index for given predb_id.
452
     */
453
    public function updatePreDb(array $parameters): void
454
    {
455
        if (empty($parameters)) {
456
            Log::warning('ManticoreSearch: Cannot update predb with empty parameters');
457
458
            return;
459
        }
460
461
        $this->insertPredb($parameters);
462
    }
463
464
    public function truncateRTIndex(array $indexes = []): bool
465
    {
466
        if (empty($indexes)) {
467
            cli()->error('You need to provide index name to truncate');
468
469
            return false;
470
        }
471
472
        $success = true;
473
        foreach ($indexes as $index) {
474
            if (! \in_array($index, $this->config['indexes'], true)) {
475
                cli()->error('Unsupported index: '.$index);
476
                $success = false;
477
478
                continue;
479
            }
480
481
            try {
482
                $this->manticoreSearch->table($index)->truncate();
483
                cli()->info('Truncating index '.$index.' finished.');
484
            } catch (ResponseException $e) {
485
                // Handle case where index doesn't exist - create it
486
                $message = $e->getMessage();
487
                if (str_contains($message, 'does not exist') || $message === 'Invalid index') {
488
                    cli()->info('Index '.$index.' does not exist, creating it...');
489
                    $this->createIndexIfNotExists($index);
490
                } else {
491
                    cli()->error('Error truncating index '.$index.': '.$message);
492
                    $success = false;
493
                }
494
            } catch (\Throwable $e) {
495
                // Also handle generic exceptions for non-existent tables
496
                $message = $e->getMessage();
497
                if (str_contains($message, 'does not exist')) {
498
                    cli()->info('Index '.$index.' does not exist, creating it...');
499
                    $this->createIndexIfNotExists($index);
500
                } else {
501
                    cli()->error('Unexpected error truncating index '.$index.': '.$message);
502
                    $success = false;
503
                }
504
            }
505
        }
506
507
        return $success;
508
    }
509
510
    /**
511
     * Truncate/clear an index (remove all documents).
512
     * Implements SearchServiceInterface::truncateIndex
513
     *
514
     * @param  array|string  $indexes  Index name(s) to truncate
515
     */
516
    public function truncateIndex(array|string $indexes): void
517
    {
518
        $indexArray = is_array($indexes) ? $indexes : [$indexes];
0 ignored issues
show
introduced by
The condition is_array($indexes) is always true.
Loading history...
519
        $this->truncateRTIndex($indexArray);
520
    }
521
522
    /**
523
     * Create index if it doesn't exist
524
     */
525
    private function createIndexIfNotExists(string $index): void
526
    {
527
        try {
528
            // Use the tables() API which properly handles settings
529
            $indices = $this->manticoreSearch->tables();
530
531
            if ($index === 'releases_rt') {
532
                $indices->create([
533
                    'index' => $index,
534
                    'body' => [
535
                        'settings' => [
536
                            'min_prefix_len' => 0,
537
                            'min_infix_len' => 2,
538
                        ],
539
                        'columns' => [
540
                            'name' => ['type' => 'text'],
541
                            'searchname' => ['type' => 'text'],
542
                            'fromname' => ['type' => 'text'],
543
                            'filename' => ['type' => 'text'],
544
                            'categories_id' => ['type' => 'integer'],
545
                            // External media IDs for efficient searching
546
                            'imdbid' => ['type' => 'integer'],
547
                            'tmdbid' => ['type' => 'integer'],
548
                            'traktid' => ['type' => 'integer'],
549
                            'tvdb' => ['type' => 'integer'],
550
                            'tvmaze' => ['type' => 'integer'],
551
                            'tvrage' => ['type' => 'integer'],
552
                            'videos_id' => ['type' => 'integer'],
553
                            'movieinfo_id' => ['type' => 'integer'],
554
                        ],
555
                    ],
556
                ]);
557
                cli()->info('Created releases_rt index with external ID fields and infix search support');
558
            } elseif ($index === 'predb_rt') {
559
                $indices->create([
560
                    'index' => $index,
561
                    'body' => [
562
                        'settings' => [
563
                            'min_prefix_len' => 0,
564
                            'min_infix_len' => 2,
565
                        ],
566
                        'columns' => [
567
                            'title' => ['type' => 'text'],
568
                            'filename' => ['type' => 'text'],
569
                            'source' => ['type' => 'text'],
570
                        ],
571
                    ],
572
                ]);
573
                cli()->info('Created predb_rt index with infix search support');
574
            } elseif ($index === 'movies_rt') {
575
                $indices->create([
576
                    'index' => $index,
577
                    'body' => [
578
                        'settings' => [
579
                            'min_prefix_len' => 0,
580
                            'min_infix_len' => 2,
581
                        ],
582
                        'columns' => [
583
                            'imdbid' => ['type' => 'integer'],
584
                            'tmdbid' => ['type' => 'integer'],
585
                            'traktid' => ['type' => 'integer'],
586
                            'title' => ['type' => 'text'],
587
                            'year' => ['type' => 'text'],
588
                            'genre' => ['type' => 'text'],
589
                            'actors' => ['type' => 'text'],
590
                            'director' => ['type' => 'text'],
591
                            'rating' => ['type' => 'text'],
592
                            'plot' => ['type' => 'text'],
593
                        ],
594
                    ],
595
                ]);
596
                cli()->info('Created movies_rt index with infix search support');
597
            } elseif ($index === 'tvshows_rt') {
598
                $indices->create([
599
                    'index' => $index,
600
                    'body' => [
601
                        'settings' => [
602
                            'min_prefix_len' => 0,
603
                            'min_infix_len' => 2,
604
                        ],
605
                        'columns' => [
606
                            'title' => ['type' => 'text'],
607
                            'tvdb' => ['type' => 'integer'],
608
                            'trakt' => ['type' => 'integer'],
609
                            'tvmaze' => ['type' => 'integer'],
610
                            'tvrage' => ['type' => 'integer'],
611
                            'imdb' => ['type' => 'integer'],
612
                            'tmdb' => ['type' => 'integer'],
613
                            'started' => ['type' => 'text'],
614
                            'type' => ['type' => 'integer'],
615
                        ],
616
                    ],
617
                ]);
618
                cli()->info('Created tvshows_rt index with infix search support');
619
            }
620
        } catch (\Throwable $e) {
621
            cli()->error('Error creating index '.$index.': '.$e->getMessage());
622
        }
623
    }
624
625
    /**
626
     * Optimize the RT indices.
627
     */
628
    public function optimizeRTIndex(): bool
629
    {
630
        $success = true;
631
632
        foreach ($this->config['indexes'] as $index) {
633
            try {
634
                $this->manticoreSearch->table($index)->flush();
635
                $this->manticoreSearch->table($index)->optimize();
636
                Log::info("Successfully optimized index: {$index}");
637
            } catch (ResponseException $e) {
638
                Log::error('Failed to optimize index '.$index.': '.$e->getMessage());
639
                $success = false;
640
            } catch (\Throwable $e) {
641
                Log::error('Unexpected error optimizing index '.$index.': '.$e->getMessage());
642
                $success = false;
643
            }
644
        }
645
646
        return $success;
647
    }
648
649
    /**
650
     * Optimize index for better search performance.
651
     * Implements SearchServiceInterface::optimizeIndex
652
     */
653
    public function optimizeIndex(): void
654
    {
655
        $this->optimizeRTIndex();
656
    }
657
658
    /**
659
     * Search releases index.
660
     *
661
     * @param  array|string  $phrases  Search phrases - can be a string, indexed array of terms, or associative array with field names
662
     * @param  int  $limit  Maximum number of results
663
     * @return array Array of release IDs
664
     */
665
    public function searchReleases(array|string $phrases, int $limit = 1000): array
666
    {
667
        if (is_string($phrases)) {
0 ignored issues
show
introduced by
The condition is_string($phrases) is always false.
Loading history...
668
            // Simple string search - search in searchname field
669
            $searchArray = ['searchname' => $phrases];
670
        } elseif (is_array($phrases)) {
0 ignored issues
show
introduced by
The condition is_array($phrases) is always true.
Loading history...
671
            // Check if it's an associative array (has string keys like 'searchname')
672
            $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0;
673
674
            if ($isAssociative) {
675
                // Already has field names as keys
676
                $searchArray = $phrases;
677
            } else {
678
                // Indexed array - combine values and search in searchname
679
                $searchArray = ['searchname' => implode(' ', $phrases)];
680
            }
681
        } else {
682
            return [];
683
        }
684
685
        $result = $this->searchIndexes($this->getReleasesIndex(), '', [], $searchArray);
686
687
        return ! empty($result) ? ($result['id'] ?? []) : [];
688
    }
689
690
    /**
691
     * Search releases with fuzzy fallback.
692
     *
693
     * If exact search returns no results and fuzzy is enabled, this method
694
     * will automatically try a fuzzy search as a fallback.
695
     *
696
     * @param  array|string  $phrases  Search phrases
697
     * @param  int  $limit  Maximum number of results
698
     * @param  bool  $forceFuzzy  Force fuzzy search regardless of exact results
699
     * @return array Array with 'ids' (release IDs) and 'fuzzy' (bool indicating if fuzzy was used)
700
     */
701
    public function searchReleasesWithFuzzy(array|string $phrases, int $limit = 1000, bool $forceFuzzy = false): array
702
    {
703
        // First try exact search unless forcing fuzzy
704
        if (! $forceFuzzy) {
705
            $exactResults = $this->searchReleases($phrases, $limit);
706
            if (! empty($exactResults)) {
707
                return [
708
                    'ids' => $exactResults,
709
                    'fuzzy' => false,
710
                ];
711
            }
712
        }
713
714
        // If exact search returned nothing (or forcing fuzzy) and fuzzy is enabled, try fuzzy search
715
        if ($this->isFuzzyEnabled()) {
716
            $fuzzyResults = $this->fuzzySearchReleases($phrases, $limit);
717
            if (! empty($fuzzyResults)) {
718
                return [
719
                    'ids' => $fuzzyResults,
720
                    'fuzzy' => true,
721
                ];
722
            }
723
        }
724
725
        return [
726
            'ids' => [],
727
            'fuzzy' => false,
728
        ];
729
    }
730
731
    /**
732
     * Perform fuzzy search on releases index.
733
     *
734
     * Uses Manticore's native fuzzy search with Levenshtein distance algorithm.
735
     *
736
     * @param  array|string  $phrases  Search phrases
737
     * @param  int  $limit  Maximum number of results
738
     * @return array Array of release IDs
739
     */
740
    public function fuzzySearchReleases(array|string $phrases, int $limit = 1000): array
741
    {
742
        if (! $this->isFuzzyEnabled()) {
743
            return [];
744
        }
745
746
        if (is_string($phrases)) {
0 ignored issues
show
introduced by
The condition is_string($phrases) is always false.
Loading history...
747
            $searchArray = ['searchname' => $phrases];
748
        } elseif (is_array($phrases)) {
0 ignored issues
show
introduced by
The condition is_array($phrases) is always true.
Loading history...
749
            $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0;
750
            if ($isAssociative) {
751
                $searchArray = $phrases;
752
            } else {
753
                $searchArray = ['searchname' => implode(' ', $phrases)];
754
            }
755
        } else {
756
            return [];
757
        }
758
759
        $result = $this->fuzzySearchIndexes($this->getReleasesIndex(), $searchArray, $limit);
760
761
        return ! empty($result) ? ($result['id'] ?? []) : [];
762
    }
763
764
    /**
765
     * Perform fuzzy search on an index using Manticore's native fuzzy search.
766
     *
767
     * Uses Levenshtein distance algorithm to find matches with typo tolerance.
768
     * Supports:
769
     * - Missing characters: "laptp" → "laptop"
770
     * - Extra characters: "laptopp" → "laptop"
771
     * - Transposed characters: "lpatop" → "laptop"
772
     * - Wrong characters: "laptip" → "laptop"
773
     *
774
     * @param  string  $index  Index to search
775
     * @param  array  $searchArray  Associative array of field => value to search
776
     * @param  int  $limit  Maximum number of results
777
     * @return array Array with 'id' and 'data' keys
778
     */
779
    public function fuzzySearchIndexes(string $index, array $searchArray, int $limit = 1000): array
780
    {
781
        if (empty($index) || empty($searchArray)) {
782
            return [];
783
        }
784
785
        $fuzzyConfig = $this->getFuzzyConfig();
786
        $distance = $fuzzyConfig['max_distance'] ?? 2;
787
788
        // Create cache key for fuzzy search results
789
        $cacheKey = 'manticore:fuzzy:'.md5(serialize([
790
            'index' => $index,
791
            'array' => $searchArray,
792
            'limit' => $limit,
793
            'distance' => $distance,
794
        ]));
795
796
        $cached = Cache::get($cacheKey);
797
        if ($cached !== null) {
798
            if (config('app.debug')) {
799
                Log::debug('ManticoreSearch::fuzzySearchIndexes returning cached result', [
800
                    'cacheKey' => $cacheKey,
801
                ]);
802
            }
803
804
            return $cached;
805
        }
806
807
        // For fuzzy search, we need to use a simple query string without the @@relaxed @field syntax
808
        // The fuzzy option only works with plain query_string queries
809
        $searchTerms = [];
810
        foreach ($searchArray as $field => $value) {
811
            if (! empty($value)) {
812
                // Clean value for fuzzy search - remove special characters but keep words
813
                $cleanValue = preg_replace('/[^\w\s]/', ' ', $value);
814
                $cleanValue = preg_replace('/\s+/', ' ', trim($cleanValue));
815
                if (! empty($cleanValue)) {
816
                    $searchTerms[] = $cleanValue;
817
                }
818
            }
819
        }
820
821
        if (empty($searchTerms)) {
822
            return [];
823
        }
824
825
        $searchExpr = implode(' ', $searchTerms);
826
827
        if (config('app.debug')) {
828
            Log::debug('ManticoreSearch::fuzzySearchIndexes query', [
829
                'index' => $index,
830
                'searchExpr' => $searchExpr,
831
                'fuzzy' => true,
832
                'distance' => $distance,
833
            ]);
834
        }
835
836
        try {
837
            // Use Manticore's native fuzzy search with Levenshtein distance
838
            // Important: use search() with plain query string for fuzzy to work
839
            // Note: Keep options minimal - layouts, stripBadUtf8, and sort can interfere with fuzzy
840
            $query = (new Search($this->manticoreSearch))
841
                ->setTable($index)
842
                ->search($searchExpr)
843
                ->option('fuzzy', true)
844
                ->option('distance', $distance)
845
                ->limit(min($limit, 10000));
846
847
            $results = $query->get();
848
        } catch (ResponseException $e) {
849
            $message = $e->getMessage();
850
851
            // Check if fuzzy search failed due to missing min_infix_len
852
            // This happens when index was created without proper settings
853
            if (str_contains($message, 'min_infix_len')) {
854
                Log::warning('ManticoreSearch fuzzySearchIndexes: Fuzzy search unavailable - index missing min_infix_len setting. Please recreate the index with: php artisan manticore:create-indexes --drop', [
855
                    'index' => $index,
856
                ]);
857
858
                // Fall back to regular search without fuzzy
859
                return $this->searchIndexes($index, '', [], $searchArray);
860
            }
861
862
            Log::error('ManticoreSearch fuzzySearchIndexes ResponseException: '.$message, [
863
                'index' => $index,
864
                'searchArray' => $searchArray,
865
            ]);
866
867
            return [];
868
        } catch (RuntimeException $e) {
869
            Log::error('ManticoreSearch fuzzySearchIndexes RuntimeException: '.$e->getMessage(), [
870
                'index' => $index,
871
            ]);
872
873
            return [];
874
        } catch (\Throwable $e) {
875
            Log::error('ManticoreSearch fuzzySearchIndexes unexpected error: '.$e->getMessage(), [
876
                'index' => $index,
877
            ]);
878
879
            return [];
880
        }
881
882
        $resultIds = [];
883
        $resultData = [];
884
        foreach ($results as $doc) {
885
            $resultIds[] = $doc->getId();
886
            $resultData[] = $doc->getData();
887
        }
888
889
        $result = [
890
            'id' => $resultIds,
891
            'data' => $resultData,
892
        ];
893
894
        if (config('app.debug')) {
895
            Log::debug('ManticoreSearch::fuzzySearchIndexes results', [
896
                'index' => $index,
897
                'total_results' => count($resultIds),
898
            ]);
899
        }
900
901
        // Cache fuzzy results for 5 minutes
902
        if (! empty($resultIds)) {
903
            Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? 5));
904
        }
905
906
        return $result;
907
    }
908
909
    /**
910
     * Search predb index.
911
     *
912
     * @param  array|string  $searchTerm  Search term(s)
913
     * @return array Array of predb records
914
     */
915
    public function searchPredb(array|string $searchTerm): array
916
    {
917
        $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm;
0 ignored issues
show
introduced by
The condition is_array($searchTerm) is always true.
Loading history...
918
919
        $result = $this->searchIndexes($this->getPredbIndex(), $searchString, ['title', 'filename'], []);
920
921
        return $result['data'] ?? [];
922
    }
923
924
    public function searchIndexes(string $rt_index, ?string $searchString, array $column = [], array $searchArray = []): array
925
    {
926
        if (empty($rt_index)) {
927
            Log::warning('ManticoreSearch: Index name is required for search');
928
929
            return [];
930
        }
931
932
        if (config('app.debug')) {
933
            Log::debug('ManticoreSearch::searchIndexes called', [
934
                'rt_index' => $rt_index,
935
                'searchString' => $searchString,
936
                'column' => $column,
937
                'searchArray' => $searchArray,
938
            ]);
939
        }
940
941
        // Create cache key for search results
942
        $cacheKey = md5(serialize([
943
            'index' => $rt_index,
944
            'search' => $searchString,
945
            'columns' => $column,
946
            'array' => $searchArray,
947
        ]));
948
949
        $cached = Cache::get($cacheKey);
950
        if ($cached !== null) {
951
            if (config('app.debug')) {
952
                Log::debug('ManticoreSearch::searchIndexes returning cached result', [
953
                    'cacheKey' => $cacheKey,
954
                    'cached_ids_count' => count($cached['id'] ?? []),
955
                ]);
956
            }
957
958
            return $cached;
959
        }
960
961
        // Build query string once so we can retry if needed
962
        $searchExpr = null;
963
        if (! empty($searchArray)) {
964
            $terms = [];
965
            foreach ($searchArray as $key => $value) {
966
                if (! empty($value)) {
967
                    $escapedValue = self::escapeString($value);
968
                    if (! empty($escapedValue)) {
969
                        $terms[] = '@@relaxed @'.$key.' '.$escapedValue;
970
                    }
971
                }
972
            }
973
            if (! empty($terms)) {
974
                $searchExpr = implode(' ', $terms);
975
            } else {
976
                if (config('app.debug')) {
977
                    Log::debug('ManticoreSearch::searchIndexes no terms after escaping searchArray');
978
                }
979
980
                return [];
981
            }
982
        } elseif (! empty($searchString)) {
983
            $escapedSearch = self::escapeString($searchString);
984
            if (empty($escapedSearch)) {
985
                if (config('app.debug')) {
986
                    Log::debug('ManticoreSearch::searchIndexes escapedSearch is empty');
987
                }
988
989
                return [];
990
            }
991
992
            $searchColumns = '';
993
            if (! empty($column)) {
994
                if (count($column) > 1) {
995
                    $searchColumns = '@('.implode(',', $column).')';
996
                } else {
997
                    $searchColumns = '@'.$column[0];
998
                }
999
            }
1000
1001
            $searchExpr = '@@relaxed '.$searchColumns.' '.$escapedSearch;
1002
        } else {
1003
            return [];
1004
        }
1005
1006
        // Avoid explicit sort for predb_rt to prevent Manticore's "too many sort-by attributes" error
1007
        $avoidSortForIndex = ($rt_index === 'predb_rt');
1008
1009
        if (config('app.debug')) {
1010
            Log::debug('ManticoreSearch::searchIndexes executing query', [
1011
                'rt_index' => $rt_index,
1012
                'searchExpr' => $searchExpr,
1013
            ]);
1014
        }
1015
1016
        try {
1017
            // Use a fresh Search instance for every query to avoid parameter accumulation across calls
1018
            $query = (new Search($this->manticoreSearch))
1019
                ->setTable($rt_index)
1020
                ->option('ranker', 'sph04')
1021
                ->maxMatches(10000)
1022
                ->limit(10000)
1023
                ->stripBadUtf8(true)
1024
                ->search($searchExpr);
1025
1026
            if (! $avoidSortForIndex) {
1027
                $query->sort('id', 'desc');
1028
            }
1029
1030
            $results = $query->get();
1031
        } catch (ResponseException $e) {
1032
            // If we hit Manticore's "too many sort-by attributes" limit, retry once without explicit sorting
1033
            if (stripos($e->getMessage(), 'too many sort-by attributes') !== false) {
1034
                try {
1035
                    $query = (new Search($this->manticoreSearch))
1036
                        ->setTable($rt_index)
1037
                        ->option('ranker', 'sph04')
1038
                        ->maxMatches(10000)
1039
                        ->limit(10000)
1040
                        ->stripBadUtf8(true)
1041
                        ->search($searchExpr);
1042
1043
                    $results = $query->get();
1044
1045
                    Log::warning('ManticoreSearch: Retried search without sorting due to sort-by attributes limit', [
1046
                        'index' => $rt_index,
1047
                    ]);
1048
                } catch (ResponseException $e2) {
1049
                    Log::error('ManticoreSearch searchIndexes ResponseException after retry: '.$e2->getMessage(), [
1050
                        'index' => $rt_index,
1051
                        'search' => $searchString,
1052
                    ]);
1053
1054
                    return [];
1055
                }
1056
            } else {
1057
                Log::error('ManticoreSearch searchIndexes ResponseException: '.$e->getMessage(), [
1058
                    'index' => $rt_index,
1059
                    'search' => $searchString,
1060
                ]);
1061
1062
                return [];
1063
            }
1064
        } catch (RuntimeException $e) {
1065
            Log::error('ManticoreSearch searchIndexes RuntimeException: '.$e->getMessage(), [
1066
                'index' => $rt_index,
1067
                'search' => $searchString,
1068
            ]);
1069
1070
            return [];
1071
        } catch (\Throwable $e) {
1072
            Log::error('ManticoreSearch searchIndexes unexpected error: '.$e->getMessage(), [
1073
                'index' => $rt_index,
1074
                'search' => $searchString,
1075
            ]);
1076
1077
            return [];
1078
        }
1079
1080
        // Parse results and cache
1081
        $resultIds = [];
1082
        $resultData = [];
1083
        foreach ($results as $doc) {
1084
            $resultIds[] = $doc->getId();
1085
            $resultData[] = $doc->getData();
1086
        }
1087
1088
        $result = [
1089
            'id' => $resultIds,
1090
            'data' => $resultData,
1091
        ];
1092
1093
        // Only cache non-empty results to avoid caching temporary failures or empty index states
1094
        if (! empty($resultIds)) {
1095
            Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? 5));
1096
        }
1097
1098
        return $result;
1099
    }
1100
1101
    /**
1102
     * Get autocomplete suggestions for a search query.
1103
     * Searches the releases index and returns matching searchnames.
1104
     *
1105
     * @param  string  $query  The partial search query
1106
     * @param  string|null  $index  Index to search (defaults to releases index)
1107
     * @return array<array{suggest: string, distance: int, docs: int}>
1108
     */
1109
    public function autocomplete(string $query, ?string $index = null): array
1110
    {
1111
        $autocompleteConfig = $this->config['autocomplete'] ?? [
1112
            'enabled' => true,
1113
            'min_length' => 2,
1114
            'max_results' => 10,
1115
            'cache_minutes' => 10,
1116
        ];
1117
1118
        if (! ($autocompleteConfig['enabled'] ?? true)) {
1119
            return [];
1120
        }
1121
1122
        $query = trim($query);
1123
        $minLength = $autocompleteConfig['min_length'] ?? 2;
1124
        if (strlen($query) < $minLength) {
1125
            return [];
1126
        }
1127
1128
        $index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt');
1129
        $cacheKey = 'manticore:autocomplete:'.md5($index.$query);
1130
1131
        $cached = Cache::get($cacheKey);
1132
        if ($cached !== null) {
1133
            return $cached;
1134
        }
1135
1136
        $suggestions = [];
1137
        $maxResults = $autocompleteConfig['max_results'] ?? 10;
1138
1139
        try {
1140
            // Search releases index for matching searchnames
1141
            $escapedQuery = self::escapeString($query);
1142
            if (empty($escapedQuery)) {
1143
                return [];
1144
            }
1145
1146
            // Use relaxed search on searchname field
1147
            $searchExpr = '@@relaxed @searchname '.$escapedQuery;
1148
1149
            $search = (new Search($this->manticoreSearch))
1150
                ->setTable($index)
1151
                ->search($searchExpr)
1152
                ->sort('id', 'desc')
1153
                ->limit($maxResults * 3)
1154
                ->stripBadUtf8(true);
1155
1156
            $results = $search->get();
1157
1158
            $seen = [];
1159
            foreach ($results as $doc) {
1160
                $data = $doc->getData();
1161
                $searchname = $data['searchname'] ?? '';
1162
1163
                if (empty($searchname)) {
1164
                    continue;
1165
                }
1166
1167
                // Create a clean suggestion from the searchname
1168
                $suggestion = $this->extractSuggestion($searchname, $query);
1169
1170
                if (! empty($suggestion) && ! isset($seen[strtolower($suggestion)])) {
1171
                    $seen[strtolower($suggestion)] = true;
1172
                    $suggestions[] = [
1173
                        'suggest' => $suggestion,
1174
                        'distance' => 0,
1175
                        'docs' => 1,
1176
                    ];
1177
                }
1178
1179
                if (count($suggestions) >= $maxResults) {
1180
                    break;
1181
                }
1182
            }
1183
        } catch (\Throwable $e) {
1184
            if (config('app.debug')) {
1185
                Log::warning('ManticoreSearch autocomplete error: '.$e->getMessage());
1186
            }
1187
        }
1188
1189
        if (! empty($suggestions)) {
1190
            $cacheMinutes = (int) ($autocompleteConfig['cache_minutes'] ?? 10);
1191
            Cache::put($cacheKey, $suggestions, now()->addMinutes($cacheMinutes));
1192
        }
1193
1194
        return $suggestions;
1195
    }
1196
1197
    /**
1198
     * Extract a clean suggestion from a searchname.
1199
     *
1200
     * @param  string  $searchname  The full searchname
1201
     * @param  string  $query  The user's query
1202
     * @return string|null The extracted suggestion
1203
     */
1204
    private function extractSuggestion(string $searchname, string $query): ?string
1205
    {
1206
        // Clean up the searchname - remove file extensions, quality tags at the end
1207
        $clean = preg_replace('/\.(mkv|avi|mp4|wmv|nfo|nzb|par2|rar|zip|r\d+)$/i', '', $searchname);
1208
1209
        // Replace dots and underscores with spaces for readability
1210
        $clean = str_replace(['.', '_'], ' ', $clean);
1211
1212
        // Remove multiple spaces
1213
        $clean = preg_replace('/\s+/', ' ', $clean);
1214
        $clean = trim($clean);
1215
1216
        if (empty($clean)) {
1217
            return null;
1218
        }
1219
1220
        // If the clean name is reasonable length, use it
1221
        if (strlen($clean) <= 80) {
1222
            return $clean;
1223
        }
1224
1225
        // For very long names, try to extract the relevant part
1226
        // Find where the query matches and extract context around it
1227
        $pos = stripos($clean, $query);
1228
        if ($pos !== false) {
1229
            // Get up to 80 chars starting from the match position, or from beginning if match is early
1230
            $start = max(0, $pos - 10);
1231
            $extracted = substr($clean, $start, 80);
1232
1233
            // Clean up - don't cut mid-word
1234
            if ($start > 0) {
1235
                $extracted = preg_replace('/^\S*\s/', '', $extracted);
1236
            }
1237
            $extracted = preg_replace('/\s\S*$/', '', $extracted);
1238
1239
            return trim($extracted);
1240
        }
1241
1242
        // Fallback: just truncate
1243
        return substr($clean, 0, 80);
1244
    }
1245
1246
    /**
1247
     * Get spell correction suggestions ("Did you mean?").
1248
     *
1249
     * @param  string  $query  The search query to check
1250
     * @param  string|null  $index  Index to use for suggestions
1251
     * @return array<array{suggest: string, distance: int, docs: int}>
1252
     */
1253
    public function suggest(string $query, ?string $index = null): array
1254
    {
1255
        $suggestConfig = $this->config['suggest'] ?? [
1256
            'enabled' => true,
1257
            'max_edits' => 4,
1258
        ];
1259
1260
        if (! ($suggestConfig['enabled'] ?? true)) {
1261
            return [];
1262
        }
1263
1264
        $query = trim($query);
1265
        if (empty($query)) {
1266
            return [];
1267
        }
1268
1269
        $index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt');
1270
        $cacheKey = 'manticore:suggest:'.md5($index.$query);
1271
1272
        $cached = Cache::get($cacheKey);
1273
        if ($cached !== null) {
1274
            return $cached;
1275
        }
1276
1277
        $suggestions = [];
1278
1279
        try {
1280
            // Try native CALL SUGGEST first
1281
            $result = $this->manticoreSearch->suggest([
1282
                'table' => $index,
1283
                'body' => [
1284
                    'query' => $query,
1285
                    'options' => [
1286
                        'limit' => 5,
1287
                        'max_edits' => $suggestConfig['max_edits'] ?? 4,
1288
                    ],
1289
                ],
1290
            ]);
1291
1292
            if (! empty($result) && is_array($result)) {
1293
                foreach ($result as $item) {
1294
                    if (isset($item['suggest']) && $item['suggest'] !== $query) {
1295
                        $suggestions[] = [
1296
                            'suggest' => $item['suggest'],
1297
                            'distance' => $item['distance'] ?? 0,
1298
                            'docs' => $item['docs'] ?? 0,
1299
                        ];
1300
                    }
1301
                }
1302
            }
1303
        } catch (\Throwable $e) {
1304
            if (config('app.debug')) {
1305
                Log::debug('ManticoreSearch native suggest failed: '.$e->getMessage());
1306
            }
1307
        }
1308
1309
        // If native suggest didn't return results, try a fuzzy search fallback
1310
        if (empty($suggestions)) {
1311
            $suggestions = $this->suggestFallback($query, $index);
1312
        }
1313
1314
        if (! empty($suggestions)) {
1315
            Cache::put($cacheKey, $suggestions, now()->addMinutes($this->config['cache_minutes'] ?? 5));
1316
        }
1317
1318
        return $suggestions;
1319
    }
1320
1321
    /**
1322
     * Fallback suggest using similar searchname matches.
1323
     *
1324
     * @param  string  $query  The search query
1325
     * @param  string  $index  Index to search
1326
     * @return array<array{suggest: string, distance: int, docs: int}>
1327
     */
1328
    private function suggestFallback(string $query, string $index): array
1329
    {
1330
        try {
1331
            $escapedQuery = self::escapeString($query);
1332
            if (empty($escapedQuery)) {
1333
                return [];
1334
            }
1335
1336
            // Use relaxed search to find partial matches
1337
            $searchExpr = '@@relaxed @searchname '.$escapedQuery;
1338
1339
            $search = (new Search($this->manticoreSearch))
1340
                ->setTable($index)
1341
                ->search($searchExpr)
1342
                ->limit(20)
1343
                ->stripBadUtf8(true);
1344
1345
            $results = $search->get();
1346
1347
            // Extract common terms from the results that differ from the query
1348
            $termCounts = [];
1349
            foreach ($results as $doc) {
1350
                $data = $doc->getData();
1351
                $searchname = $data['searchname'] ?? '';
1352
1353
                // Extract words from searchname
1354
                $words = preg_split('/[\s.\-_]+/', strtolower($searchname));
1355
                foreach ($words as $word) {
1356
                    if (strlen($word) >= 3 && $word !== strtolower($query)) {
1357
                        // Check if word is similar to query (within edit distance)
1358
                        $distance = levenshtein(strtolower($query), $word);
1359
                        if ($distance > 0 && $distance <= 3) {
1360
                            if (! isset($termCounts[$word])) {
1361
                                $termCounts[$word] = ['count' => 0, 'distance' => $distance];
1362
                            }
1363
                            $termCounts[$word]['count']++;
1364
                        }
1365
                    }
1366
                }
1367
            }
1368
1369
            // Sort by count (most common first)
1370
            uasort($termCounts, fn ($a, $b) => $b['count'] - $a['count']);
1371
1372
            $suggestions = [];
1373
            foreach (array_slice($termCounts, 0, 5, true) as $term => $data) {
1374
                $suggestions[] = [
1375
                    'suggest' => $term,
1376
                    'distance' => $data['distance'],
1377
                    'docs' => $data['count'],
1378
                ];
1379
            }
1380
1381
            return $suggestions;
1382
        } catch (\Throwable $e) {
1383
            if (config('app.debug')) {
1384
                Log::debug('ManticoreSearch suggest fallback error: '.$e->getMessage());
1385
            }
1386
1387
            return [];
1388
        }
1389
    }
1390
1391
    /**
1392
     * Insert a movie into the movies search index.
1393
     *
1394
     * @param  array  $parameters  Movie data
1395
     */
1396
    public function insertMovie(array $parameters): void
1397
    {
1398
        if (empty($parameters['id'])) {
1399
            Log::warning('ManticoreSearch: Cannot insert movie without ID');
1400
1401
            return;
1402
        }
1403
1404
        try {
1405
            $document = [
1406
                'imdbid' => (int) ($parameters['imdbid'] ?? 0),
1407
                'tmdbid' => (int) ($parameters['tmdbid'] ?? 0),
1408
                'traktid' => (int) ($parameters['traktid'] ?? 0),
1409
                'title' => (string) ($parameters['title'] ?? ''),
1410
                'year' => (string) ($parameters['year'] ?? ''),
1411
                'genre' => (string) ($parameters['genre'] ?? ''),
1412
                'actors' => (string) ($parameters['actors'] ?? ''),
1413
                'director' => (string) ($parameters['director'] ?? ''),
1414
                'rating' => (string) ($parameters['rating'] ?? ''),
1415
                'plot' => (string) ($parameters['plot'] ?? ''),
1416
            ];
1417
1418
            $this->manticoreSearch->table($this->getMoviesIndex())
1419
                ->replaceDocument($document, $parameters['id']);
1420
1421
        } catch (ResponseException $e) {
1422
            Log::error('ManticoreSearch insertMovie ResponseException: '.$e->getMessage(), [
1423
                'movie_id' => $parameters['id'],
1424
            ]);
1425
        } catch (\Throwable $e) {
1426
            Log::error('ManticoreSearch insertMovie unexpected error: '.$e->getMessage(), [
1427
                'movie_id' => $parameters['id'],
1428
            ]);
1429
        }
1430
    }
1431
1432
    /**
1433
     * Update a movie in the search index.
1434
     *
1435
     * @param  int  $movieId  Movie ID
1436
     */
1437
    public function updateMovie(int $movieId): void
1438
    {
1439
        if (empty($movieId)) {
1440
            Log::warning('ManticoreSearch: Cannot update movie without ID');
1441
1442
            return;
1443
        }
1444
1445
        try {
1446
            $movie = MovieInfo::find($movieId);
1447
1448
            if ($movie !== null) {
1449
                $this->insertMovie($movie->toArray());
1450
            } else {
1451
                Log::warning('ManticoreSearch: Movie not found for update', ['id' => $movieId]);
1452
            }
1453
        } catch (\Throwable $e) {
1454
            Log::error('ManticoreSearch updateMovie error: '.$e->getMessage(), [
1455
                'movie_id' => $movieId,
1456
            ]);
1457
        }
1458
    }
1459
1460
    /**
1461
     * Delete a movie from the search index.
1462
     *
1463
     * @param  int  $id  Movie ID
1464
     */
1465
    public function deleteMovie(int $id): void
1466
    {
1467
        if (empty($id)) {
1468
            Log::warning('ManticoreSearch: Cannot delete movie without ID');
1469
1470
            return;
1471
        }
1472
1473
        try {
1474
            $this->manticoreSearch->table($this->getMoviesIndex())
1475
                ->deleteDocument($id);
1476
        } catch (ResponseException $e) {
1477
            Log::error('ManticoreSearch deleteMovie error: '.$e->getMessage(), [
1478
                'id' => $id,
1479
            ]);
1480
        }
1481
    }
1482
1483
    /**
1484
     * Bulk insert multiple movies into the index.
1485
     *
1486
     * @param  array  $movies  Array of movie data arrays
1487
     * @return array Results with 'success' and 'errors' counts
1488
     */
1489
    public function bulkInsertMovies(array $movies): array
1490
    {
1491
        if (empty($movies)) {
1492
            return ['success' => 0, 'errors' => 0];
1493
        }
1494
1495
        $success = 0;
1496
        $errors = 0;
1497
1498
        $documents = [];
1499
        foreach ($movies as $movie) {
1500
            if (empty($movie['id'])) {
1501
                $errors++;
1502
1503
                continue;
1504
            }
1505
1506
            $documents[] = [
1507
                'id' => $movie['id'],
1508
                'imdbid' => (int) ($movie['imdbid'] ?? 0),
1509
                'tmdbid' => (int) ($movie['tmdbid'] ?? 0),
1510
                'traktid' => (int) ($movie['traktid'] ?? 0),
1511
                'title' => (string) ($movie['title'] ?? ''),
1512
                'year' => (string) ($movie['year'] ?? ''),
1513
                'genre' => (string) ($movie['genre'] ?? ''),
1514
                'actors' => (string) ($movie['actors'] ?? ''),
1515
                'director' => (string) ($movie['director'] ?? ''),
1516
                'rating' => (string) ($movie['rating'] ?? ''),
1517
                'plot' => (string) ($movie['plot'] ?? ''),
1518
            ];
1519
        }
1520
1521
        if (! empty($documents)) {
1522
            try {
1523
                $this->manticoreSearch->table($this->getMoviesIndex())
1524
                    ->replaceDocuments($documents);
1525
                $success = count($documents);
1526
            } catch (\Throwable $e) {
1527
                Log::error('ManticoreSearch bulkInsertMovies error: '.$e->getMessage());
1528
                $errors += count($documents);
1529
            }
1530
        }
1531
1532
        return ['success' => $success, 'errors' => $errors];
1533
    }
1534
1535
    /**
1536
     * Search the movies index.
1537
     *
1538
     * @param  array|string  $searchTerm  Search term(s)
1539
     * @param  int  $limit  Maximum number of results
1540
     * @return array Array with 'id' (movie IDs) and 'data' (movie data)
1541
     */
1542
    public function searchMovies(array|string $searchTerm, int $limit = 1000): array
1543
    {
1544
        $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm;
0 ignored issues
show
introduced by
The condition is_array($searchTerm) is always true.
Loading history...
1545
1546
        return $this->searchIndexes($this->getMoviesIndex(), $searchString, ['title', 'actors', 'director'], []);
1547
    }
1548
1549
    /**
1550
     * Search movies by external ID (IMDB, TMDB, Trakt).
1551
     *
1552
     * @param  string  $field  Field name (imdbid, tmdbid, traktid)
1553
     * @param  int|string  $value  The external ID value
1554
     * @return array|null Movie data or null if not found
1555
     */
1556
    public function searchMovieByExternalId(string $field, int|string $value): ?array
1557
    {
1558
        if (empty($value) || ! in_array($field, ['imdbid', 'tmdbid', 'traktid'])) {
1559
            return null;
1560
        }
1561
1562
        $cacheKey = 'manticore:movie:'.$field.':'.$value;
1563
        $cached = Cache::get($cacheKey);
1564
        if ($cached !== null) {
1565
            return $cached;
1566
        }
1567
1568
        try {
1569
            $query = (new Search($this->manticoreSearch))
1570
                ->setTable($this->getMoviesIndex())
1571
                ->filter($field, '=', (int) $value)
1572
                ->limit(1);
1573
1574
            $results = $query->get();
1575
1576
            foreach ($results as $doc) {
1577
                $data = $doc->getData();
1578
                $data['id'] = $doc->getId();
1579
                Cache::put($cacheKey, $data, now()->addMinutes($this->config['cache_minutes'] ?? 5));
1580
1581
                return $data;
1582
            }
1583
        } catch (\Throwable $e) {
1584
            Log::error('ManticoreSearch searchMovieByExternalId error: '.$e->getMessage(), [
1585
                'field' => $field,
1586
                'value' => $value,
1587
            ]);
1588
        }
1589
1590
        return null;
1591
    }
1592
1593
    /**
1594
     * Insert a TV show into the tvshows search index.
1595
     *
1596
     * @param  array  $parameters  TV show data
1597
     */
1598
    public function insertTvShow(array $parameters): void
1599
    {
1600
        if (empty($parameters['id'])) {
1601
            Log::warning('ManticoreSearch: Cannot insert TV show without ID');
1602
1603
            return;
1604
        }
1605
1606
        try {
1607
            $document = [
1608
                'title' => (string) ($parameters['title'] ?? ''),
1609
                'tvdb' => (int) ($parameters['tvdb'] ?? 0),
1610
                'trakt' => (int) ($parameters['trakt'] ?? 0),
1611
                'tvmaze' => (int) ($parameters['tvmaze'] ?? 0),
1612
                'tvrage' => (int) ($parameters['tvrage'] ?? 0),
1613
                'imdb' => (int) ($parameters['imdb'] ?? 0),
1614
                'tmdb' => (int) ($parameters['tmdb'] ?? 0),
1615
                'started' => (string) ($parameters['started'] ?? ''),
1616
                'type' => (int) ($parameters['type'] ?? 0),
1617
            ];
1618
1619
            $this->manticoreSearch->table($this->getTvShowsIndex())
1620
                ->replaceDocument($document, $parameters['id']);
1621
1622
        } catch (ResponseException $e) {
1623
            Log::error('ManticoreSearch insertTvShow ResponseException: '.$e->getMessage(), [
1624
                'tvshow_id' => $parameters['id'],
1625
            ]);
1626
        } catch (\Throwable $e) {
1627
            Log::error('ManticoreSearch insertTvShow unexpected error: '.$e->getMessage(), [
1628
                'tvshow_id' => $parameters['id'],
1629
            ]);
1630
        }
1631
    }
1632
1633
    /**
1634
     * Update a TV show in the search index.
1635
     *
1636
     * @param  int  $videoId  Video/TV show ID
1637
     */
1638
    public function updateTvShow(int $videoId): void
1639
    {
1640
        if (empty($videoId)) {
1641
            Log::warning('ManticoreSearch: Cannot update TV show without ID');
1642
1643
            return;
1644
        }
1645
1646
        try {
1647
            $video = Video::find($videoId);
1648
1649
            if ($video !== null) {
1650
                $this->insertTvShow($video->toArray());
1651
            } else {
1652
                Log::warning('ManticoreSearch: TV show not found for update', ['id' => $videoId]);
1653
            }
1654
        } catch (\Throwable $e) {
1655
            Log::error('ManticoreSearch updateTvShow error: '.$e->getMessage(), [
1656
                'tvshow_id' => $videoId,
1657
            ]);
1658
        }
1659
    }
1660
1661
    /**
1662
     * Delete a TV show from the search index.
1663
     *
1664
     * @param  int  $id  TV show ID
1665
     */
1666
    public function deleteTvShow(int $id): void
1667
    {
1668
        if (empty($id)) {
1669
            Log::warning('ManticoreSearch: Cannot delete TV show without ID');
1670
1671
            return;
1672
        }
1673
1674
        try {
1675
            $this->manticoreSearch->table($this->getTvShowsIndex())
1676
                ->deleteDocument($id);
1677
        } catch (ResponseException $e) {
1678
            Log::error('ManticoreSearch deleteTvShow error: '.$e->getMessage(), [
1679
                'id' => $id,
1680
            ]);
1681
        }
1682
    }
1683
1684
    /**
1685
     * Bulk insert multiple TV shows into the index.
1686
     *
1687
     * @param  array  $tvShows  Array of TV show data arrays
1688
     * @return array Results with 'success' and 'errors' counts
1689
     */
1690
    public function bulkInsertTvShows(array $tvShows): array
1691
    {
1692
        if (empty($tvShows)) {
1693
            return ['success' => 0, 'errors' => 0];
1694
        }
1695
1696
        $success = 0;
1697
        $errors = 0;
1698
1699
        $documents = [];
1700
        foreach ($tvShows as $tvShow) {
1701
            if (empty($tvShow['id'])) {
1702
                $errors++;
1703
1704
                continue;
1705
            }
1706
1707
            $documents[] = [
1708
                'id' => $tvShow['id'],
1709
                'title' => (string) ($tvShow['title'] ?? ''),
1710
                'tvdb' => (int) ($tvShow['tvdb'] ?? 0),
1711
                'trakt' => (int) ($tvShow['trakt'] ?? 0),
1712
                'tvmaze' => (int) ($tvShow['tvmaze'] ?? 0),
1713
                'tvrage' => (int) ($tvShow['tvrage'] ?? 0),
1714
                'imdb' => (int) ($tvShow['imdb'] ?? 0),
1715
                'tmdb' => (int) ($tvShow['tmdb'] ?? 0),
1716
                'started' => (string) ($tvShow['started'] ?? ''),
1717
                'type' => (int) ($tvShow['type'] ?? 0),
1718
            ];
1719
        }
1720
1721
        if (! empty($documents)) {
1722
            try {
1723
                $this->manticoreSearch->table($this->getTvShowsIndex())
1724
                    ->replaceDocuments($documents);
1725
                $success = count($documents);
1726
            } catch (\Throwable $e) {
1727
                Log::error('ManticoreSearch bulkInsertTvShows error: '.$e->getMessage());
1728
                $errors += count($documents);
1729
            }
1730
        }
1731
1732
        return ['success' => $success, 'errors' => $errors];
1733
    }
1734
1735
    /**
1736
     * Search the TV shows index.
1737
     *
1738
     * @param  array|string  $searchTerm  Search term(s)
1739
     * @param  int  $limit  Maximum number of results
1740
     * @return array Array with 'id' (TV show IDs) and 'data' (TV show data)
1741
     */
1742
    public function searchTvShows(array|string $searchTerm, int $limit = 1000): array
1743
    {
1744
        $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm;
0 ignored issues
show
introduced by
The condition is_array($searchTerm) is always true.
Loading history...
1745
1746
        return $this->searchIndexes($this->getTvShowsIndex(), $searchString, ['title'], []);
1747
    }
1748
1749
    /**
1750
     * Search TV shows by external ID (TVDB, Trakt, TVMaze, TVRage, IMDB, TMDB).
1751
     *
1752
     * @param  string  $field  Field name (tvdb, trakt, tvmaze, tvrage, imdb, tmdb)
1753
     * @param  int|string  $value  The external ID value
1754
     * @return array|null TV show data or null if not found
1755
     */
1756
    public function searchTvShowByExternalId(string $field, int|string $value): ?array
1757
    {
1758
        if (empty($value) || ! in_array($field, ['tvdb', 'trakt', 'tvmaze', 'tvrage', 'imdb', 'tmdb'])) {
1759
            return null;
1760
        }
1761
1762
        $cacheKey = 'manticore:tvshow:'.$field.':'.$value;
1763
        $cached = Cache::get($cacheKey);
1764
        if ($cached !== null) {
1765
            return $cached;
1766
        }
1767
1768
        try {
1769
            $query = (new Search($this->manticoreSearch))
1770
                ->setTable($this->getTvShowsIndex())
1771
                ->filter($field, '=', (int) $value)
1772
                ->limit(1);
1773
1774
            $results = $query->get();
1775
1776
            foreach ($results as $doc) {
1777
                $data = $doc->getData();
1778
                $data['id'] = $doc->getId();
1779
                Cache::put($cacheKey, $data, now()->addMinutes($this->config['cache_minutes'] ?? 5));
1780
1781
                return $data;
1782
            }
1783
        } catch (\Throwable $e) {
1784
            Log::error('ManticoreSearch searchTvShowByExternalId error: '.$e->getMessage(), [
1785
                'field' => $field,
1786
                'value' => $value,
1787
            ]);
1788
        }
1789
1790
        return null;
1791
    }
1792
1793
    /**
1794
     * Search releases by external media IDs.
1795
     * Used to find releases associated with a specific movie or TV show.
1796
     *
1797
     * @param  array  $externalIds  Associative array of external IDs
1798
     * @param  int  $limit  Maximum number of results
1799
     * @return array Array of release IDs
1800
     */
1801
    public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array
1802
    {
1803
        if (empty($externalIds)) {
1804
            return [];
1805
        }
1806
1807
        $cacheKey = 'manticore:releases:extid:'.md5(serialize($externalIds));
1808
        $cached = Cache::get($cacheKey);
1809
        if ($cached !== null) {
1810
            return $cached;
1811
        }
1812
1813
        try {
1814
            $query = (new Search($this->manticoreSearch))
1815
                ->setTable($this->getReleasesIndex())
1816
                ->limit(min($limit, 10000));
1817
1818
            // Add filters for each external ID provided
1819
            foreach ($externalIds as $field => $value) {
1820
                if (! empty($value) && in_array($field, ['imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze', 'tvrage'])) {
1821
                    $query->filter($field, '=', (int) $value);
1822
                }
1823
            }
1824
1825
            $results = $query->get();
1826
1827
            $resultIds = [];
1828
            foreach ($results as $doc) {
1829
                $resultIds[] = $doc->getId();
1830
            }
1831
1832
            if (! empty($resultIds)) {
1833
                Cache::put($cacheKey, $resultIds, now()->addMinutes($this->config['cache_minutes'] ?? 5));
1834
            }
1835
1836
            return $resultIds;
1837
        } catch (\Throwable $e) {
1838
            Log::error('ManticoreSearch searchReleasesByExternalId error: '.$e->getMessage(), [
1839
                'externalIds' => $externalIds,
1840
            ]);
1841
        }
1842
1843
        return [];
1844
    }
1845
1846
    /**
1847
     * Search releases by category ID using the search index.
1848
     * This provides a fast way to get release IDs for a specific category without hitting the database.
1849
     *
1850
     * @param  array  $categoryIds  Array of category IDs to filter by
1851
     * @param  int  $limit  Maximum number of results
1852
     * @return array Array of release IDs
1853
     */
1854
    public function searchReleasesByCategory(array $categoryIds, int $limit = 1000): array
1855
    {
1856
        if (empty($categoryIds)) {
1857
            return [];
1858
        }
1859
1860
        // Filter out invalid category IDs (-1 means "all categories")
1861
        $validCategoryIds = array_filter($categoryIds, fn ($id) => $id > 0);
1862
        if (empty($validCategoryIds)) {
1863
            return [];
1864
        }
1865
1866
        $cacheKey = 'manticore:releases:cat:'.md5(serialize($validCategoryIds).':'.$limit);
1867
        $cached = Cache::get($cacheKey);
1868
        if ($cached !== null) {
1869
            return $cached;
1870
        }
1871
1872
        try {
1873
            $query = (new Search($this->manticoreSearch))
1874
                ->setTable($this->getReleasesIndex())
1875
                ->limit(min($limit, 10000));
1876
1877
            // Use IN filter for multiple category IDs
1878
            if (count($validCategoryIds) === 1) {
1879
                $query->filter('categories_id', '=', (int) $validCategoryIds[0]);
1880
            } else {
1881
                $query->filter('categories_id', 'in', array_map('intval', $validCategoryIds));
1882
            }
1883
1884
            $results = $query->get();
1885
1886
            $resultIds = [];
1887
            foreach ($results as $doc) {
1888
                $resultIds[] = $doc->getId();
1889
            }
1890
1891
            if (! empty($resultIds)) {
1892
                Cache::put($cacheKey, $resultIds, now()->addMinutes($this->config['cache_minutes'] ?? 5));
1893
            }
1894
1895
            return $resultIds;
1896
        } catch (\Throwable $e) {
1897
            Log::error('ManticoreSearch searchReleasesByCategory error: '.$e->getMessage(), [
1898
                'categoryIds' => $categoryIds,
1899
            ]);
1900
        }
1901
1902
        return [];
1903
    }
1904
1905
    /**
1906
     * Combined search: text search with category filtering.
1907
     * First searches by text, then filters by category IDs using the search index.
1908
     *
1909
     * @param  string  $searchTerm  Search text
1910
     * @param  array  $categoryIds  Array of category IDs to filter by (empty for all categories)
1911
     * @param  int  $limit  Maximum number of results
1912
     * @return array Array of release IDs
1913
     */
1914
    public function searchReleasesWithCategoryFilter(string $searchTerm, array $categoryIds = [], int $limit = 1000): array
1915
    {
1916
        if (empty($searchTerm)) {
1917
            // If no search term, just filter by category
1918
            return $this->searchReleasesByCategory($categoryIds, $limit);
1919
        }
1920
1921
        // Filter out invalid category IDs
1922
        $validCategoryIds = array_filter($categoryIds, fn ($id) => $id > 0);
1923
1924
        $cacheKey = 'manticore:releases:search_cat:'.md5($searchTerm.':'.serialize($validCategoryIds).':'.$limit);
1925
        $cached = Cache::get($cacheKey);
1926
        if ($cached !== null) {
1927
            return $cached;
1928
        }
1929
1930
        try {
1931
            $escapedSearch = self::escapeString($searchTerm);
1932
            if (empty($escapedSearch)) {
1933
                return $this->searchReleasesByCategory($categoryIds, $limit);
1934
            }
1935
1936
            $searchExpr = '@@relaxed @searchname '.$escapedSearch;
1937
1938
            $query = (new Search($this->manticoreSearch))
1939
                ->setTable($this->getReleasesIndex())
1940
                ->search($searchExpr)
1941
                ->option('ranker', 'sph04')
1942
                ->stripBadUtf8(true)
1943
                ->limit(min($limit, 10000));
1944
1945
            // Add category filter if provided
1946
            if (! empty($validCategoryIds)) {
1947
                if (count($validCategoryIds) === 1) {
1948
                    $query->filter('categories_id', '=', (int) $validCategoryIds[0]);
1949
                } else {
1950
                    $query->filter('categories_id', 'in', array_map('intval', $validCategoryIds));
1951
                }
1952
            }
1953
1954
            $results = $query->get();
1955
1956
            $resultIds = [];
1957
            foreach ($results as $doc) {
1958
                $resultIds[] = $doc->getId();
1959
            }
1960
1961
            if (! empty($resultIds)) {
1962
                Cache::put($cacheKey, $resultIds, now()->addMinutes($this->config['cache_minutes'] ?? 5));
1963
            }
1964
1965
            return $resultIds;
1966
        } catch (\Throwable $e) {
1967
            Log::error('ManticoreSearch searchReleasesWithCategoryFilter error: '.$e->getMessage(), [
1968
                'searchTerm' => $searchTerm,
1969
                'categoryIds' => $categoryIds,
1970
            ]);
1971
        }
1972
1973
        return [];
1974
    }
1975
}
1976