ElasticSearchDriver::bulkInsertTvShows()   B
last analyzed

Complexity

Conditions 6
Paths 10

Size

Total Lines 52
Code Lines 33

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 33
c 1
b 0
f 0
dl 0
loc 52
rs 8.7697
cc 6
nc 10
nop 1

How to fix   Long Method   

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\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 Elasticsearch\Client;
10
use Elasticsearch\ClientBuilder;
11
use Elasticsearch\Common\Exceptions\ElasticsearchException;
12
use Elasticsearch\Common\Exceptions\Missing404Exception;
13
use GuzzleHttp\Ring\Client\CurlHandler;
14
use Illuminate\Support\Collection;
15
use Illuminate\Support\Facades\Cache;
16
use Illuminate\Support\Facades\DB;
17
use Illuminate\Support\Facades\Log;
18
use RuntimeException;
19
20
/**
21
 * Elasticsearch driver for full-text search functionality.
22
 *
23
 * Provides search functionality for the releases and predb indices with
24
 * caching, connection pooling, and automatic reconnection.
25
 */
26
class ElasticSearchDriver implements SearchDriverInterface
27
{
28
    private const CACHE_TTL_MINUTES = 5;
29
30
    private const SCROLL_TIMEOUT = '30s';
31
32
    private const MAX_RESULTS = 10000;
33
34
    private const AVAILABILITY_CHECK_CACHE_TTL = 30; // seconds
35
36
    private const DEFAULT_TIMEOUT = 10;
37
38
    private const DEFAULT_CONNECT_TIMEOUT = 5;
39
40
    private const AUTOCOMPLETE_CACHE_MINUTES = 10;
41
42
    private const AUTOCOMPLETE_MAX_RESULTS = 10;
43
44
    private const AUTOCOMPLETE_MIN_LENGTH = 2;
45
46
    private static ?Client $client = null;
47
48
    private static ?bool $availabilityCache = null;
49
50
    private static ?int $availabilityCacheTime = null;
51
52
    protected array $config;
53
54
    public function __construct(array $config = [])
55
    {
56
        $this->config = ! empty($config) ? $config : config('search.drivers.elasticsearch');
57
    }
58
59
    /**
60
     * Get the driver name.
61
     */
62
    public function getDriverName(): string
63
    {
64
        return 'elasticsearch';
65
    }
66
67
    /**
68
     * Check if Elasticsearch is available.
69
     */
70
    public function isAvailable(): bool
71
    {
72
        return $this->isElasticsearchAvailable();
73
    }
74
75
    /**
76
     * Get or create an Elasticsearch client with proper cURL configuration.
77
     *
78
     * Uses a singleton pattern to reuse the client connection across requests.
79
     *
80
     * @throws RuntimeException When client initialization fails
81
     */
82
    private function getClient(): Client
83
    {
84
        if (self::$client === null) {
85
            try {
86
                if (! extension_loaded('curl')) {
87
                    throw new RuntimeException('cURL extension is not loaded');
88
                }
89
90
                if (empty($this->config)) {
91
                    throw new RuntimeException('Elasticsearch configuration not found');
92
                }
93
94
                $clientBuilder = ClientBuilder::create();
95
                $hosts = $this->buildHostsArray($this->config['hosts'] ?? []);
96
97
                if (empty($hosts)) {
98
                    throw new RuntimeException('No Elasticsearch hosts configured');
99
                }
100
101
                if (config('app.debug')) {
102
                    Log::debug('Elasticsearch client initializing', [
103
                        'hosts' => $hosts,
104
                    ]);
105
                }
106
107
                $clientBuilder->setHosts($hosts);
108
                $clientBuilder->setHandler(new CurlHandler);
109
                $clientBuilder->setConnectionParams([
110
                    'timeout' => $this->config['timeout'] ?? self::DEFAULT_TIMEOUT,
111
                    'connect_timeout' => $this->config['connect_timeout'] ?? self::DEFAULT_CONNECT_TIMEOUT,
112
                ]);
113
114
                // Enable retries for better resilience
115
                $clientBuilder->setRetries($this->config['retries'] ?? 2);
116
117
                self::$client = $clientBuilder->build();
118
119
                if (config('app.debug')) {
120
                    Log::debug('Elasticsearch client initialized successfully');
121
                }
122
123
            } catch (\Throwable $e) {
124
                Log::error('Failed to initialize Elasticsearch client: '.$e->getMessage(), [
125
                    'exception_class' => get_class($e),
126
                ]);
127
                throw new RuntimeException('Elasticsearch client initialization failed: '.$e->getMessage());
128
            }
129
        }
130
131
        return self::$client;
0 ignored issues
show
Bug Best Practice introduced by
The expression return self::client could return the type null which is incompatible with the type-hinted return Elasticsearch\Client. Consider adding an additional type-check to rule them out.
Loading history...
132
    }
133
134
    /**
135
     * Build hosts array from configuration.
136
     *
137
     * @param  array  $configHosts  Configuration hosts array
138
     * @return array Formatted hosts array for Elasticsearch client
139
     */
140
    private function buildHostsArray(array $configHosts): array
141
    {
142
        $hosts = [];
143
144
        foreach ($configHosts as $host) {
145
            $hostConfig = [
146
                'host' => $host['host'] ?? 'localhost',
147
                'port' => $host['port'] ?? 9200,
148
            ];
149
150
            if (! empty($host['scheme'])) {
151
                $hostConfig['scheme'] = $host['scheme'];
152
            }
153
154
            if (! empty($host['user']) && ! empty($host['pass'])) {
155
                $hostConfig['user'] = $host['user'];
156
                $hostConfig['pass'] = $host['pass'];
157
            }
158
159
            $hosts[] = $hostConfig;
160
        }
161
162
        return $hosts;
163
    }
164
165
    /**
166
     * Check if Elasticsearch is available.
167
     *
168
     * Caches the availability status to avoid frequent ping requests.
169
     */
170
    private function isElasticsearchAvailable(): bool
171
    {
172
        $now = time();
173
174
        // Return cached result if still valid
175
        if (self::$availabilityCache !== null
176
            && self::$availabilityCacheTime !== null
177
            && ($now - self::$availabilityCacheTime) < self::AVAILABILITY_CHECK_CACHE_TTL) {
178
            return self::$availabilityCache;
179
        }
180
181
        try {
182
            $client = $this->getClient();
183
            $result = $client->ping();
184
185
            if (config('app.debug')) {
186
                Log::debug('Elasticsearch ping result', ['available' => $result]);
187
            }
188
189
            self::$availabilityCache = $result;
190
            self::$availabilityCacheTime = $now;
191
192
            return $result;
193
        } catch (\Throwable $e) {
194
            Log::warning('Elasticsearch is not available: '.$e->getMessage(), [
195
                'exception_class' => get_class($e),
196
            ]);
197
198
            self::$availabilityCache = false;
199
            self::$availabilityCacheTime = $now;
200
201
            return false;
202
        }
203
    }
204
205
    /**
206
     * Reset the client connection (useful for testing or reconnection).
207
     */
208
    public function resetConnection(): void
209
    {
210
        self::$client = null;
211
        self::$availabilityCache = null;
212
        self::$availabilityCacheTime = null;
213
    }
214
215
    /**
216
     * Check if autocomplete is enabled.
217
     */
218
    public function isAutocompleteEnabled(): bool
219
    {
220
        return ($this->config['autocomplete']['enabled'] ?? true) && $this->isElasticsearchAvailable();
221
    }
222
223
    /**
224
     * Check if suggest is enabled.
225
     */
226
    public function isSuggestEnabled(): bool
227
    {
228
        return ($this->config['suggest']['enabled'] ?? true) && $this->isElasticsearchAvailable();
229
    }
230
231
    /**
232
     * Check if fuzzy search is enabled.
233
     */
234
    public function isFuzzyEnabled(): bool
235
    {
236
        return ($this->config['fuzzy']['enabled'] ?? true) && $this->isElasticsearchAvailable();
237
    }
238
239
    /**
240
     * Get fuzzy search configuration.
241
     */
242
    public function getFuzzyConfig(): array
243
    {
244
        return $this->config['fuzzy'] ?? [
245
            'enabled' => true,
246
            'fuzziness' => 'AUTO',
247
            'prefix_length' => 2,
248
            'max_expansions' => 50,
249
        ];
250
    }
251
252
    /**
253
     * Get the releases index name.
254
     */
255
    public function getReleasesIndex(): string
256
    {
257
        return $this->config['indexes']['releases'] ?? 'releases';
258
    }
259
260
    /**
261
     * Get the predb index name.
262
     */
263
    public function getPredbIndex(): string
264
    {
265
        return $this->config['indexes']['predb'] ?? 'predb';
266
    }
267
268
    /**
269
     * Get the movies index name.
270
     */
271
    public function getMoviesIndex(): string
272
    {
273
        return $this->config['indexes']['movies'] ?? 'movies';
274
    }
275
276
    /**
277
     * Get the TV shows index name.
278
     */
279
    public function getTvShowsIndex(): string
280
    {
281
        return $this->config['indexes']['tvshows'] ?? 'tvshows';
282
    }
283
284
    /**
285
     * Escape special characters for Elasticsearch.
286
     */
287
    public static function escapeString(string $string): string
288
    {
289
        if (empty($string) || $string === '*') {
290
            return '';
291
        }
292
293
        // Replace dots with spaces for release name searches
294
        $string = str_replace('.', ' ', $string);
295
296
        // Remove multiple consecutive spaces
297
        $string = preg_replace('/\s+/', ' ', trim($string));
298
299
        return $string;
300
    }
301
302
    /**
303
     * Get autocomplete suggestions for a search query.
304
     * Searches the releases index and returns matching searchnames.
305
     *
306
     * @param  string  $query  The partial search query
307
     * @param  string|null  $index  Index to search (defaults to releases index)
308
     * @return array<array{suggest: string, distance: int, docs: int}>
309
     */
310
    public function autocomplete(string $query, ?string $index = null): array
311
    {
312
        if (! $this->isAutocompleteEnabled()) {
313
            return [];
314
        }
315
316
        $query = trim($query);
317
        $minLength = (int) ($this->config['autocomplete']['min_length'] ?? self::AUTOCOMPLETE_MIN_LENGTH);
318
        if (strlen($query) < $minLength) {
319
            return [];
320
        }
321
322
        $index = $index ?? $this->getReleasesIndex();
323
        $cacheKey = 'es:autocomplete:'.md5($index.$query);
324
325
        $cached = Cache::get($cacheKey);
326
        if ($cached !== null) {
327
            return $cached;
328
        }
329
330
        $suggestions = [];
331
        $maxResults = (int) ($this->config['autocomplete']['max_results'] ?? self::AUTOCOMPLETE_MAX_RESULTS);
332
333
        try {
334
            $client = $this->getClient();
335
336
            // Use a prefix/match query on searchname field
337
            $searchParams = [
338
                'index' => $index,
339
                'body' => [
340
                    'query' => [
341
                        'bool' => [
342
                            'should' => [
343
                                // Prefix match for autocomplete-like behavior
344
                                [
345
                                    'match_phrase_prefix' => [
346
                                        'searchname' => [
347
                                            'query' => $query,
348
                                            'max_expansions' => 50,
349
                                        ],
350
                                    ],
351
                                ],
352
                                // Also include regular match for better results
353
                                [
354
                                    'match' => [
355
                                        'searchname' => [
356
                                            'query' => $query,
357
                                            'fuzziness' => 'AUTO',
358
                                        ],
359
                                    ],
360
                                ],
361
                            ],
362
                            'minimum_should_match' => 1,
363
                        ],
364
                    ],
365
                    'size' => $maxResults * 3,
366
                    '_source' => ['searchname'],
367
                    'sort' => [
368
                        // Sort by date first to get latest results, then by score
369
                        ['add_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']],
370
                        ['_score' => ['order' => 'desc']],
371
                    ],
372
                ],
373
            ];
374
375
            $response = $client->search($searchParams);
376
377
            $seen = [];
378
            if (isset($response['hits']['hits'])) {
379
                foreach ($response['hits']['hits'] as $hit) {
380
                    $searchname = $hit['_source']['searchname'] ?? '';
381
382
                    if (empty($searchname)) {
383
                        continue;
384
                    }
385
386
                    // Create a clean suggestion from the searchname
387
                    $suggestion = $this->extractSuggestion($searchname, $query);
388
389
                    if (! empty($suggestion) && ! isset($seen[strtolower($suggestion)])) {
390
                        $seen[strtolower($suggestion)] = true;
391
                        $suggestions[] = [
392
                            'suggest' => $suggestion,
393
                            'distance' => 0,
394
                            'docs' => 1,
395
                        ];
396
                    }
397
398
                    if (count($suggestions) >= $maxResults) {
399
                        break;
400
                    }
401
                }
402
            }
403
        } catch (\Throwable $e) {
404
            if (config('app.debug')) {
405
                Log::warning('ElasticSearch autocomplete error: '.$e->getMessage());
406
            }
407
        }
408
409
        if (! empty($suggestions)) {
410
            $cacheMinutes = (int) ($this->config['autocomplete']['cache_minutes'] ?? self::AUTOCOMPLETE_CACHE_MINUTES);
411
            Cache::put($cacheKey, $suggestions, now()->addMinutes($cacheMinutes));
412
        }
413
414
        return $suggestions;
415
    }
416
417
    /**
418
     * Get spell correction suggestions ("Did you mean?").
419
     *
420
     * @param  string  $query  The search query to check
421
     * @param  string|null  $index  Index to use for suggestions
422
     * @return array<array{suggest: string, distance: int, docs: int}>
423
     */
424
    public function suggest(string $query, ?string $index = null): array
425
    {
426
        if (! $this->isSuggestEnabled()) {
427
            return [];
428
        }
429
430
        $query = trim($query);
431
        if (empty($query)) {
432
            return [];
433
        }
434
435
        $index = $index ?? $this->getReleasesIndex();
436
        $cacheKey = 'es:suggest:'.md5($index.$query);
437
438
        $cached = Cache::get($cacheKey);
439
        if ($cached !== null) {
440
            return $cached;
441
        }
442
443
        $suggestions = [];
444
445
        try {
446
            $client = $this->getClient();
447
448
            // Use Elasticsearch suggest API with phrase suggester
449
            $searchParams = [
450
                'index' => $index,
451
                'body' => [
452
                    'suggest' => [
453
                        'text' => $query,
454
                        'searchname_suggest' => [
455
                            'phrase' => [
456
                                'field' => 'searchname',
457
                                'size' => 5,
458
                                'gram_size' => 3,
459
                                'direct_generator' => [
460
                                    [
461
                                        'field' => 'searchname',
462
                                        'suggest_mode' => 'popular',
463
                                    ],
464
                                ],
465
                                'highlight' => [
466
                                    'pre_tag' => '',
467
                                    'post_tag' => '',
468
                                ],
469
                            ],
470
                        ],
471
                    ],
472
                ],
473
            ];
474
475
            $response = $client->search($searchParams);
476
477
            if (isset($response['suggest']['searchname_suggest'][0]['options'])) {
478
                foreach ($response['suggest']['searchname_suggest'][0]['options'] as $option) {
479
                    $suggestedText = $option['text'] ?? '';
480
                    if (! empty($suggestedText) && strtolower($suggestedText) !== strtolower($query)) {
481
                        $suggestions[] = [
482
                            'suggest' => $suggestedText,
483
                            'distance' => 1,
484
                            'docs' => (int) ($option['freq'] ?? 1),
485
                        ];
486
                    }
487
                }
488
            }
489
        } catch (\Throwable $e) {
490
            if (config('app.debug')) {
491
                Log::debug('ElasticSearch native suggest failed: '.$e->getMessage());
492
            }
493
        }
494
495
        // Fallback: if native suggest didn't work, use fuzzy search
496
        if (empty($suggestions)) {
497
            $suggestions = $this->suggestFallback($query, $index);
498
        }
499
500
        if (! empty($suggestions)) {
501
            Cache::put($cacheKey, $suggestions, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
502
        }
503
504
        return $suggestions;
505
    }
506
507
    /**
508
     * Fallback suggest using fuzzy search on searchnames.
509
     *
510
     * @param  string  $query  The search query
511
     * @param  string  $index  Index to search
512
     * @return array<array{suggest: string, distance: int, docs: int}>
513
     */
514
    private function suggestFallback(string $query, string $index): array
515
    {
516
        try {
517
            $client = $this->getClient();
518
519
            // Use fuzzy match to find similar terms
520
            $searchParams = [
521
                'index' => $index,
522
                'body' => [
523
                    'query' => [
524
                        'match' => [
525
                            'searchname' => [
526
                                'query' => $query,
527
                                'fuzziness' => 'AUTO',
528
                            ],
529
                        ],
530
                    ],
531
                    'size' => 20,
532
                    '_source' => ['searchname'],
533
                ],
534
            ];
535
536
            $response = $client->search($searchParams);
537
538
            // Extract common terms from results that differ from the query
539
            $termCounts = [];
540
            if (isset($response['hits']['hits'])) {
541
                foreach ($response['hits']['hits'] as $hit) {
542
                    $searchname = $hit['_source']['searchname'] ?? '';
543
                    $words = preg_split('/[\s.\-_]+/', strtolower($searchname));
544
545
                    foreach ($words as $word) {
546
                        if (strlen($word) >= 3 && $word !== strtolower($query)) {
547
                            $distance = levenshtein(strtolower($query), $word);
548
                            if ($distance > 0 && $distance <= 3) {
549
                                if (! isset($termCounts[$word])) {
550
                                    $termCounts[$word] = ['count' => 0, 'distance' => $distance];
551
                                }
552
                                $termCounts[$word]['count']++;
553
                            }
554
                        }
555
                    }
556
                }
557
            }
558
559
            // Sort by count
560
            uasort($termCounts, fn ($a, $b) => $b['count'] - $a['count']);
561
562
            $suggestions = [];
563
            foreach (array_slice($termCounts, 0, 5, true) as $term => $data) {
564
                $suggestions[] = [
565
                    'suggest' => $term,
566
                    'distance' => $data['distance'],
567
                    'docs' => $data['count'],
568
                ];
569
            }
570
571
            return $suggestions;
572
        } catch (\Throwable $e) {
573
            if (config('app.debug')) {
574
                Log::warning('ElasticSearch suggest fallback error: '.$e->getMessage());
575
            }
576
577
            return [];
578
        }
579
    }
580
581
    /**
582
     * Extract a clean suggestion from a searchname.
583
     *
584
     * @param  string  $searchname  The full searchname
585
     * @param  string  $query  The user's query
586
     * @return string|null The extracted suggestion
587
     */
588
    private function extractSuggestion(string $searchname, string $query): ?string
589
    {
590
        // Clean up the searchname - remove file extensions
591
        $clean = preg_replace('/\.(mkv|avi|mp4|wmv|nfo|nzb|par2|rar|zip|r\d+)$/i', '', $searchname);
592
593
        // Replace dots and underscores with spaces for readability
594
        $clean = str_replace(['.', '_'], ' ', $clean);
595
596
        // Remove multiple spaces
597
        $clean = preg_replace('/\s+/', ' ', $clean);
598
        $clean = trim($clean);
599
600
        if (empty($clean)) {
601
            return null;
602
        }
603
604
        // If the clean name is reasonable length, use it
605
        if (strlen($clean) <= 80) {
606
            return $clean;
607
        }
608
609
        // For very long names, try to extract the relevant part
610
        $pos = stripos($clean, $query);
611
        if ($pos !== false) {
612
            $start = max(0, $pos - 10);
613
            $extracted = substr($clean, $start, 80);
614
615
            if ($start > 0) {
616
                $extracted = preg_replace('/^\S*\s/', '', $extracted);
617
            }
618
            $extracted = preg_replace('/\s\S*$/', '', $extracted);
619
620
            return trim($extracted);
621
        }
622
623
        return substr($clean, 0, 80);
624
    }
625
626
    /**
627
     * Search releases index.
628
     *
629
     * @param  array|string  $phrases  Search phrases - can be a string, indexed array of terms, or associative array with field names
630
     * @param  int  $limit  Maximum number of results
631
     * @return array Array of release IDs
632
     */
633
    public function searchReleases(array|string $phrases, int $limit = 1000): array
634
    {
635
        // Normalize the input to a search string
636
        if (is_string($phrases)) {
0 ignored issues
show
introduced by
The condition is_string($phrases) is always false.
Loading history...
637
            $searchString = $phrases;
638
        } elseif (is_array($phrases)) {
0 ignored issues
show
introduced by
The condition is_array($phrases) is always true.
Loading history...
639
            // Check if it's an associative array (has string keys like 'searchname')
640
            $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0;
641
642
            if ($isAssociative) {
643
                // Extract values from associative array
644
                $searchString = implode(' ', array_values($phrases));
645
            } else {
646
                // Indexed array - combine values
647
                $searchString = implode(' ', $phrases);
648
            }
649
        } else {
650
            return [];
651
        }
652
653
        $result = $this->indexSearch($searchString, $limit);
654
655
        return is_array($result) ? $result : $result->toArray();
0 ignored issues
show
introduced by
The condition is_array($result) is always true.
Loading history...
656
    }
657
658
    /**
659
     * Search releases with fuzzy fallback.
660
     *
661
     * If exact search returns no results and fuzzy is enabled, this method
662
     * will automatically try a fuzzy search as a fallback.
663
     *
664
     * @param  array|string  $phrases  Search phrases
665
     * @param  int  $limit  Maximum number of results
666
     * @param  bool  $forceFuzzy  Force fuzzy search regardless of exact results
667
     * @return array Array with 'ids' (release IDs) and 'fuzzy' (bool indicating if fuzzy was used)
668
     */
669
    public function searchReleasesWithFuzzy(array|string $phrases, int $limit = 1000, bool $forceFuzzy = false): array
670
    {
671
        // First try exact search unless forcing fuzzy
672
        if (! $forceFuzzy) {
673
            $exactResults = $this->searchReleases($phrases, $limit);
674
            if (! empty($exactResults)) {
675
                return [
676
                    'ids' => $exactResults,
677
                    'fuzzy' => false,
678
                ];
679
            }
680
        }
681
682
        // If exact search returned nothing (or forcing fuzzy) and fuzzy is enabled, try fuzzy search
683
        if ($this->isFuzzyEnabled()) {
684
            $fuzzyResults = $this->fuzzySearchReleases($phrases, $limit);
685
            if (! empty($fuzzyResults)) {
686
                return [
687
                    'ids' => $fuzzyResults,
688
                    'fuzzy' => true,
689
                ];
690
            }
691
        }
692
693
        return [
694
            'ids' => [],
695
            'fuzzy' => false,
696
        ];
697
    }
698
699
    /**
700
     * Perform fuzzy search on releases index.
701
     *
702
     * Uses Elasticsearch's fuzzy matching to find results with typo tolerance.
703
     *
704
     * @param  array|string  $phrases  Search phrases
705
     * @param  int  $limit  Maximum number of results
706
     * @return array Array of release IDs
707
     */
708
    public function fuzzySearchReleases(array|string $phrases, int $limit = 1000): array
709
    {
710
        if (! $this->isFuzzyEnabled() || ! $this->isElasticsearchAvailable()) {
711
            return [];
712
        }
713
714
        // Normalize the input to a search string
715
        if (is_string($phrases)) {
0 ignored issues
show
introduced by
The condition is_string($phrases) is always false.
Loading history...
716
            $searchString = $phrases;
717
        } elseif (is_array($phrases)) {
0 ignored issues
show
introduced by
The condition is_array($phrases) is always true.
Loading history...
718
            $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0;
719
            if ($isAssociative) {
720
                $searchString = implode(' ', array_values($phrases));
721
            } else {
722
                $searchString = implode(' ', $phrases);
723
            }
724
        } else {
725
            return [];
726
        }
727
728
        $keywords = $this->sanitizeSearchTerms($searchString);
729
        if (empty($keywords)) {
730
            return [];
731
        }
732
733
        $fuzzyConfig = $this->getFuzzyConfig();
734
        $cacheKey = $this->buildCacheKey('fuzzy_search', [$keywords, $limit, $fuzzyConfig]);
735
        $cached = Cache::get($cacheKey);
736
        if ($cached !== null) {
737
            return $cached;
738
        }
739
740
        try {
741
            $client = $this->getClient();
742
743
            // Build fuzzy query
744
            $searchParams = [
745
                'index' => $this->getReleasesIndex(),
746
                'body' => [
747
                    'query' => [
748
                        'bool' => [
749
                            'should' => [
750
                                // Fuzzy match on searchname
751
                                [
752
                                    'match' => [
753
                                        'searchname' => [
754
                                            'query' => $keywords,
755
                                            'fuzziness' => $fuzzyConfig['fuzziness'] ?? 'AUTO',
756
                                            'prefix_length' => $fuzzyConfig['prefix_length'] ?? 2,
757
                                            'max_expansions' => $fuzzyConfig['max_expansions'] ?? 50,
758
                                            'boost' => 2,
759
                                        ],
760
                                    ],
761
                                ],
762
                                // Fuzzy match on plainsearchname
763
                                [
764
                                    'match' => [
765
                                        'plainsearchname' => [
766
                                            'query' => $keywords,
767
                                            'fuzziness' => $fuzzyConfig['fuzziness'] ?? 'AUTO',
768
                                            'prefix_length' => $fuzzyConfig['prefix_length'] ?? 2,
769
                                            'max_expansions' => $fuzzyConfig['max_expansions'] ?? 50,
770
                                            'boost' => 1.5,
771
                                        ],
772
                                    ],
773
                                ],
774
                                // Fuzzy match on name
775
                                [
776
                                    'match' => [
777
                                        'name' => [
778
                                            'query' => $keywords,
779
                                            'fuzziness' => $fuzzyConfig['fuzziness'] ?? 'AUTO',
780
                                            'prefix_length' => $fuzzyConfig['prefix_length'] ?? 2,
781
                                            'max_expansions' => $fuzzyConfig['max_expansions'] ?? 50,
782
                                            'boost' => 1.2,
783
                                        ],
784
                                    ],
785
                                ],
786
                            ],
787
                            'minimum_should_match' => 1,
788
                        ],
789
                    ],
790
                    'size' => min($limit, self::MAX_RESULTS),
791
                    '_source' => false,
792
                    'sort' => [
793
                        ['_score' => ['order' => 'desc']],
794
                    ],
795
                ],
796
            ];
797
798
            $response = $client->search($searchParams);
799
800
            $ids = [];
801
            if (isset($response['hits']['hits'])) {
802
                foreach ($response['hits']['hits'] as $hit) {
803
                    $ids[] = (int) $hit['_id'];
804
                }
805
            }
806
807
            Cache::put($cacheKey, $ids, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
808
809
            return $ids;
810
811
        } catch (ElasticsearchException $e) {
812
            Log::error('ElasticSearch fuzzySearchReleases error: '.$e->getMessage(), [
813
                'keywords' => $keywords,
814
                'limit' => $limit,
815
            ]);
816
817
            return [];
818
        } catch (\Throwable $e) {
819
            Log::error('ElasticSearch fuzzySearchReleases unexpected error: '.$e->getMessage(), [
820
                'keywords' => $keywords,
821
            ]);
822
823
            return [];
824
        }
825
    }
826
827
    /**
828
     * Search the predb index.
829
     *
830
     * @param  array|string  $searchTerm  Search term(s)
831
     * @return array Array of predb records
832
     */
833
    public function searchPredb(array|string $searchTerm): array
834
    {
835
        $result = $this->predbIndexSearch($searchTerm);
836
837
        return is_array($result) ? $result : $result->toArray();
0 ignored issues
show
introduced by
The condition is_array($result) is always true.
Loading history...
838
    }
839
840
    /**
841
     * Search releases index.
842
     *
843
     * @param  array|string  $phrases  Search phrases
844
     * @param  int  $limit  Maximum number of results
845
     * @return array|Collection Array of release IDs
846
     */
847
    public function indexSearch(array|string $phrases, int $limit): array|Collection
848
    {
849
        if (empty($phrases) || ! $this->isElasticsearchAvailable()) {
850
            if (config('app.debug')) {
851
                Log::debug('ElasticSearch indexSearch: empty phrases or ES not available', [
852
                    'phrases_empty' => empty($phrases),
853
                    'es_available' => $this->isElasticsearchAvailable(),
854
                ]);
855
            }
856
857
            return [];
858
        }
859
860
        $keywords = $this->sanitizeSearchTerms($phrases);
861
862
        if (config('app.debug')) {
863
            Log::debug('ElasticSearch indexSearch: sanitized keywords', [
864
                'original' => is_array($phrases) ? implode(' ', $phrases) : $phrases,
0 ignored issues
show
introduced by
The condition is_array($phrases) is always true.
Loading history...
865
                'sanitized' => $keywords,
866
            ]);
867
        }
868
869
        if (empty($keywords)) {
870
            if (config('app.debug')) {
871
                Log::debug('ElasticSearch indexSearch: keywords empty after sanitization');
872
            }
873
874
            return [];
875
        }
876
877
        $cacheKey = $this->buildCacheKey('index_search', [$keywords, $limit]);
878
        $cached = Cache::get($cacheKey);
879
        if ($cached !== null) {
880
            return $cached;
881
        }
882
883
        try {
884
            $search = $this->buildSearchQuery(
885
                index: $this->getReleasesIndex(),
886
                keywords: $keywords,
887
                fields: ['searchname^2', 'plainsearchname^1.5', 'fromname', 'filename', 'name^1.2'],
888
                limit: $limit
889
            );
890
891
            $result = $this->executeSearch($search);
892
            Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
893
894
            return $result;
895
896
        } catch (ElasticsearchException $e) {
897
            Log::error('ElasticSearch indexSearch error: '.$e->getMessage(), [
898
                'keywords' => $keywords,
899
                'limit' => $limit,
900
            ]);
901
902
            return [];
903
        }
904
    }
905
906
    /**
907
     * Search releases for API requests.
908
     *
909
     * @param  array|string  $searchName  Search name(s)
910
     * @param  int  $limit  Maximum number of results
911
     * @return array|Collection Array of release IDs
912
     */
913
    public function indexSearchApi(array|string $searchName, int $limit): array|Collection
914
    {
915
        if (empty($searchName) || ! $this->isElasticsearchAvailable()) {
916
            return [];
917
        }
918
919
        $keywords = $this->sanitizeSearchTerms($searchName);
920
        if (empty($keywords)) {
921
            return [];
922
        }
923
924
        $cacheKey = $this->buildCacheKey('api_search', [$keywords, $limit]);
925
        $cached = Cache::get($cacheKey);
926
        if ($cached !== null) {
927
            return $cached;
928
        }
929
930
        try {
931
            $search = $this->buildSearchQuery(
932
                index: $this->getReleasesIndex(),
933
                keywords: $keywords,
934
                fields: ['searchname^2', 'plainsearchname^1.5', 'fromname', 'filename', 'name^1.2'],
935
                limit: $limit
936
            );
937
938
            $result = $this->executeSearch($search);
939
            Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
940
941
            return $result;
942
943
        } catch (ElasticsearchException $e) {
944
            Log::error('ElasticSearch indexSearchApi error: '.$e->getMessage(), [
945
                'keywords' => $keywords,
946
                'limit' => $limit,
947
            ]);
948
949
            return [];
950
        }
951
    }
952
953
    /**
954
     * Search releases for TV/Movie/Audio (TMA) matching.
955
     *
956
     * @param  array|string  $name  Name(s) to search
957
     * @param  int  $limit  Maximum number of results
958
     * @return array|Collection Array of release IDs
959
     */
960
    public function indexSearchTMA(array|string $name, int $limit): array|Collection
961
    {
962
        if (empty($name) || ! $this->isElasticsearchAvailable()) {
963
            return [];
964
        }
965
966
        $keywords = $this->sanitizeSearchTerms($name);
967
        if (empty($keywords)) {
968
            return [];
969
        }
970
971
        $cacheKey = $this->buildCacheKey('tma_search', [$keywords, $limit]);
972
        $cached = Cache::get($cacheKey);
973
        if ($cached !== null) {
974
            return $cached;
975
        }
976
977
        try {
978
            $search = $this->buildSearchQuery(
979
                index: $this->getReleasesIndex(),
980
                keywords: $keywords,
981
                fields: ['searchname^2', 'plainsearchname^1.5'],
982
                limit: $limit,
983
                options: [
984
                    'boost' => 1.2,
985
                ]
986
            );
987
988
            $result = $this->executeSearch($search);
989
            Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
990
991
            return $result;
992
993
        } catch (ElasticsearchException $e) {
994
            Log::error('ElasticSearch indexSearchTMA error: '.$e->getMessage(), [
995
                'keywords' => $keywords,
996
                'limit' => $limit,
997
            ]);
998
999
            return [];
1000
        }
1001
    }
1002
1003
    /**
1004
     * Search predb index.
1005
     *
1006
     * @param  array|string  $searchTerm  Search term(s)
1007
     * @return array|Collection Array of predb records
1008
     */
1009
    public function predbIndexSearch(array|string $searchTerm): array|Collection
1010
    {
1011
        if (empty($searchTerm) || ! $this->isElasticsearchAvailable()) {
1012
            return [];
1013
        }
1014
1015
        $keywords = $this->sanitizeSearchTerms($searchTerm);
1016
        if (empty($keywords)) {
1017
            return [];
1018
        }
1019
1020
        $cacheKey = $this->buildCacheKey('predb_search', [$keywords]);
1021
        $cached = Cache::get($cacheKey);
1022
        if ($cached !== null) {
1023
            return $cached;
1024
        }
1025
1026
        try {
1027
            $search = $this->buildSearchQuery(
1028
                index: $this->getPredbIndex(),
1029
                keywords: $keywords,
1030
                fields: ['title^2', 'filename'],
1031
                limit: 1000,
1032
                options: [
1033
                    'fuzziness' => 'AUTO',
1034
                ],
1035
                includeDateSort: false
1036
            );
1037
1038
            $result = $this->executeSearch($search, fullResults: true);
1039
            Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
1040
1041
            return $result;
1042
1043
        } catch (ElasticsearchException $e) {
1044
            Log::error('ElasticSearch predbIndexSearch error: '.$e->getMessage(), [
1045
                'keywords' => $keywords,
1046
            ]);
1047
1048
            return [];
1049
        }
1050
    }
1051
1052
    /**
1053
     * Insert a release into the index.
1054
     *
1055
     * @param  array  $parameters  Release data with 'id', 'name', 'searchname', etc.
1056
     */
1057
    public function insertRelease(array $parameters): void
1058
    {
1059
        if (empty($parameters['id']) || ! $this->isElasticsearchAvailable()) {
1060
            if (empty($parameters['id'])) {
1061
                Log::warning('ElasticSearch: Cannot insert release without ID');
1062
            }
1063
1064
            return;
1065
        }
1066
1067
        try {
1068
            $client = $this->getClient();
1069
            $client->index($this->buildReleaseDocument($parameters));
1070
1071
        } catch (ElasticsearchException $e) {
1072
            Log::error('ElasticSearch insertRelease error: '.$e->getMessage(), [
1073
                'release_id' => $parameters['id'],
1074
            ]);
1075
        } catch (\Throwable $e) {
1076
            Log::error('ElasticSearch insertRelease unexpected error: '.$e->getMessage(), [
1077
                'release_id' => $parameters['id'],
1078
            ]);
1079
        }
1080
    }
1081
1082
    /**
1083
     * Update a release in the index.
1084
     *
1085
     * @param  int|string  $releaseID  Release ID
1086
     */
1087
    public function updateRelease(int|string $releaseID): void
1088
    {
1089
        if (empty($releaseID)) {
1090
            Log::warning('ElasticSearch: Cannot update release without ID');
1091
1092
            return;
1093
        }
1094
1095
        if (! $this->isElasticsearchAvailable()) {
1096
            return;
1097
        }
1098
1099
        try {
1100
            $release = Release::query()
1101
                ->where('releases.id', $releaseID)
1102
                ->leftJoin('release_files as rf', 'releases.id', '=', 'rf.releases_id')
1103
                ->select([
1104
                    'releases.id',
1105
                    'releases.name',
1106
                    'releases.searchname',
1107
                    'releases.fromname',
1108
                    'releases.categories_id',
1109
                    DB::raw('IFNULL(GROUP_CONCAT(rf.name SEPARATOR " "),"") filename'),
1110
                ])
1111
                ->groupBy('releases.id')
1112
                ->first();
1113
1114
            if ($release === null) {
1115
                Log::warning('ElasticSearch: Release not found for update', ['id' => $releaseID]);
1116
1117
                return;
1118
            }
1119
1120
            $searchNameDotless = $this->createPlainSearchName($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...
1121
            $data = [
1122
                'body' => [
1123
                    'doc' => [
1124
                        'id' => $release->id,
1125
                        '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...
1126
                        'searchname' => $release->searchname,
1127
                        'plainsearchname' => $searchNameDotless,
1128
                        '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...
1129
                        'categories_id' => $release->categories_id,
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...
1130
                        'filename' => $release->filename,
0 ignored issues
show
Bug introduced by
The property filename 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...
1131
                    ],
1132
                    'doc_as_upsert' => true,
1133
                ],
1134
                'index' => $this->getReleasesIndex(),
1135
                'id' => $release->id,
1136
            ];
1137
1138
            $client = $this->getClient();
1139
            $client->update($data);
1140
1141
        } catch (ElasticsearchException $e) {
1142
            Log::error('ElasticSearch updateRelease error: '.$e->getMessage(), [
1143
                'release_id' => $releaseID,
1144
            ]);
1145
        } catch (\Throwable $e) {
1146
            Log::error('ElasticSearch updateRelease unexpected error: '.$e->getMessage(), [
1147
                'release_id' => $releaseID,
1148
            ]);
1149
        }
1150
    }
1151
1152
    /**
1153
     * Insert a predb record into the index.
1154
     *
1155
     * @param  array  $parameters  Predb data with 'id', 'title', 'source', 'filename'
1156
     */
1157
    public function insertPredb(array $parameters): void
1158
    {
1159
        if (empty($parameters['id'])) {
1160
            Log::warning('ElasticSearch: Cannot insert predb without ID');
1161
1162
            return;
1163
        }
1164
1165
        if (! $this->isElasticsearchAvailable()) {
1166
            return;
1167
        }
1168
1169
        try {
1170
            $data = [
1171
                'body' => [
1172
                    'id' => $parameters['id'],
1173
                    'title' => $parameters['title'] ?? '',
1174
                    'source' => $parameters['source'] ?? '',
1175
                    'filename' => $parameters['filename'] ?? '',
1176
                ],
1177
                'index' => $this->getPredbIndex(),
1178
                'id' => $parameters['id'],
1179
            ];
1180
1181
            $client = $this->getClient();
1182
            $client->index($data);
1183
1184
        } catch (ElasticsearchException $e) {
1185
            Log::error('ElasticSearch insertPreDb error: '.$e->getMessage(), [
1186
                'predb_id' => $parameters['id'],
1187
            ]);
1188
        } catch (\Throwable $e) {
1189
            Log::error('ElasticSearch insertPreDb unexpected error: '.$e->getMessage(), [
1190
                'predb_id' => $parameters['id'],
1191
            ]);
1192
        }
1193
    }
1194
1195
    /**
1196
     * Update a predb record in the index.
1197
     *
1198
     * @param  array  $parameters  Predb data with 'id', 'title', 'source', 'filename'
1199
     */
1200
    public function updatePreDb(array $parameters): void
1201
    {
1202
        if (empty($parameters['id'])) {
1203
            Log::warning('ElasticSearch: Cannot update predb without ID');
1204
1205
            return;
1206
        }
1207
1208
        if (! $this->isElasticsearchAvailable()) {
1209
            return;
1210
        }
1211
1212
        try {
1213
            $data = [
1214
                'body' => [
1215
                    'doc' => [
1216
                        'id' => $parameters['id'],
1217
                        'title' => $parameters['title'] ?? '',
1218
                        'filename' => $parameters['filename'] ?? '',
1219
                        'source' => $parameters['source'] ?? '',
1220
                    ],
1221
                    'doc_as_upsert' => true,
1222
                ],
1223
                'index' => $this->getPredbIndex(),
1224
                'id' => $parameters['id'],
1225
            ];
1226
1227
            $client = $this->getClient();
1228
            $client->update($data);
1229
1230
        } catch (ElasticsearchException $e) {
1231
            Log::error('ElasticSearch updatePreDb error: '.$e->getMessage(), [
1232
                'predb_id' => $parameters['id'],
1233
            ]);
1234
        } catch (\Throwable $e) {
1235
            Log::error('ElasticSearch updatePreDb unexpected error: '.$e->getMessage(), [
1236
                'predb_id' => $parameters['id'],
1237
            ]);
1238
        }
1239
    }
1240
1241
    /**
1242
     * Delete a release from the index.
1243
     *
1244
     * @param  int  $id  Release ID
1245
     */
1246
    public function deleteRelease(int $id): void
1247
    {
1248
        if (empty($id) || ! $this->isElasticsearchAvailable()) {
1249
            if (empty($id)) {
1250
                Log::warning('ElasticSearch: Cannot delete release without ID');
1251
            }
1252
1253
            return;
1254
        }
1255
1256
        try {
1257
            $client = $this->getClient();
1258
            $client->delete([
1259
                'index' => $this->getReleasesIndex(),
1260
                'id' => $id,
1261
            ]);
1262
1263
        } catch (Missing404Exception $e) {
1264
            // Document already deleted, not an error
1265
            if (config('app.debug')) {
1266
                Log::debug('ElasticSearch deleteRelease: document not found', ['release_id' => $id]);
1267
            }
1268
        } catch (ElasticsearchException $e) {
1269
            Log::error('ElasticSearch deleteRelease error: '.$e->getMessage(), [
1270
                'release_id' => $id,
1271
            ]);
1272
        } catch (\Throwable $e) {
1273
            Log::error('ElasticSearch deleteRelease unexpected error: '.$e->getMessage(), [
1274
                'release_id' => $id,
1275
            ]);
1276
        }
1277
    }
1278
1279
    /**
1280
     * Delete a predb record from the index.
1281
     *
1282
     * @param  int  $id  Predb ID
1283
     */
1284
    public function deletePreDb(int $id): void
1285
    {
1286
        if (empty($id) || ! $this->isElasticsearchAvailable()) {
1287
            if (empty($id)) {
1288
                Log::warning('ElasticSearch: Cannot delete predb without ID');
1289
            }
1290
1291
            return;
1292
        }
1293
1294
        try {
1295
            $client = $this->getClient();
1296
            $client->delete([
1297
                'index' => $this->getPredbIndex(),
1298
                'id' => $id,
1299
            ]);
1300
1301
        } catch (Missing404Exception $e) {
1302
            if (config('app.debug')) {
1303
                Log::debug('ElasticSearch deletePreDb: document not found', ['predb_id' => $id]);
1304
            }
1305
        } catch (ElasticsearchException $e) {
1306
            Log::error('ElasticSearch deletePreDb error: '.$e->getMessage(), [
1307
                'predb_id' => $id,
1308
            ]);
1309
        } catch (\Throwable $e) {
1310
            Log::error('ElasticSearch deletePreDb unexpected error: '.$e->getMessage(), [
1311
                'predb_id' => $id,
1312
            ]);
1313
        }
1314
    }
1315
1316
    /**
1317
     * Bulk insert multiple releases into the index.
1318
     *
1319
     * @param  array  $releases  Array of release data arrays
1320
     * @return array Results with 'success' and 'errors' counts
1321
     */
1322
    public function bulkInsertReleases(array $releases): array
1323
    {
1324
        if (empty($releases) || ! $this->isElasticsearchAvailable()) {
1325
            return ['success' => 0, 'errors' => 0];
1326
        }
1327
1328
        $success = 0;
1329
        $errors = 0;
1330
1331
        $params = ['body' => []];
1332
1333
        foreach ($releases as $release) {
1334
            if (empty($release['id'])) {
1335
                $errors++;
1336
1337
                continue;
1338
            }
1339
1340
            $searchNameDotless = $this->createPlainSearchName($release['searchname'] ?? '');
1341
1342
            $params['body'][] = [
1343
                'index' => [
1344
                    '_index' => $this->getReleasesIndex(),
1345
                    '_id' => $release['id'],
1346
                ],
1347
            ];
1348
1349
            $params['body'][] = [
1350
                'id' => $release['id'],
1351
                'name' => (string) ($release['name'] ?? ''),
1352
                'searchname' => (string) ($release['searchname'] ?? ''),
1353
                'plainsearchname' => $searchNameDotless,
1354
                'fromname' => (string) ($release['fromname'] ?? ''),
1355
                'categories_id' => (int) ($release['categories_id'] ?? 0),
1356
                'filename' => (string) ($release['filename'] ?? ''),
1357
            ];
1358
1359
            $success++;
1360
        }
1361
1362
        if (! empty($params['body'])) {
1363
            try {
1364
                $client = $this->getClient();
1365
                $response = $client->bulk($params);
1366
1367
                if (isset($response['errors']) && $response['errors']) {
1368
                    foreach ($response['items'] as $item) {
1369
                        if (isset($item['index']['error'])) {
1370
                            $errors++;
1371
                            $success--;
1372
                            if (config('app.debug')) {
1373
                                Log::error('ElasticSearch bulkInsertReleases error: '.json_encode($item['index']['error']));
1374
                            }
1375
                        }
1376
                    }
1377
                }
1378
            } catch (\Throwable $e) {
1379
                Log::error('ElasticSearch bulkInsertReleases error: '.$e->getMessage());
1380
                $errors += $success;
1381
                $success = 0;
1382
            }
1383
        }
1384
1385
        return ['success' => $success, 'errors' => $errors];
1386
    }
1387
1388
    /**
1389
     * Bulk insert multiple predb records into the index.
1390
     *
1391
     * @param  array  $predbRecords  Array of predb data arrays
1392
     * @return array Results with 'success' and 'errors' counts
1393
     */
1394
    public function bulkInsertPredb(array $predbRecords): array
1395
    {
1396
        if (empty($predbRecords) || ! $this->isElasticsearchAvailable()) {
1397
            return ['success' => 0, 'errors' => 0];
1398
        }
1399
1400
        $success = 0;
1401
        $errors = 0;
1402
1403
        $params = ['body' => []];
1404
1405
        foreach ($predbRecords as $predb) {
1406
            if (empty($predb['id'])) {
1407
                $errors++;
1408
1409
                continue;
1410
            }
1411
1412
            $params['body'][] = [
1413
                'index' => [
1414
                    '_index' => $this->getPredbIndex(),
1415
                    '_id' => $predb['id'],
1416
                ],
1417
            ];
1418
1419
            $params['body'][] = [
1420
                'id' => $predb['id'],
1421
                'title' => (string) ($predb['title'] ?? ''),
1422
                'filename' => (string) ($predb['filename'] ?? ''),
1423
                'source' => (string) ($predb['source'] ?? ''),
1424
            ];
1425
1426
            $success++;
1427
        }
1428
1429
        if (! empty($params['body'])) {
1430
            try {
1431
                $client = $this->getClient();
1432
                $response = $client->bulk($params);
1433
1434
                if (isset($response['errors']) && $response['errors']) {
1435
                    foreach ($response['items'] as $item) {
1436
                        if (isset($item['index']['error'])) {
1437
                            $errors++;
1438
                            $success--;
1439
                            if (config('app.debug')) {
1440
                                Log::error('ElasticSearch bulkInsertPredb error: '.json_encode($item['index']['error']));
1441
                            }
1442
                        }
1443
                    }
1444
                }
1445
            } catch (\Throwable $e) {
1446
                Log::error('ElasticSearch bulkInsertPredb error: '.$e->getMessage());
1447
                $errors += $success;
1448
                $success = 0;
1449
            }
1450
        }
1451
1452
        return ['success' => $success, 'errors' => $errors];
1453
    }
1454
1455
    /**
1456
     * Check if an index exists.
1457
     *
1458
     * @param  string  $index  Index name
1459
     */
1460
    public function indexExists(string $index): bool
1461
    {
1462
        if (! $this->isElasticsearchAvailable()) {
1463
            return false;
1464
        }
1465
1466
        try {
1467
            $client = $this->getClient();
1468
1469
            return $client->indices()->exists(['index' => $index]);
1470
        } catch (\Throwable $e) {
1471
            Log::error('ElasticSearch indexExists error: '.$e->getMessage(), ['index' => $index]);
1472
1473
            return false;
1474
        }
1475
    }
1476
1477
    /**
1478
     * Truncate/clear an index (remove all documents).
1479
     * Implements SearchServiceInterface::truncateIndex
1480
     *
1481
     * @param  array|string  $indexes  Index name(s) to truncate
1482
     */
1483
    public function truncateIndex(array|string $indexes): void
1484
    {
1485
        if (! $this->isElasticsearchAvailable()) {
1486
            return;
1487
        }
1488
1489
        $indexArray = is_array($indexes) ? $indexes : [$indexes];
0 ignored issues
show
introduced by
The condition is_array($indexes) is always true.
Loading history...
1490
1491
        foreach ($indexArray as $index) {
1492
            try {
1493
                $client = $this->getClient();
1494
1495
                // Check if index exists
1496
                if (! $client->indices()->exists(['index' => $index])) {
1497
                    Log::info("ElasticSearch truncateIndex: index {$index} does not exist, skipping");
1498
1499
                    continue;
1500
                }
1501
1502
                // Delete all documents from the index
1503
                $client->deleteByQuery([
1504
                    'index' => $index,
1505
                    'body' => [
1506
                        'query' => ['match_all' => (object) []],
1507
                    ],
1508
                    'conflicts' => 'proceed',
1509
                ]);
1510
1511
                // Force refresh to ensure deletions are visible
1512
                $client->indices()->refresh(['index' => $index]);
1513
1514
                Log::info("ElasticSearch: Truncated index {$index}");
1515
1516
            } catch (\Throwable $e) {
1517
                Log::error("ElasticSearch truncateIndex error for {$index}: ".$e->getMessage());
1518
            }
1519
        }
1520
    }
1521
1522
    /**
1523
     * Optimize index for better search performance.
1524
     * Implements SearchServiceInterface::optimizeIndex
1525
     */
1526
    public function optimizeIndex(): void
1527
    {
1528
        if (! $this->isElasticsearchAvailable()) {
1529
            return;
1530
        }
1531
1532
        try {
1533
            $client = $this->getClient();
1534
1535
            // Force merge the releases index
1536
            $client->indices()->forcemerge([
1537
                'index' => $this->getReleasesIndex(),
1538
                'max_num_segments' => 1,
1539
            ]);
1540
1541
            // Force merge the predb index
1542
            $client->indices()->forcemerge([
1543
                'index' => $this->getPredbIndex(),
1544
                'max_num_segments' => 1,
1545
            ]);
1546
1547
            // Refresh both indexes
1548
            $client->indices()->refresh(['index' => $this->getReleasesIndex()]);
1549
            $client->indices()->refresh(['index' => $this->getPredbIndex()]);
1550
1551
            Log::info('ElasticSearch: Optimized indexes');
1552
1553
        } catch (\Throwable $e) {
1554
            Log::error('ElasticSearch optimizeIndex error: '.$e->getMessage());
1555
        }
1556
    }
1557
1558
    /**
1559
     * Get cluster health information.
1560
     *
1561
     * @return array Health information or empty array on failure
1562
     */
1563
    public function getClusterHealth(): array
1564
    {
1565
        if (! $this->isElasticsearchAvailable()) {
1566
            return [];
1567
        }
1568
1569
        try {
1570
            $client = $this->getClient();
1571
1572
            return $client->cluster()->health();
1573
        } catch (\Throwable $e) {
1574
            Log::error('ElasticSearch getClusterHealth error: '.$e->getMessage());
1575
1576
            return [];
1577
        }
1578
    }
1579
1580
    /**
1581
     * Get index statistics.
1582
     *
1583
     * @param  string  $index  Index name
1584
     * @return array Statistics or empty array on failure
1585
     */
1586
    public function getIndexStats(string $index): array
1587
    {
1588
        if (! $this->isElasticsearchAvailable()) {
1589
            return [];
1590
        }
1591
1592
        try {
1593
            $client = $this->getClient();
1594
1595
            return $client->indices()->stats(['index' => $index]);
1596
        } catch (\Throwable $e) {
1597
            Log::error('ElasticSearch getIndexStats error: '.$e->getMessage(), ['index' => $index]);
1598
1599
            return [];
1600
        }
1601
    }
1602
1603
    /**
1604
     * Sanitize search terms for Elasticsearch.
1605
     *
1606
     * Uses the global sanitize() helper function which properly escapes
1607
     * Elasticsearch query string special characters.
1608
     *
1609
     * @param  array|string  $terms  Search terms
1610
     * @return string Sanitized search string
1611
     */
1612
    private function sanitizeSearchTerms(array|string $terms): string
1613
    {
1614
        if (is_array($terms)) {
0 ignored issues
show
introduced by
The condition is_array($terms) is always true.
Loading history...
1615
            $terms = implode(' ', array_filter($terms));
1616
        }
1617
1618
        $terms = trim($terms);
1619
        if (empty($terms)) {
1620
            return '';
1621
        }
1622
1623
        // Use the original sanitize() helper function that properly handles
1624
        // Elasticsearch query string escaping
1625
        if (function_exists('sanitize')) {
1626
            return sanitize($terms);
1627
        }
1628
1629
        // Fallback if sanitize function doesn't exist
1630
        // Replace dots with spaces for release name searches
1631
        $terms = str_replace('.', ' ', $terms);
1632
1633
        // Remove multiple consecutive spaces
1634
        $terms = preg_replace('/\s+/', ' ', trim($terms));
1635
1636
        return $terms;
1637
    }
1638
1639
    /**
1640
     * Build a cache key for search results.
1641
     *
1642
     * @param  string  $prefix  Cache key prefix
1643
     * @param  array  $params  Parameters to include in key
1644
     */
1645
    private function buildCacheKey(string $prefix, array $params): string
1646
    {
1647
        return 'es_'.$prefix.'_'.md5(serialize($params));
1648
    }
1649
1650
    /**
1651
     * Build a search query array.
1652
     *
1653
     * @param  string  $index  Index name
1654
     * @param  string  $keywords  Sanitized keywords
1655
     * @param  array  $fields  Fields to search with boosts
1656
     * @param  int  $limit  Maximum results
1657
     * @param  array  $options  Additional query_string options
1658
     * @param  bool  $includeDateSort  Include date sorting
1659
     */
1660
    private function buildSearchQuery(
1661
        string $index,
1662
        string $keywords,
1663
        array $fields,
1664
        int $limit,
1665
        array $options = [],
1666
        bool $includeDateSort = true
1667
    ): array {
1668
        $queryString = array_merge([
1669
            'query' => $keywords,
1670
            'fields' => $fields,
1671
            'analyze_wildcard' => true,
1672
            'default_operator' => 'and',
1673
        ], $options);
1674
1675
        $sort = [['_score' => ['order' => 'desc']]];
1676
1677
        if ($includeDateSort) {
1678
            $sort[] = ['add_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']];
1679
            $sort[] = ['post_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']];
1680
        }
1681
1682
        return [
1683
            'scroll' => self::SCROLL_TIMEOUT,
1684
            'index' => $index,
1685
            'body' => [
1686
                'query' => [
1687
                    'query_string' => $queryString,
1688
                ],
1689
                'size' => min($limit, self::MAX_RESULTS),
1690
                'sort' => $sort,
1691
                '_source' => ['id'],
1692
                'track_total_hits' => true,
1693
            ],
1694
        ];
1695
    }
1696
1697
    /**
1698
     * Build a release document for indexing.
1699
     *
1700
     * @param  array  $parameters  Release parameters
1701
     */
1702
    private function buildReleaseDocument(array $parameters): array
1703
    {
1704
        $searchNameDotless = $this->createPlainSearchName($parameters['searchname'] ?? '');
1705
1706
        return [
1707
            'body' => [
1708
                'id' => $parameters['id'],
1709
                'name' => $parameters['name'] ?? '',
1710
                'searchname' => $parameters['searchname'] ?? '',
1711
                'plainsearchname' => $searchNameDotless,
1712
                'fromname' => $parameters['fromname'] ?? '',
1713
                'categories_id' => $parameters['categories_id'] ?? 0,
1714
                'filename' => $parameters['filename'] ?? '',
1715
                'add_date' => now()->format('Y-m-d H:i:s'),
1716
                'post_date' => $parameters['postdate'] ?? now()->format('Y-m-d H:i:s'),
1717
            ],
1718
            'index' => $this->getReleasesIndex(),
1719
            'id' => $parameters['id'],
1720
        ];
1721
    }
1722
1723
    /**
1724
     * Create a plain search name by removing dots and dashes.
1725
     *
1726
     * @param  string  $searchName  Original search name
1727
     */
1728
    private function createPlainSearchName(string $searchName): string
1729
    {
1730
        return str_replace(['.', '-'], ' ', $searchName);
1731
    }
1732
1733
    /**
1734
     * Execute a search with scroll support.
1735
     *
1736
     * @param  array  $search  Search query
1737
     * @param  bool  $fullResults  Return full source documents instead of just IDs
1738
     */
1739
    protected function executeSearch(array $search, bool $fullResults = false): array
1740
    {
1741
        if (empty($search) || ! $this->isElasticsearchAvailable()) {
1742
            return [];
1743
        }
1744
1745
        $scrollId = null;
1746
1747
        try {
1748
            $client = $this->getClient();
1749
1750
            // Log the search query for debugging
1751
            if (config('app.debug')) {
1752
                Log::debug('ElasticSearch executing search', [
1753
                    'index' => $search['index'] ?? 'unknown',
1754
                    'query' => $search['body']['query'] ?? [],
1755
                ]);
1756
            }
1757
1758
            $results = $client->search($search);
1759
1760
            // Log the number of hits
1761
            if (config('app.debug')) {
1762
                $totalHits = $results['hits']['total']['value'] ?? $results['hits']['total'] ?? 0;
1763
                Log::debug('ElasticSearch search results', [
1764
                    'total_hits' => $totalHits,
1765
                    'returned_hits' => count($results['hits']['hits'] ?? []),
1766
                ]);
1767
            }
1768
1769
            $searchResult = [];
1770
1771
            while (isset($results['hits']['hits']) && count($results['hits']['hits']) > 0) {
1772
                foreach ($results['hits']['hits'] as $result) {
1773
                    if ($fullResults) {
1774
                        $searchResult[] = $result['_source'];
1775
                    } else {
1776
                        $searchResult[] = $result['_source']['id'] ?? $result['_id'];
1777
                    }
1778
                }
1779
1780
                // Handle scrolling for large result sets
1781
                if (! isset($results['_scroll_id'])) {
1782
                    break;
1783
                }
1784
1785
                $scrollId = $results['_scroll_id'];
1786
                $results = $client->scroll([
1787
                    'scroll_id' => $scrollId,
1788
                    'scroll' => self::SCROLL_TIMEOUT,
1789
                ]);
1790
            }
1791
1792
            return $searchResult;
1793
1794
        } catch (ElasticsearchException $e) {
1795
            Log::error('ElasticSearch search error: '.$e->getMessage());
1796
1797
            return [];
1798
        } catch (\Throwable $e) {
1799
            Log::error('ElasticSearch search unexpected error: '.$e->getMessage());
1800
1801
            return [];
1802
        } finally {
1803
            // Clean up scroll context
1804
            $this->clearScrollContext($scrollId);
1805
        }
1806
    }
1807
1808
    /**
1809
     * Clear a scroll context to free server resources.
1810
     *
1811
     * @param  string|null  $scrollId  Scroll ID to clear
1812
     */
1813
    private function clearScrollContext(?string $scrollId): void
1814
    {
1815
        if ($scrollId === null) {
1816
            return;
1817
        }
1818
1819
        try {
1820
            $client = $this->getClient();
1821
            $client->clearScroll(['scroll_id' => $scrollId]);
1822
        } catch (\Throwable $e) {
1823
            // Ignore errors when clearing scroll - it's just cleanup
1824
            if (config('app.debug')) {
1825
                Log::debug('Failed to clear scroll context: '.$e->getMessage());
1826
            }
1827
        }
1828
    }
1829
1830
    /**
1831
     * Execute a bulk operation.
1832
     *
1833
     * @param  array  $params  Bulk operation parameters
1834
     */
1835
    private function executeBulk(array $params): void
1836
    {
1837
        if (empty($params['body'])) {
1838
            return;
1839
        }
1840
1841
        try {
1842
            $client = $this->getClient();
1843
            $response = $client->bulk($params);
1844
1845
            if (! empty($response['errors'])) {
1846
                foreach ($response['items'] as $item) {
1847
                    $operation = $item['index'] ?? $item['update'] ?? $item['delete'] ?? [];
1848
                    if (isset($operation['error'])) {
1849
                        Log::error('ElasticSearch bulk operation error', [
1850
                            'id' => $operation['_id'] ?? 'unknown',
1851
                            'error' => $operation['error'],
1852
                        ]);
1853
                    }
1854
                }
1855
            }
1856
        } catch (ElasticsearchException $e) {
1857
            Log::error('ElasticSearch bulk error: '.$e->getMessage());
1858
        } catch (\Throwable $e) {
1859
            Log::error('ElasticSearch bulk unexpected error: '.$e->getMessage());
1860
        }
1861
    }
1862
1863
    /**
1864
     * Insert a movie into the movies search index.
1865
     *
1866
     * @param  array  $parameters  Movie data
1867
     */
1868
    public function insertMovie(array $parameters): void
1869
    {
1870
        if (empty($parameters['id'])) {
1871
            Log::warning('ElasticSearch: Cannot insert movie without ID');
1872
1873
            return;
1874
        }
1875
1876
        try {
1877
            $client = $this->getClient();
1878
1879
            $document = [
1880
                'id' => $parameters['id'],
1881
                'imdbid' => (int) ($parameters['imdbid'] ?? 0),
1882
                'tmdbid' => (int) ($parameters['tmdbid'] ?? 0),
1883
                'traktid' => (int) ($parameters['traktid'] ?? 0),
1884
                'title' => (string) ($parameters['title'] ?? ''),
1885
                'year' => (string) ($parameters['year'] ?? ''),
1886
                'genre' => (string) ($parameters['genre'] ?? ''),
1887
                'actors' => (string) ($parameters['actors'] ?? ''),
1888
                'director' => (string) ($parameters['director'] ?? ''),
1889
                'rating' => (string) ($parameters['rating'] ?? ''),
1890
                'plot' => (string) ($parameters['plot'] ?? ''),
1891
            ];
1892
1893
            $client->index([
1894
                'index' => $this->getMoviesIndex(),
1895
                'id' => $parameters['id'],
1896
                'body' => $document,
1897
            ]);
1898
1899
        } catch (ElasticsearchException $e) {
1900
            Log::error('ElasticSearch insertMovie error: '.$e->getMessage(), [
1901
                'movie_id' => $parameters['id'],
1902
            ]);
1903
        } catch (\Throwable $e) {
1904
            Log::error('ElasticSearch insertMovie unexpected error: '.$e->getMessage(), [
1905
                'movie_id' => $parameters['id'],
1906
            ]);
1907
        }
1908
    }
1909
1910
    /**
1911
     * Update a movie in the search index.
1912
     *
1913
     * @param  int  $movieId  Movie ID
1914
     */
1915
    public function updateMovie(int $movieId): void
1916
    {
1917
        if (empty($movieId)) {
1918
            Log::warning('ElasticSearch: Cannot update movie without ID');
1919
1920
            return;
1921
        }
1922
1923
        try {
1924
            $movie = MovieInfo::find($movieId);
1925
1926
            if ($movie !== null) {
1927
                $this->insertMovie($movie->toArray());
1928
            } else {
1929
                Log::warning('ElasticSearch: Movie not found for update', ['id' => $movieId]);
1930
            }
1931
        } catch (\Throwable $e) {
1932
            Log::error('ElasticSearch updateMovie error: '.$e->getMessage(), [
1933
                'movie_id' => $movieId,
1934
            ]);
1935
        }
1936
    }
1937
1938
    /**
1939
     * Delete a movie from the search index.
1940
     *
1941
     * @param  int  $id  Movie ID
1942
     */
1943
    public function deleteMovie(int $id): void
1944
    {
1945
        if (empty($id)) {
1946
            Log::warning('ElasticSearch: Cannot delete movie without ID');
1947
1948
            return;
1949
        }
1950
1951
        try {
1952
            $client = $this->getClient();
1953
            $client->delete([
1954
                'index' => $this->getMoviesIndex(),
1955
                'id' => $id,
1956
            ]);
1957
        } catch (Missing404Exception $e) {
1958
            // Document doesn't exist, that's fine
1959
        } catch (ElasticsearchException $e) {
1960
            Log::error('ElasticSearch deleteMovie error: '.$e->getMessage(), [
1961
                'id' => $id,
1962
            ]);
1963
        }
1964
    }
1965
1966
    /**
1967
     * Bulk insert multiple movies into the index.
1968
     *
1969
     * @param  array  $movies  Array of movie data arrays
1970
     * @return array Results with 'success' and 'errors' counts
1971
     */
1972
    public function bulkInsertMovies(array $movies): array
1973
    {
1974
        if (empty($movies)) {
1975
            return ['success' => 0, 'errors' => 0];
1976
        }
1977
1978
        $success = 0;
1979
        $errors = 0;
1980
1981
        $params = ['body' => []];
1982
1983
        foreach ($movies as $movie) {
1984
            if (empty($movie['id'])) {
1985
                $errors++;
1986
1987
                continue;
1988
            }
1989
1990
            $params['body'][] = [
1991
                'index' => [
1992
                    '_index' => $this->getMoviesIndex(),
1993
                    '_id' => $movie['id'],
1994
                ],
1995
            ];
1996
1997
            $params['body'][] = [
1998
                'id' => $movie['id'],
1999
                'imdbid' => (int) ($movie['imdbid'] ?? 0),
2000
                'tmdbid' => (int) ($movie['tmdbid'] ?? 0),
2001
                'traktid' => (int) ($movie['traktid'] ?? 0),
2002
                'title' => (string) ($movie['title'] ?? ''),
2003
                'year' => (string) ($movie['year'] ?? ''),
2004
                'genre' => (string) ($movie['genre'] ?? ''),
2005
                'actors' => (string) ($movie['actors'] ?? ''),
2006
                'director' => (string) ($movie['director'] ?? ''),
2007
                'rating' => (string) ($movie['rating'] ?? ''),
2008
                'plot' => (string) ($movie['plot'] ?? ''),
2009
            ];
2010
2011
            $success++;
2012
        }
2013
2014
        if (! empty($params['body'])) {
2015
            try {
2016
                $this->executeBulk($params);
2017
            } catch (\Throwable $e) {
2018
                Log::error('ElasticSearch bulkInsertMovies error: '.$e->getMessage());
2019
                $errors += $success;
2020
                $success = 0;
2021
            }
2022
        }
2023
2024
        return ['success' => $success, 'errors' => $errors];
2025
    }
2026
2027
    /**
2028
     * Search the movies index.
2029
     *
2030
     * @param  array|string  $searchTerm  Search term(s)
2031
     * @param  int  $limit  Maximum number of results
2032
     * @return array Array with 'id' (movie IDs) and 'data' (movie data)
2033
     */
2034
    public function searchMovies(array|string $searchTerm, int $limit = 1000): array
2035
    {
2036
        $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm;
0 ignored issues
show
introduced by
The condition is_array($searchTerm) is always true.
Loading history...
2037
        $escapedSearch = self::escapeString($searchString);
2038
2039
        if (empty($escapedSearch)) {
2040
            return ['id' => [], 'data' => []];
2041
        }
2042
2043
        $cacheKey = 'es:movies:'.md5($escapedSearch.$limit);
2044
        $cached = Cache::get($cacheKey);
2045
        if ($cached !== null) {
2046
            return $cached;
2047
        }
2048
2049
        try {
2050
            $client = $this->getClient();
2051
2052
            $searchParams = [
2053
                'index' => $this->getMoviesIndex(),
2054
                'body' => [
2055
                    'query' => [
2056
                        'multi_match' => [
2057
                            'query' => $escapedSearch,
2058
                            'fields' => ['title^3', 'actors', 'director', 'genre'],
2059
                            'type' => 'best_fields',
2060
                            'fuzziness' => 'AUTO',
2061
                        ],
2062
                    ],
2063
                    'size' => min($limit, self::MAX_RESULTS),
2064
                ],
2065
            ];
2066
2067
            $response = $client->search($searchParams);
2068
2069
            $resultIds = [];
2070
            $resultData = [];
2071
2072
            if (isset($response['hits']['hits'])) {
2073
                foreach ($response['hits']['hits'] as $hit) {
2074
                    $resultIds[] = $hit['_id'];
2075
                    $resultData[] = $hit['_source'];
2076
                }
2077
            }
2078
2079
            $result = ['id' => $resultIds, 'data' => $resultData];
2080
2081
            Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL_MINUTES));
2082
2083
            return $result;
2084
2085
        } catch (\Throwable $e) {
2086
            Log::error('ElasticSearch searchMovies error: '.$e->getMessage());
2087
2088
            return ['id' => [], 'data' => []];
2089
        }
2090
    }
2091
2092
    /**
2093
     * Search movies by external ID (IMDB, TMDB, Trakt).
2094
     *
2095
     * @param  string  $field  Field name (imdbid, tmdbid, traktid)
2096
     * @param  int|string  $value  The external ID value
2097
     * @return array|null Movie data or null if not found
2098
     */
2099
    public function searchMovieByExternalId(string $field, int|string $value): ?array
2100
    {
2101
        if (empty($value) || ! in_array($field, ['imdbid', 'tmdbid', 'traktid'])) {
2102
            return null;
2103
        }
2104
2105
        $cacheKey = 'es:movie:'.$field.':'.$value;
2106
        $cached = Cache::get($cacheKey);
2107
        if ($cached !== null) {
2108
            return $cached;
2109
        }
2110
2111
        try {
2112
            $client = $this->getClient();
2113
2114
            $searchParams = [
2115
                'index' => $this->getMoviesIndex(),
2116
                'body' => [
2117
                    'query' => [
2118
                        'term' => [
2119
                            $field => (int) $value,
2120
                        ],
2121
                    ],
2122
                    'size' => 1,
2123
                ],
2124
            ];
2125
2126
            $response = $client->search($searchParams);
2127
2128
            if (isset($response['hits']['hits'][0])) {
2129
                $data = $response['hits']['hits'][0]['_source'];
2130
                $data['id'] = $response['hits']['hits'][0]['_id'];
2131
                Cache::put($cacheKey, $data, now()->addMinutes(self::CACHE_TTL_MINUTES));
2132
2133
                return $data;
2134
            }
2135
2136
        } catch (\Throwable $e) {
2137
            Log::error('ElasticSearch searchMovieByExternalId error: '.$e->getMessage(), [
2138
                'field' => $field,
2139
                'value' => $value,
2140
            ]);
2141
        }
2142
2143
        return null;
2144
    }
2145
2146
    /**
2147
     * Insert a TV show into the tvshows search index.
2148
     *
2149
     * @param  array  $parameters  TV show data
2150
     */
2151
    public function insertTvShow(array $parameters): void
2152
    {
2153
        if (empty($parameters['id'])) {
2154
            Log::warning('ElasticSearch: Cannot insert TV show without ID');
2155
2156
            return;
2157
        }
2158
2159
        try {
2160
            $client = $this->getClient();
2161
2162
            $document = [
2163
                'id' => $parameters['id'],
2164
                'title' => (string) ($parameters['title'] ?? ''),
2165
                'tvdb' => (int) ($parameters['tvdb'] ?? 0),
2166
                'trakt' => (int) ($parameters['trakt'] ?? 0),
2167
                'tvmaze' => (int) ($parameters['tvmaze'] ?? 0),
2168
                'tvrage' => (int) ($parameters['tvrage'] ?? 0),
2169
                'imdb' => (int) ($parameters['imdb'] ?? 0),
2170
                'tmdb' => (int) ($parameters['tmdb'] ?? 0),
2171
                'started' => (string) ($parameters['started'] ?? ''),
2172
                'type' => (int) ($parameters['type'] ?? 0),
2173
            ];
2174
2175
            $client->index([
2176
                'index' => $this->getTvShowsIndex(),
2177
                'id' => $parameters['id'],
2178
                'body' => $document,
2179
            ]);
2180
2181
        } catch (ElasticsearchException $e) {
2182
            Log::error('ElasticSearch insertTvShow error: '.$e->getMessage(), [
2183
                'tvshow_id' => $parameters['id'],
2184
            ]);
2185
        } catch (\Throwable $e) {
2186
            Log::error('ElasticSearch insertTvShow unexpected error: '.$e->getMessage(), [
2187
                'tvshow_id' => $parameters['id'],
2188
            ]);
2189
        }
2190
    }
2191
2192
    /**
2193
     * Update a TV show in the search index.
2194
     *
2195
     * @param  int  $videoId  Video/TV show ID
2196
     */
2197
    public function updateTvShow(int $videoId): void
2198
    {
2199
        if (empty($videoId)) {
2200
            Log::warning('ElasticSearch: Cannot update TV show without ID');
2201
2202
            return;
2203
        }
2204
2205
        try {
2206
            $video = Video::find($videoId);
2207
2208
            if ($video !== null) {
2209
                $this->insertTvShow($video->toArray());
2210
            } else {
2211
                Log::warning('ElasticSearch: TV show not found for update', ['id' => $videoId]);
2212
            }
2213
        } catch (\Throwable $e) {
2214
            Log::error('ElasticSearch updateTvShow error: '.$e->getMessage(), [
2215
                'tvshow_id' => $videoId,
2216
            ]);
2217
        }
2218
    }
2219
2220
    /**
2221
     * Delete a TV show from the search index.
2222
     *
2223
     * @param  int  $id  TV show ID
2224
     */
2225
    public function deleteTvShow(int $id): void
2226
    {
2227
        if (empty($id)) {
2228
            Log::warning('ElasticSearch: Cannot delete TV show without ID');
2229
2230
            return;
2231
        }
2232
2233
        try {
2234
            $client = $this->getClient();
2235
            $client->delete([
2236
                'index' => $this->getTvShowsIndex(),
2237
                'id' => $id,
2238
            ]);
2239
        } catch (Missing404Exception $e) {
2240
            // Document doesn't exist, that's fine
2241
        } catch (ElasticsearchException $e) {
2242
            Log::error('ElasticSearch deleteTvShow error: '.$e->getMessage(), [
2243
                'id' => $id,
2244
            ]);
2245
        }
2246
    }
2247
2248
    /**
2249
     * Bulk insert multiple TV shows into the index.
2250
     *
2251
     * @param  array  $tvShows  Array of TV show data arrays
2252
     * @return array Results with 'success' and 'errors' counts
2253
     */
2254
    public function bulkInsertTvShows(array $tvShows): array
2255
    {
2256
        if (empty($tvShows)) {
2257
            return ['success' => 0, 'errors' => 0];
2258
        }
2259
2260
        $success = 0;
2261
        $errors = 0;
2262
2263
        $params = ['body' => []];
2264
2265
        foreach ($tvShows as $tvShow) {
2266
            if (empty($tvShow['id'])) {
2267
                $errors++;
2268
2269
                continue;
2270
            }
2271
2272
            $params['body'][] = [
2273
                'index' => [
2274
                    '_index' => $this->getTvShowsIndex(),
2275
                    '_id' => $tvShow['id'],
2276
                ],
2277
            ];
2278
2279
            $params['body'][] = [
2280
                'id' => $tvShow['id'],
2281
                'title' => (string) ($tvShow['title'] ?? ''),
2282
                'tvdb' => (int) ($tvShow['tvdb'] ?? 0),
2283
                'trakt' => (int) ($tvShow['trakt'] ?? 0),
2284
                'tvmaze' => (int) ($tvShow['tvmaze'] ?? 0),
2285
                'tvrage' => (int) ($tvShow['tvrage'] ?? 0),
2286
                'imdb' => (int) ($tvShow['imdb'] ?? 0),
2287
                'tmdb' => (int) ($tvShow['tmdb'] ?? 0),
2288
                'started' => (string) ($tvShow['started'] ?? ''),
2289
                'type' => (int) ($tvShow['type'] ?? 0),
2290
            ];
2291
2292
            $success++;
2293
        }
2294
2295
        if (! empty($params['body'])) {
2296
            try {
2297
                $this->executeBulk($params);
2298
            } catch (\Throwable $e) {
2299
                Log::error('ElasticSearch bulkInsertTvShows error: '.$e->getMessage());
2300
                $errors += $success;
2301
                $success = 0;
2302
            }
2303
        }
2304
2305
        return ['success' => $success, 'errors' => $errors];
2306
    }
2307
2308
    /**
2309
     * Search the TV shows index.
2310
     *
2311
     * @param  array|string  $searchTerm  Search term(s)
2312
     * @param  int  $limit  Maximum number of results
2313
     * @return array Array with 'id' (TV show IDs) and 'data' (TV show data)
2314
     */
2315
    public function searchTvShows(array|string $searchTerm, int $limit = 1000): array
2316
    {
2317
        $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm;
0 ignored issues
show
introduced by
The condition is_array($searchTerm) is always true.
Loading history...
2318
        $escapedSearch = self::escapeString($searchString);
2319
2320
        if (empty($escapedSearch)) {
2321
            return ['id' => [], 'data' => []];
2322
        }
2323
2324
        $cacheKey = 'es:tvshows:'.md5($escapedSearch.$limit);
2325
        $cached = Cache::get($cacheKey);
2326
        if ($cached !== null) {
2327
            return $cached;
2328
        }
2329
2330
        try {
2331
            $client = $this->getClient();
2332
2333
            $searchParams = [
2334
                'index' => $this->getTvShowsIndex(),
2335
                'body' => [
2336
                    'query' => [
2337
                        'match' => [
2338
                            'title' => [
2339
                                'query' => $escapedSearch,
2340
                                'fuzziness' => 'AUTO',
2341
                            ],
2342
                        ],
2343
                    ],
2344
                    'size' => min($limit, self::MAX_RESULTS),
2345
                ],
2346
            ];
2347
2348
            $response = $client->search($searchParams);
2349
2350
            $resultIds = [];
2351
            $resultData = [];
2352
2353
            if (isset($response['hits']['hits'])) {
2354
                foreach ($response['hits']['hits'] as $hit) {
2355
                    $resultIds[] = $hit['_id'];
2356
                    $resultData[] = $hit['_source'];
2357
                }
2358
            }
2359
2360
            $result = ['id' => $resultIds, 'data' => $resultData];
2361
2362
            Cache::put($cacheKey, $result, now()->addMinutes(self::CACHE_TTL_MINUTES));
2363
2364
            return $result;
2365
2366
        } catch (\Throwable $e) {
2367
            Log::error('ElasticSearch searchTvShows error: '.$e->getMessage());
2368
2369
            return ['id' => [], 'data' => []];
2370
        }
2371
    }
2372
2373
    /**
2374
     * Search TV shows by external ID (TVDB, Trakt, TVMaze, TVRage, IMDB, TMDB).
2375
     *
2376
     * @param  string  $field  Field name (tvdb, trakt, tvmaze, tvrage, imdb, tmdb)
2377
     * @param  int|string  $value  The external ID value
2378
     * @return array|null TV show data or null if not found
2379
     */
2380
    public function searchTvShowByExternalId(string $field, int|string $value): ?array
2381
    {
2382
        if (empty($value) || ! in_array($field, ['tvdb', 'trakt', 'tvmaze', 'tvrage', 'imdb', 'tmdb'])) {
2383
            return null;
2384
        }
2385
2386
        $cacheKey = 'es:tvshow:'.$field.':'.$value;
2387
        $cached = Cache::get($cacheKey);
2388
        if ($cached !== null) {
2389
            return $cached;
2390
        }
2391
2392
        try {
2393
            $client = $this->getClient();
2394
2395
            $searchParams = [
2396
                'index' => $this->getTvShowsIndex(),
2397
                'body' => [
2398
                    'query' => [
2399
                        'term' => [
2400
                            $field => (int) $value,
2401
                        ],
2402
                    ],
2403
                    'size' => 1,
2404
                ],
2405
            ];
2406
2407
            $response = $client->search($searchParams);
2408
2409
            if (isset($response['hits']['hits'][0])) {
2410
                $data = $response['hits']['hits'][0]['_source'];
2411
                $data['id'] = $response['hits']['hits'][0]['_id'];
2412
                Cache::put($cacheKey, $data, now()->addMinutes(self::CACHE_TTL_MINUTES));
2413
2414
                return $data;
2415
            }
2416
2417
        } catch (\Throwable $e) {
2418
            Log::error('ElasticSearch searchTvShowByExternalId error: '.$e->getMessage(), [
2419
                'field' => $field,
2420
                'value' => $value,
2421
            ]);
2422
        }
2423
2424
        return null;
2425
    }
2426
2427
    /**
2428
     * Search releases by external media IDs.
2429
     * Used to find releases associated with a specific movie or TV show.
2430
     *
2431
     * @param  array  $externalIds  Associative array of external IDs
2432
     * @param  int  $limit  Maximum number of results
2433
     * @return array Array of release IDs
2434
     */
2435
    public function searchReleasesByExternalId(array $externalIds, int $limit = 1000): array
2436
    {
2437
        if (empty($externalIds)) {
2438
            return [];
2439
        }
2440
2441
        $cacheKey = 'es:releases:extid:'.md5(serialize($externalIds));
2442
        $cached = Cache::get($cacheKey);
2443
        if ($cached !== null) {
2444
            return $cached;
2445
        }
2446
2447
        try {
2448
            $client = $this->getClient();
2449
2450
            $shouldClauses = [];
2451
            foreach ($externalIds as $field => $value) {
2452
                if (! empty($value) && in_array($field, ['imdbid', 'tmdbid', 'traktid', 'tvdb', 'tvmaze', 'tvrage'])) {
2453
                    $shouldClauses[] = ['term' => [$field => (int) $value]];
2454
                }
2455
            }
2456
2457
            if (empty($shouldClauses)) {
2458
                return [];
2459
            }
2460
2461
            $searchParams = [
2462
                'index' => $this->getReleasesIndex(),
2463
                'body' => [
2464
                    'query' => [
2465
                        'bool' => [
2466
                            'should' => $shouldClauses,
2467
                            'minimum_should_match' => 1,
2468
                        ],
2469
                    ],
2470
                    'size' => min($limit, self::MAX_RESULTS),
2471
                    '_source' => false,
2472
                ],
2473
            ];
2474
2475
            $response = $client->search($searchParams);
2476
2477
            $resultIds = [];
2478
            if (isset($response['hits']['hits'])) {
2479
                foreach ($response['hits']['hits'] as $hit) {
2480
                    $resultIds[] = $hit['_id'];
2481
                }
2482
            }
2483
2484
            if (! empty($resultIds)) {
2485
                Cache::put($cacheKey, $resultIds, now()->addMinutes(self::CACHE_TTL_MINUTES));
2486
            }
2487
2488
            return $resultIds;
2489
2490
        } catch (\Throwable $e) {
2491
            Log::error('ElasticSearch searchReleasesByExternalId error: '.$e->getMessage(), [
2492
                'externalIds' => $externalIds,
2493
            ]);
2494
        }
2495
2496
        return [];
2497
    }
2498
2499
    /**
2500
     * Search releases by category ID using the search index.
2501
     * This provides a fast way to get release IDs for a specific category without hitting the database.
2502
     *
2503
     * @param  array  $categoryIds  Array of category IDs to filter by
2504
     * @param  int  $limit  Maximum number of results
2505
     * @return array Array of release IDs
2506
     */
2507
    public function searchReleasesByCategory(array $categoryIds, int $limit = 1000): array
2508
    {
2509
        if (empty($categoryIds)) {
2510
            return [];
2511
        }
2512
2513
        // Filter out invalid category IDs (-1 means "all categories")
2514
        $validCategoryIds = array_filter($categoryIds, fn ($id) => $id > 0);
2515
        if (empty($validCategoryIds)) {
2516
            return [];
2517
        }
2518
2519
        $cacheKey = 'es:releases:cat:'.md5(serialize($validCategoryIds).':'.$limit);
2520
        $cached = Cache::get($cacheKey);
2521
        if ($cached !== null) {
2522
            return $cached;
2523
        }
2524
2525
        try {
2526
            $client = $this->getClient();
2527
2528
            $searchParams = [
2529
                'index' => $this->getReleasesIndex(),
2530
                'body' => [
2531
                    'query' => [
2532
                        'terms' => [
2533
                            'categories_id' => array_map('intval', $validCategoryIds),
2534
                        ],
2535
                    ],
2536
                    'size' => min($limit, self::MAX_RESULTS),
2537
                    '_source' => false,
2538
                ],
2539
            ];
2540
2541
            $response = $client->search($searchParams);
2542
2543
            $resultIds = [];
2544
            if (isset($response['hits']['hits'])) {
2545
                foreach ($response['hits']['hits'] as $hit) {
2546
                    $resultIds[] = $hit['_id'];
2547
                }
2548
            }
2549
2550
            if (! empty($resultIds)) {
2551
                Cache::put($cacheKey, $resultIds, now()->addMinutes(self::CACHE_TTL_MINUTES));
2552
            }
2553
2554
            return $resultIds;
2555
2556
        } catch (\Throwable $e) {
2557
            Log::error('ElasticSearch searchReleasesByCategory error: '.$e->getMessage(), [
2558
                'categoryIds' => $categoryIds,
2559
            ]);
2560
        }
2561
2562
        return [];
2563
    }
2564
2565
    /**
2566
     * Combined search: text search with category filtering.
2567
     * First searches by text, then filters by category IDs using the search index.
2568
     *
2569
     * @param  string  $searchTerm  Search text
2570
     * @param  array  $categoryIds  Array of category IDs to filter by (empty for all categories)
2571
     * @param  int  $limit  Maximum number of results
2572
     * @return array Array of release IDs
2573
     */
2574
    public function searchReleasesWithCategoryFilter(string $searchTerm, array $categoryIds = [], int $limit = 1000): array
2575
    {
2576
        if (empty($searchTerm)) {
2577
            // If no search term, just filter by category
2578
            return $this->searchReleasesByCategory($categoryIds, $limit);
2579
        }
2580
2581
        // Filter out invalid category IDs
2582
        $validCategoryIds = array_filter($categoryIds, fn ($id) => $id > 0);
2583
2584
        $cacheKey = 'es:releases:search_cat:'.md5($searchTerm.':'.serialize($validCategoryIds).':'.$limit);
2585
        $cached = Cache::get($cacheKey);
2586
        if ($cached !== null) {
2587
            return $cached;
2588
        }
2589
2590
        try {
2591
            $client = $this->getClient();
2592
2593
            $query = [
2594
                'bool' => [
2595
                    'must' => [
2596
                        [
2597
                            'multi_match' => [
2598
                                'query' => $searchTerm,
2599
                                'fields' => ['searchname^3', 'name^2', 'filename'],
2600
                                'type' => 'best_fields',
2601
                                'fuzziness' => 'AUTO',
2602
                            ],
2603
                        ],
2604
                    ],
2605
                ],
2606
            ];
2607
2608
            // Add category filter if provided
2609
            if (! empty($validCategoryIds)) {
2610
                $query['bool']['filter'] = [
2611
                    'terms' => [
2612
                        'categories_id' => array_map('intval', $validCategoryIds),
2613
                    ],
2614
                ];
2615
            }
2616
2617
            $searchParams = [
2618
                'index' => $this->getReleasesIndex(),
2619
                'body' => [
2620
                    'query' => $query,
2621
                    'size' => min($limit, self::MAX_RESULTS),
2622
                    '_source' => false,
2623
                ],
2624
            ];
2625
2626
            $response = $client->search($searchParams);
2627
2628
            $resultIds = [];
2629
            if (isset($response['hits']['hits'])) {
2630
                foreach ($response['hits']['hits'] as $hit) {
2631
                    $resultIds[] = $hit['_id'];
2632
                }
2633
            }
2634
2635
            if (! empty($resultIds)) {
2636
                Cache::put($cacheKey, $resultIds, now()->addMinutes(self::CACHE_TTL_MINUTES));
2637
            }
2638
2639
            return $resultIds;
2640
2641
        } catch (\Throwable $e) {
2642
            Log::error('ElasticSearch searchReleasesWithCategoryFilter error: '.$e->getMessage(), [
2643
                'searchTerm' => $searchTerm,
2644
                'categoryIds' => $categoryIds,
2645
            ]);
2646
        }
2647
2648
        return [];
2649
    }
2650
}
2651