ElasticSearchDriver   F
last analyzed

Complexity

Total Complexity 205

Size/Duplication

Total Lines 1535
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 205
eloc 687
c 0
b 0
f 0
dl 0
loc 1535
rs 1.913

42 Methods

Rating   Name   Duplication   Size   Complexity  
A indexExists() 0 14 3
A getIndexStats() 0 14 3
B deleteRelease() 0 29 8
A getClusterHealth() 0 14 3
A extractSuggestion() 0 36 5
A truncateIndex() 0 34 6
A getPredbIndex() 0 3 1
B indexSearchTMA() 0 40 6
A createPlainSearchName() 0 3 1
B getClient() 0 50 8
C suggest() 0 81 12
A buildReleaseDocument() 0 18 1
F executeSearch() 0 66 12
A sanitizeSearchTerms() 0 25 4
B updateRelease() 0 61 6
A __construct() 0 3 2
B predbIndexSearch() 0 40 6
A searchPredb() 0 5 2
B executeBulk() 0 25 7
B indexSearchApi() 0 37 6
A isSuggestEnabled() 0 3 2
A insertPredb() 0 34 5
A escapeString() 0 13 3
A insertRelease() 0 21 6
A optimizeIndex() 0 29 3
A buildSearchQuery() 0 33 2
B indexSearch() 0 56 10
A isElasticsearchAvailable() 0 32 6
A searchReleases() 0 23 5
B deletePreDb() 0 28 8
B bulkInsertReleases() 0 38 7
A resetConnection() 0 5 1
A buildCacheKey() 0 3 1
A buildHostsArray() 0 23 5
A clearScrollContext() 0 13 4
A updatePreDb() 0 37 5
C suggestFallback() 0 64 12
C autocomplete() 0 105 13
A getReleasesIndex() 0 3 1
A getDriverName() 0 3 1
A isAvailable() 0 3 1
A isAutocompleteEnabled() 0 3 2

How to fix   Complexity   

Complex Class

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

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

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

1
<?php
2
3
namespace App\Services\Search\Drivers;
4
5
use App\Models\Release;
6
use App\Services\Search\Contracts\SearchDriverInterface;
7
use Elasticsearch\Client;
8
use Elasticsearch\ClientBuilder;
9
use Elasticsearch\Common\Exceptions\ElasticsearchException;
10
use Elasticsearch\Common\Exceptions\Missing404Exception;
11
use GuzzleHttp\Ring\Client\CurlHandler;
12
use Illuminate\Support\Collection;
13
use Illuminate\Support\Facades\Cache;
14
use Illuminate\Support\Facades\DB;
15
use Illuminate\Support\Facades\Log;
16
use RuntimeException;
17
18
/**
19
 * Elasticsearch driver for full-text search functionality.
20
 *
21
 * Provides search functionality for the releases and predb indices with
22
 * caching, connection pooling, and automatic reconnection.
23
 */
24
class ElasticSearchDriver implements SearchDriverInterface
25
{
26
    private const CACHE_TTL_MINUTES = 5;
27
28
    private const SCROLL_TIMEOUT = '30s';
29
30
    private const MAX_RESULTS = 10000;
31
32
    private const AVAILABILITY_CHECK_CACHE_TTL = 30; // seconds
33
34
    private const DEFAULT_TIMEOUT = 10;
35
36
    private const DEFAULT_CONNECT_TIMEOUT = 5;
37
38
    private const AUTOCOMPLETE_CACHE_MINUTES = 10;
39
40
    private const AUTOCOMPLETE_MAX_RESULTS = 10;
41
42
    private const AUTOCOMPLETE_MIN_LENGTH = 2;
43
44
    private static ?Client $client = null;
45
46
    private static ?bool $availabilityCache = null;
47
48
    private static ?int $availabilityCacheTime = null;
49
50
    protected array $config;
51
52
    public function __construct(array $config = [])
53
    {
54
        $this->config = ! empty($config) ? $config : config('search.drivers.elasticsearch');
55
    }
56
57
    /**
58
     * Get the driver name.
59
     */
60
    public function getDriverName(): string
61
    {
62
        return 'elasticsearch';
63
    }
64
65
    /**
66
     * Check if Elasticsearch is available.
67
     */
68
    public function isAvailable(): bool
69
    {
70
        return $this->isElasticsearchAvailable();
71
    }
72
73
    /**
74
     * Get or create an Elasticsearch client with proper cURL configuration.
75
     *
76
     * Uses a singleton pattern to reuse the client connection across requests.
77
     *
78
     * @throws RuntimeException When client initialization fails
79
     */
80
    private function getClient(): Client
81
    {
82
        if (self::$client === null) {
83
            try {
84
                if (! extension_loaded('curl')) {
85
                    throw new RuntimeException('cURL extension is not loaded');
86
                }
87
88
                if (empty($this->config)) {
89
                    throw new RuntimeException('Elasticsearch configuration not found');
90
                }
91
92
                $clientBuilder = ClientBuilder::create();
93
                $hosts = $this->buildHostsArray($this->config['hosts'] ?? []);
94
95
                if (empty($hosts)) {
96
                    throw new RuntimeException('No Elasticsearch hosts configured');
97
                }
98
99
                if (config('app.debug')) {
100
                    Log::debug('Elasticsearch client initializing', [
101
                        'hosts' => $hosts,
102
                    ]);
103
                }
104
105
                $clientBuilder->setHosts($hosts);
106
                $clientBuilder->setHandler(new CurlHandler);
107
                $clientBuilder->setConnectionParams([
108
                    'timeout' => $this->config['timeout'] ?? self::DEFAULT_TIMEOUT,
109
                    'connect_timeout' => $this->config['connect_timeout'] ?? self::DEFAULT_CONNECT_TIMEOUT,
110
                ]);
111
112
                // Enable retries for better resilience
113
                $clientBuilder->setRetries($this->config['retries'] ?? 2);
114
115
                self::$client = $clientBuilder->build();
116
117
                if (config('app.debug')) {
118
                    Log::debug('Elasticsearch client initialized successfully');
119
                }
120
121
            } catch (\Throwable $e) {
122
                Log::error('Failed to initialize Elasticsearch client: '.$e->getMessage(), [
123
                    'exception_class' => get_class($e),
124
                ]);
125
                throw new RuntimeException('Elasticsearch client initialization failed: '.$e->getMessage());
126
            }
127
        }
128
129
        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...
130
    }
131
132
    /**
133
     * Build hosts array from configuration.
134
     *
135
     * @param  array  $configHosts  Configuration hosts array
136
     * @return array Formatted hosts array for Elasticsearch client
137
     */
138
    private function buildHostsArray(array $configHosts): array
139
    {
140
        $hosts = [];
141
142
        foreach ($configHosts as $host) {
143
            $hostConfig = [
144
                'host' => $host['host'] ?? 'localhost',
145
                'port' => $host['port'] ?? 9200,
146
            ];
147
148
            if (! empty($host['scheme'])) {
149
                $hostConfig['scheme'] = $host['scheme'];
150
            }
151
152
            if (! empty($host['user']) && ! empty($host['pass'])) {
153
                $hostConfig['user'] = $host['user'];
154
                $hostConfig['pass'] = $host['pass'];
155
            }
156
157
            $hosts[] = $hostConfig;
158
        }
159
160
        return $hosts;
161
    }
162
163
    /**
164
     * Check if Elasticsearch is available.
165
     *
166
     * Caches the availability status to avoid frequent ping requests.
167
     */
168
    private function isElasticsearchAvailable(): bool
169
    {
170
        $now = time();
171
172
        // Return cached result if still valid
173
        if (self::$availabilityCache !== null
174
            && self::$availabilityCacheTime !== null
175
            && ($now - self::$availabilityCacheTime) < self::AVAILABILITY_CHECK_CACHE_TTL) {
176
            return self::$availabilityCache;
177
        }
178
179
        try {
180
            $client = $this->getClient();
181
            $result = $client->ping();
182
183
            if (config('app.debug')) {
184
                Log::debug('Elasticsearch ping result', ['available' => $result]);
185
            }
186
187
            self::$availabilityCache = $result;
188
            self::$availabilityCacheTime = $now;
189
190
            return $result;
191
        } catch (\Throwable $e) {
192
            Log::warning('Elasticsearch is not available: '.$e->getMessage(), [
193
                'exception_class' => get_class($e),
194
            ]);
195
196
            self::$availabilityCache = false;
197
            self::$availabilityCacheTime = $now;
198
199
            return false;
200
        }
201
    }
202
203
    /**
204
     * Reset the client connection (useful for testing or reconnection).
205
     */
206
    public function resetConnection(): void
207
    {
208
        self::$client = null;
209
        self::$availabilityCache = null;
210
        self::$availabilityCacheTime = null;
211
    }
212
213
    /**
214
     * Check if autocomplete is enabled.
215
     */
216
    public function isAutocompleteEnabled(): bool
217
    {
218
        return ($this->config['autocomplete']['enabled'] ?? true) && $this->isElasticsearchAvailable();
219
    }
220
221
    /**
222
     * Check if suggest is enabled.
223
     */
224
    public function isSuggestEnabled(): bool
225
    {
226
        return ($this->config['suggest']['enabled'] ?? true) && $this->isElasticsearchAvailable();
227
    }
228
229
    /**
230
     * Get the releases index name.
231
     */
232
    public function getReleasesIndex(): string
233
    {
234
        return $this->config['indexes']['releases'] ?? 'releases';
235
    }
236
237
    /**
238
     * Get the predb index name.
239
     */
240
    public function getPredbIndex(): string
241
    {
242
        return $this->config['indexes']['predb'] ?? 'predb';
243
    }
244
245
    /**
246
     * Escape special characters for Elasticsearch.
247
     */
248
    public static function escapeString(string $string): string
249
    {
250
        if (empty($string) || $string === '*') {
251
            return '';
252
        }
253
254
        // Replace dots with spaces for release name searches
255
        $string = str_replace('.', ' ', $string);
256
257
        // Remove multiple consecutive spaces
258
        $string = preg_replace('/\s+/', ' ', trim($string));
259
260
        return $string;
261
    }
262
263
    /**
264
     * Get autocomplete suggestions for a search query.
265
     * Searches the releases index and returns matching searchnames.
266
     *
267
     * @param  string  $query  The partial search query
268
     * @param  string|null  $index  Index to search (defaults to releases index)
269
     * @return array<array{suggest: string, distance: int, docs: int}>
270
     */
271
    public function autocomplete(string $query, ?string $index = null): array
272
    {
273
        if (! $this->isAutocompleteEnabled()) {
274
            return [];
275
        }
276
277
        $query = trim($query);
278
        $minLength = (int) ($this->config['autocomplete']['min_length'] ?? self::AUTOCOMPLETE_MIN_LENGTH);
279
        if (strlen($query) < $minLength) {
280
            return [];
281
        }
282
283
        $index = $index ?? $this->getReleasesIndex();
284
        $cacheKey = 'es:autocomplete:'.md5($index.$query);
285
286
        $cached = Cache::get($cacheKey);
287
        if ($cached !== null) {
288
            return $cached;
289
        }
290
291
        $suggestions = [];
292
        $maxResults = (int) ($this->config['autocomplete']['max_results'] ?? self::AUTOCOMPLETE_MAX_RESULTS);
293
294
        try {
295
            $client = $this->getClient();
296
297
            // Use a prefix/match query on searchname field
298
            $searchParams = [
299
                'index' => $index,
300
                'body' => [
301
                    'query' => [
302
                        'bool' => [
303
                            'should' => [
304
                                // Prefix match for autocomplete-like behavior
305
                                [
306
                                    'match_phrase_prefix' => [
307
                                        'searchname' => [
308
                                            'query' => $query,
309
                                            'max_expansions' => 50,
310
                                        ],
311
                                    ],
312
                                ],
313
                                // Also include regular match for better results
314
                                [
315
                                    'match' => [
316
                                        'searchname' => [
317
                                            'query' => $query,
318
                                            'fuzziness' => 'AUTO',
319
                                        ],
320
                                    ],
321
                                ],
322
                            ],
323
                            'minimum_should_match' => 1,
324
                        ],
325
                    ],
326
                    'size' => $maxResults * 3,
327
                    '_source' => ['searchname'],
328
                    'sort' => [
329
                        // Sort by date first to get latest results, then by score
330
                        ['add_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']],
331
                        ['_score' => ['order' => 'desc']],
332
                    ],
333
                ],
334
            ];
335
336
            $response = $client->search($searchParams);
337
338
            $seen = [];
339
            if (isset($response['hits']['hits'])) {
340
                foreach ($response['hits']['hits'] as $hit) {
341
                    $searchname = $hit['_source']['searchname'] ?? '';
342
343
                    if (empty($searchname)) {
344
                        continue;
345
                    }
346
347
                    // Create a clean suggestion from the searchname
348
                    $suggestion = $this->extractSuggestion($searchname, $query);
349
350
                    if (! empty($suggestion) && ! isset($seen[strtolower($suggestion)])) {
351
                        $seen[strtolower($suggestion)] = true;
352
                        $suggestions[] = [
353
                            'suggest' => $suggestion,
354
                            'distance' => 0,
355
                            'docs' => 1,
356
                        ];
357
                    }
358
359
                    if (count($suggestions) >= $maxResults) {
360
                        break;
361
                    }
362
                }
363
            }
364
        } catch (\Throwable $e) {
365
            if (config('app.debug')) {
366
                Log::warning('ElasticSearch autocomplete error: '.$e->getMessage());
367
            }
368
        }
369
370
        if (! empty($suggestions)) {
371
            $cacheMinutes = (int) ($this->config['autocomplete']['cache_minutes'] ?? self::AUTOCOMPLETE_CACHE_MINUTES);
372
            Cache::put($cacheKey, $suggestions, now()->addMinutes($cacheMinutes));
373
        }
374
375
        return $suggestions;
376
    }
377
378
    /**
379
     * Get spell correction suggestions ("Did you mean?").
380
     *
381
     * @param  string  $query  The search query to check
382
     * @param  string|null  $index  Index to use for suggestions
383
     * @return array<array{suggest: string, distance: int, docs: int}>
384
     */
385
    public function suggest(string $query, ?string $index = null): array
386
    {
387
        if (! $this->isSuggestEnabled()) {
388
            return [];
389
        }
390
391
        $query = trim($query);
392
        if (empty($query)) {
393
            return [];
394
        }
395
396
        $index = $index ?? $this->getReleasesIndex();
397
        $cacheKey = 'es:suggest:'.md5($index.$query);
398
399
        $cached = Cache::get($cacheKey);
400
        if ($cached !== null) {
401
            return $cached;
402
        }
403
404
        $suggestions = [];
405
406
        try {
407
            $client = $this->getClient();
408
409
            // Use Elasticsearch suggest API with phrase suggester
410
            $searchParams = [
411
                'index' => $index,
412
                'body' => [
413
                    'suggest' => [
414
                        'text' => $query,
415
                        'searchname_suggest' => [
416
                            'phrase' => [
417
                                'field' => 'searchname',
418
                                'size' => 5,
419
                                'gram_size' => 3,
420
                                'direct_generator' => [
421
                                    [
422
                                        'field' => 'searchname',
423
                                        'suggest_mode' => 'popular',
424
                                    ],
425
                                ],
426
                                'highlight' => [
427
                                    'pre_tag' => '',
428
                                    'post_tag' => '',
429
                                ],
430
                            ],
431
                        ],
432
                    ],
433
                ],
434
            ];
435
436
            $response = $client->search($searchParams);
437
438
            if (isset($response['suggest']['searchname_suggest'][0]['options'])) {
439
                foreach ($response['suggest']['searchname_suggest'][0]['options'] as $option) {
440
                    $suggestedText = $option['text'] ?? '';
441
                    if (! empty($suggestedText) && strtolower($suggestedText) !== strtolower($query)) {
442
                        $suggestions[] = [
443
                            'suggest' => $suggestedText,
444
                            'distance' => 1,
445
                            'docs' => (int) ($option['freq'] ?? 1),
446
                        ];
447
                    }
448
                }
449
            }
450
        } catch (\Throwable $e) {
451
            if (config('app.debug')) {
452
                Log::debug('ElasticSearch native suggest failed: '.$e->getMessage());
453
            }
454
        }
455
456
        // Fallback: if native suggest didn't work, use fuzzy search
457
        if (empty($suggestions)) {
458
            $suggestions = $this->suggestFallback($query, $index);
459
        }
460
461
        if (! empty($suggestions)) {
462
            Cache::put($cacheKey, $suggestions, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
463
        }
464
465
        return $suggestions;
466
    }
467
468
    /**
469
     * Fallback suggest using fuzzy search on searchnames.
470
     *
471
     * @param  string  $query  The search query
472
     * @param  string  $index  Index to search
473
     * @return array<array{suggest: string, distance: int, docs: int}>
474
     */
475
    private function suggestFallback(string $query, string $index): array
476
    {
477
        try {
478
            $client = $this->getClient();
479
480
            // Use fuzzy match to find similar terms
481
            $searchParams = [
482
                'index' => $index,
483
                'body' => [
484
                    'query' => [
485
                        'match' => [
486
                            'searchname' => [
487
                                'query' => $query,
488
                                'fuzziness' => 'AUTO',
489
                            ],
490
                        ],
491
                    ],
492
                    'size' => 20,
493
                    '_source' => ['searchname'],
494
                ],
495
            ];
496
497
            $response = $client->search($searchParams);
498
499
            // Extract common terms from results that differ from the query
500
            $termCounts = [];
501
            if (isset($response['hits']['hits'])) {
502
                foreach ($response['hits']['hits'] as $hit) {
503
                    $searchname = $hit['_source']['searchname'] ?? '';
504
                    $words = preg_split('/[\s.\-_]+/', strtolower($searchname));
505
506
                    foreach ($words as $word) {
507
                        if (strlen($word) >= 3 && $word !== strtolower($query)) {
508
                            $distance = levenshtein(strtolower($query), $word);
509
                            if ($distance > 0 && $distance <= 3) {
510
                                if (! isset($termCounts[$word])) {
511
                                    $termCounts[$word] = ['count' => 0, 'distance' => $distance];
512
                                }
513
                                $termCounts[$word]['count']++;
514
                            }
515
                        }
516
                    }
517
                }
518
            }
519
520
            // Sort by count
521
            uasort($termCounts, fn ($a, $b) => $b['count'] - $a['count']);
522
523
            $suggestions = [];
524
            foreach (array_slice($termCounts, 0, 5, true) as $term => $data) {
525
                $suggestions[] = [
526
                    'suggest' => $term,
527
                    'distance' => $data['distance'],
528
                    'docs' => $data['count'],
529
                ];
530
            }
531
532
            return $suggestions;
533
        } catch (\Throwable $e) {
534
            if (config('app.debug')) {
535
                Log::warning('ElasticSearch suggest fallback error: '.$e->getMessage());
536
            }
537
538
            return [];
539
        }
540
    }
541
542
    /**
543
     * Extract a clean suggestion from a searchname.
544
     *
545
     * @param  string  $searchname  The full searchname
546
     * @param  string  $query  The user's query
547
     * @return string|null The extracted suggestion
548
     */
549
    private function extractSuggestion(string $searchname, string $query): ?string
550
    {
551
        // Clean up the searchname - remove file extensions
552
        $clean = preg_replace('/\.(mkv|avi|mp4|wmv|nfo|nzb|par2|rar|zip|r\d+)$/i', '', $searchname);
553
554
        // Replace dots and underscores with spaces for readability
555
        $clean = str_replace(['.', '_'], ' ', $clean);
556
557
        // Remove multiple spaces
558
        $clean = preg_replace('/\s+/', ' ', $clean);
559
        $clean = trim($clean);
560
561
        if (empty($clean)) {
562
            return null;
563
        }
564
565
        // If the clean name is reasonable length, use it
566
        if (strlen($clean) <= 80) {
567
            return $clean;
568
        }
569
570
        // For very long names, try to extract the relevant part
571
        $pos = stripos($clean, $query);
572
        if ($pos !== false) {
573
            $start = max(0, $pos - 10);
574
            $extracted = substr($clean, $start, 80);
575
576
            if ($start > 0) {
577
                $extracted = preg_replace('/^\S*\s/', '', $extracted);
578
            }
579
            $extracted = preg_replace('/\s\S*$/', '', $extracted);
580
581
            return trim($extracted);
582
        }
583
584
        return substr($clean, 0, 80);
585
    }
586
587
    /**
588
     * Search releases index.
589
     *
590
     * @param  array|string  $phrases  Search phrases - can be a string, indexed array of terms, or associative array with field names
591
     * @param  int  $limit  Maximum number of results
592
     * @return array Array of release IDs
593
     */
594
    public function searchReleases(array|string $phrases, int $limit = 1000): array
595
    {
596
        // Normalize the input to a search string
597
        if (is_string($phrases)) {
0 ignored issues
show
introduced by
The condition is_string($phrases) is always false.
Loading history...
598
            $searchString = $phrases;
599
        } elseif (is_array($phrases)) {
0 ignored issues
show
introduced by
The condition is_array($phrases) is always true.
Loading history...
600
            // Check if it's an associative array (has string keys like 'searchname')
601
            $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0;
602
603
            if ($isAssociative) {
604
                // Extract values from associative array
605
                $searchString = implode(' ', array_values($phrases));
606
            } else {
607
                // Indexed array - combine values
608
                $searchString = implode(' ', $phrases);
609
            }
610
        } else {
611
            return [];
612
        }
613
614
        $result = $this->indexSearch($searchString, $limit);
615
616
        return is_array($result) ? $result : $result->toArray();
0 ignored issues
show
introduced by
The condition is_array($result) is always true.
Loading history...
617
    }
618
619
    /**
620
     * Search the predb index.
621
     *
622
     * @param  array|string  $searchTerm  Search term(s)
623
     * @return array Array of predb records
624
     */
625
    public function searchPredb(array|string $searchTerm): array
626
    {
627
        $result = $this->predbIndexSearch($searchTerm);
628
629
        return is_array($result) ? $result : $result->toArray();
0 ignored issues
show
introduced by
The condition is_array($result) is always true.
Loading history...
630
    }
631
632
    /**
633
     * Search releases index.
634
     *
635
     * @param  array|string  $phrases  Search phrases
636
     * @param  int  $limit  Maximum number of results
637
     * @return array|Collection Array of release IDs
638
     */
639
    public function indexSearch(array|string $phrases, int $limit): array|Collection
640
    {
641
        if (empty($phrases) || ! $this->isElasticsearchAvailable()) {
642
            if (config('app.debug')) {
643
                Log::debug('ElasticSearch indexSearch: empty phrases or ES not available', [
644
                    'phrases_empty' => empty($phrases),
645
                    'es_available' => $this->isElasticsearchAvailable(),
646
                ]);
647
            }
648
649
            return [];
650
        }
651
652
        $keywords = $this->sanitizeSearchTerms($phrases);
653
654
        if (config('app.debug')) {
655
            Log::debug('ElasticSearch indexSearch: sanitized keywords', [
656
                'original' => is_array($phrases) ? implode(' ', $phrases) : $phrases,
0 ignored issues
show
introduced by
The condition is_array($phrases) is always true.
Loading history...
657
                'sanitized' => $keywords,
658
            ]);
659
        }
660
661
        if (empty($keywords)) {
662
            if (config('app.debug')) {
663
                Log::debug('ElasticSearch indexSearch: keywords empty after sanitization');
664
            }
665
666
            return [];
667
        }
668
669
        $cacheKey = $this->buildCacheKey('index_search', [$keywords, $limit]);
670
        $cached = Cache::get($cacheKey);
671
        if ($cached !== null) {
672
            return $cached;
673
        }
674
675
        try {
676
            $search = $this->buildSearchQuery(
677
                index: $this->getReleasesIndex(),
678
                keywords: $keywords,
679
                fields: ['searchname^2', 'plainsearchname^1.5', 'fromname', 'filename', 'name^1.2'],
680
                limit: $limit
681
            );
682
683
            $result = $this->executeSearch($search);
684
            Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
685
686
            return $result;
687
688
        } catch (ElasticsearchException $e) {
689
            Log::error('ElasticSearch indexSearch error: '.$e->getMessage(), [
690
                'keywords' => $keywords,
691
                'limit' => $limit,
692
            ]);
693
694
            return [];
695
        }
696
    }
697
698
    /**
699
     * Search releases for API requests.
700
     *
701
     * @param  array|string  $searchName  Search name(s)
702
     * @param  int  $limit  Maximum number of results
703
     * @return array|Collection Array of release IDs
704
     */
705
    public function indexSearchApi(array|string $searchName, int $limit): array|Collection
706
    {
707
        if (empty($searchName) || ! $this->isElasticsearchAvailable()) {
708
            return [];
709
        }
710
711
        $keywords = $this->sanitizeSearchTerms($searchName);
712
        if (empty($keywords)) {
713
            return [];
714
        }
715
716
        $cacheKey = $this->buildCacheKey('api_search', [$keywords, $limit]);
717
        $cached = Cache::get($cacheKey);
718
        if ($cached !== null) {
719
            return $cached;
720
        }
721
722
        try {
723
            $search = $this->buildSearchQuery(
724
                index: $this->getReleasesIndex(),
725
                keywords: $keywords,
726
                fields: ['searchname^2', 'plainsearchname^1.5', 'fromname', 'filename', 'name^1.2'],
727
                limit: $limit
728
            );
729
730
            $result = $this->executeSearch($search);
731
            Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
732
733
            return $result;
734
735
        } catch (ElasticsearchException $e) {
736
            Log::error('ElasticSearch indexSearchApi error: '.$e->getMessage(), [
737
                'keywords' => $keywords,
738
                'limit' => $limit,
739
            ]);
740
741
            return [];
742
        }
743
    }
744
745
    /**
746
     * Search releases for TV/Movie/Audio (TMA) matching.
747
     *
748
     * @param  array|string  $name  Name(s) to search
749
     * @param  int  $limit  Maximum number of results
750
     * @return array|Collection Array of release IDs
751
     */
752
    public function indexSearchTMA(array|string $name, int $limit): array|Collection
753
    {
754
        if (empty($name) || ! $this->isElasticsearchAvailable()) {
755
            return [];
756
        }
757
758
        $keywords = $this->sanitizeSearchTerms($name);
759
        if (empty($keywords)) {
760
            return [];
761
        }
762
763
        $cacheKey = $this->buildCacheKey('tma_search', [$keywords, $limit]);
764
        $cached = Cache::get($cacheKey);
765
        if ($cached !== null) {
766
            return $cached;
767
        }
768
769
        try {
770
            $search = $this->buildSearchQuery(
771
                index: $this->getReleasesIndex(),
772
                keywords: $keywords,
773
                fields: ['searchname^2', 'plainsearchname^1.5'],
774
                limit: $limit,
775
                options: [
776
                    'boost' => 1.2,
777
                ]
778
            );
779
780
            $result = $this->executeSearch($search);
781
            Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
782
783
            return $result;
784
785
        } catch (ElasticsearchException $e) {
786
            Log::error('ElasticSearch indexSearchTMA error: '.$e->getMessage(), [
787
                'keywords' => $keywords,
788
                'limit' => $limit,
789
            ]);
790
791
            return [];
792
        }
793
    }
794
795
    /**
796
     * Search predb index.
797
     *
798
     * @param  array|string  $searchTerm  Search term(s)
799
     * @return array|Collection Array of predb records
800
     */
801
    public function predbIndexSearch(array|string $searchTerm): array|Collection
802
    {
803
        if (empty($searchTerm) || ! $this->isElasticsearchAvailable()) {
804
            return [];
805
        }
806
807
        $keywords = $this->sanitizeSearchTerms($searchTerm);
808
        if (empty($keywords)) {
809
            return [];
810
        }
811
812
        $cacheKey = $this->buildCacheKey('predb_search', [$keywords]);
813
        $cached = Cache::get($cacheKey);
814
        if ($cached !== null) {
815
            return $cached;
816
        }
817
818
        try {
819
            $search = $this->buildSearchQuery(
820
                index: $this->getPredbIndex(),
821
                keywords: $keywords,
822
                fields: ['title^2', 'filename'],
823
                limit: 1000,
824
                options: [
825
                    'fuzziness' => 'AUTO',
826
                ],
827
                includeDateSort: false
828
            );
829
830
            $result = $this->executeSearch($search, fullResults: true);
831
            Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? self::CACHE_TTL_MINUTES));
832
833
            return $result;
834
835
        } catch (ElasticsearchException $e) {
836
            Log::error('ElasticSearch predbIndexSearch error: '.$e->getMessage(), [
837
                'keywords' => $keywords,
838
            ]);
839
840
            return [];
841
        }
842
    }
843
844
    /**
845
     * Insert a release into the index.
846
     *
847
     * @param  array  $parameters  Release data with 'id', 'name', 'searchname', etc.
848
     */
849
    public function insertRelease(array $parameters): void
850
    {
851
        if (empty($parameters['id']) || ! $this->isElasticsearchAvailable()) {
852
            if (empty($parameters['id'])) {
853
                Log::warning('ElasticSearch: Cannot insert release without ID');
854
            }
855
856
            return;
857
        }
858
859
        try {
860
            $client = $this->getClient();
861
            $client->index($this->buildReleaseDocument($parameters));
862
863
        } catch (ElasticsearchException $e) {
864
            Log::error('ElasticSearch insertRelease error: '.$e->getMessage(), [
865
                'release_id' => $parameters['id'],
866
            ]);
867
        } catch (\Throwable $e) {
868
            Log::error('ElasticSearch insertRelease unexpected error: '.$e->getMessage(), [
869
                'release_id' => $parameters['id'],
870
            ]);
871
        }
872
    }
873
874
    /**
875
     * Bulk insert multiple releases into the index.
876
     *
877
     * @param  array  $releases  Array of release data arrays
878
     * @return array Results with 'success' and 'errors' counts
879
     */
880
    public function bulkInsertReleases(array $releases): array
881
    {
882
        if (empty($releases) || ! $this->isElasticsearchAvailable()) {
883
            return ['success' => 0, 'errors' => 0];
884
        }
885
886
        $params = ['body' => []];
887
        $validReleases = 0;
888
889
        foreach ($releases as $release) {
890
            if (empty($release['id'])) {
891
                continue;
892
            }
893
894
            $params['body'][] = [
895
                'index' => [
896
                    '_index' => $this->getReleasesIndex(),
897
                    '_id' => $release['id'],
898
                ],
899
            ];
900
901
            $document = $this->buildReleaseDocument($release);
902
            $params['body'][] = $document['body'];
903
            $validReleases++;
904
905
            // Send batch when reaching 500 documents
906
            if ($validReleases % 500 === 0) {
907
                $this->executeBulk($params);
908
                $params = ['body' => []];
909
            }
910
        }
911
912
        // Send remaining documents
913
        if (! empty($params['body'])) {
914
            $this->executeBulk($params);
915
        }
916
917
        return ['success' => $validReleases, 'errors' => 0];
918
    }
919
920
    /**
921
     * Update a release in the index.
922
     *
923
     * @param  int|string  $releaseID  Release ID
924
     */
925
    public function updateRelease(int|string $releaseID): void
926
    {
927
        if (empty($releaseID)) {
928
            Log::warning('ElasticSearch: Cannot update release without ID');
929
930
            return;
931
        }
932
933
        if (! $this->isElasticsearchAvailable()) {
934
            return;
935
        }
936
937
        try {
938
            $release = Release::query()
939
                ->where('releases.id', $releaseID)
940
                ->leftJoin('release_files as rf', 'releases.id', '=', 'rf.releases_id')
941
                ->select([
942
                    'releases.id',
943
                    'releases.name',
944
                    'releases.searchname',
945
                    'releases.fromname',
946
                    'releases.categories_id',
947
                    DB::raw('IFNULL(GROUP_CONCAT(rf.name SEPARATOR " "),"") filename'),
948
                ])
949
                ->groupBy('releases.id')
950
                ->first();
951
952
            if ($release === null) {
953
                Log::warning('ElasticSearch: Release not found for update', ['id' => $releaseID]);
954
955
                return;
956
            }
957
958
            $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...
959
            $data = [
960
                'body' => [
961
                    'doc' => [
962
                        'id' => $release->id,
963
                        '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...
964
                        'searchname' => $release->searchname,
965
                        'plainsearchname' => $searchNameDotless,
966
                        '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...
967
                        '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...
968
                        '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...
969
                    ],
970
                    'doc_as_upsert' => true,
971
                ],
972
                'index' => $this->getReleasesIndex(),
973
                'id' => $release->id,
974
            ];
975
976
            $client = $this->getClient();
977
            $client->update($data);
978
979
        } catch (ElasticsearchException $e) {
980
            Log::error('ElasticSearch updateRelease error: '.$e->getMessage(), [
981
                'release_id' => $releaseID,
982
            ]);
983
        } catch (\Throwable $e) {
984
            Log::error('ElasticSearch updateRelease unexpected error: '.$e->getMessage(), [
985
                'release_id' => $releaseID,
986
            ]);
987
        }
988
    }
989
990
991
    /**
992
     * Insert a predb record into the index.
993
     *
994
     * @param  array  $parameters  Predb data with 'id', 'title', 'source', 'filename'
995
     */
996
    public function insertPredb(array $parameters): void
997
    {
998
        if (empty($parameters['id'])) {
999
            Log::warning('ElasticSearch: Cannot insert predb without ID');
1000
1001
            return;
1002
        }
1003
1004
        if (! $this->isElasticsearchAvailable()) {
1005
            return;
1006
        }
1007
1008
        try {
1009
            $data = [
1010
                'body' => [
1011
                    'id' => $parameters['id'],
1012
                    'title' => $parameters['title'] ?? '',
1013
                    'source' => $parameters['source'] ?? '',
1014
                    'filename' => $parameters['filename'] ?? '',
1015
                ],
1016
                'index' => $this->getPredbIndex(),
1017
                'id' => $parameters['id'],
1018
            ];
1019
1020
            $client = $this->getClient();
1021
            $client->index($data);
1022
1023
        } catch (ElasticsearchException $e) {
1024
            Log::error('ElasticSearch insertPreDb error: '.$e->getMessage(), [
1025
                'predb_id' => $parameters['id'],
1026
            ]);
1027
        } catch (\Throwable $e) {
1028
            Log::error('ElasticSearch insertPreDb unexpected error: '.$e->getMessage(), [
1029
                'predb_id' => $parameters['id'],
1030
            ]);
1031
        }
1032
    }
1033
1034
    /**
1035
     * Update a predb record in the index.
1036
     *
1037
     * @param  array  $parameters  Predb data with 'id', 'title', 'source', 'filename'
1038
     */
1039
    public function updatePreDb(array $parameters): void
1040
    {
1041
        if (empty($parameters['id'])) {
1042
            Log::warning('ElasticSearch: Cannot update predb without ID');
1043
1044
            return;
1045
        }
1046
1047
        if (! $this->isElasticsearchAvailable()) {
1048
            return;
1049
        }
1050
1051
        try {
1052
            $data = [
1053
                'body' => [
1054
                    'doc' => [
1055
                        'id' => $parameters['id'],
1056
                        'title' => $parameters['title'] ?? '',
1057
                        'filename' => $parameters['filename'] ?? '',
1058
                        'source' => $parameters['source'] ?? '',
1059
                    ],
1060
                    'doc_as_upsert' => true,
1061
                ],
1062
                'index' => $this->getPredbIndex(),
1063
                'id' => $parameters['id'],
1064
            ];
1065
1066
            $client = $this->getClient();
1067
            $client->update($data);
1068
1069
        } catch (ElasticsearchException $e) {
1070
            Log::error('ElasticSearch updatePreDb error: '.$e->getMessage(), [
1071
                'predb_id' => $parameters['id'],
1072
            ]);
1073
        } catch (\Throwable $e) {
1074
            Log::error('ElasticSearch updatePreDb unexpected error: '.$e->getMessage(), [
1075
                'predb_id' => $parameters['id'],
1076
            ]);
1077
        }
1078
    }
1079
1080
    /**
1081
     * Delete a release from the index.
1082
     *
1083
     * @param  int  $id  Release ID
1084
     */
1085
    public function deleteRelease(int $id): void
1086
    {
1087
        if (empty($id) || ! $this->isElasticsearchAvailable()) {
1088
            if (empty($id)) {
1089
                Log::warning('ElasticSearch: Cannot delete release without ID');
1090
            }
1091
1092
            return;
1093
        }
1094
1095
        try {
1096
            $client = $this->getClient();
1097
            $client->delete([
1098
                'index' => $this->getReleasesIndex(),
1099
                'id' => $id,
1100
            ]);
1101
1102
        } catch (Missing404Exception $e) {
1103
            // Document already deleted, not an error
1104
            if (config('app.debug')) {
1105
                Log::debug('ElasticSearch deleteRelease: document not found', ['release_id' => $id]);
1106
            }
1107
        } catch (ElasticsearchException $e) {
1108
            Log::error('ElasticSearch deleteRelease error: '.$e->getMessage(), [
1109
                'release_id' => $id,
1110
            ]);
1111
        } catch (\Throwable $e) {
1112
            Log::error('ElasticSearch deleteRelease unexpected error: '.$e->getMessage(), [
1113
                'release_id' => $id,
1114
            ]);
1115
        }
1116
    }
1117
1118
    /**
1119
     * Delete a predb record from the index.
1120
     *
1121
     * @param  int  $id  Predb ID
1122
     */
1123
    public function deletePreDb(int $id): void
1124
    {
1125
        if (empty($id) || ! $this->isElasticsearchAvailable()) {
1126
            if (empty($id)) {
1127
                Log::warning('ElasticSearch: Cannot delete predb without ID');
1128
            }
1129
1130
            return;
1131
        }
1132
1133
        try {
1134
            $client = $this->getClient();
1135
            $client->delete([
1136
                'index' => $this->getPredbIndex(),
1137
                'id' => $id,
1138
            ]);
1139
1140
        } catch (Missing404Exception $e) {
1141
            if (config('app.debug')) {
1142
                Log::debug('ElasticSearch deletePreDb: document not found', ['predb_id' => $id]);
1143
            }
1144
        } catch (ElasticsearchException $e) {
1145
            Log::error('ElasticSearch deletePreDb error: '.$e->getMessage(), [
1146
                'predb_id' => $id,
1147
            ]);
1148
        } catch (\Throwable $e) {
1149
            Log::error('ElasticSearch deletePreDb unexpected error: '.$e->getMessage(), [
1150
                'predb_id' => $id,
1151
            ]);
1152
        }
1153
    }
1154
1155
    /**
1156
     * Check if an index exists.
1157
     *
1158
     * @param  string  $index  Index name
1159
     */
1160
    public function indexExists(string $index): bool
1161
    {
1162
        if (! $this->isElasticsearchAvailable()) {
1163
            return false;
1164
        }
1165
1166
        try {
1167
            $client = $this->getClient();
1168
1169
            return $client->indices()->exists(['index' => $index]);
1170
        } catch (\Throwable $e) {
1171
            Log::error('ElasticSearch indexExists error: '.$e->getMessage(), ['index' => $index]);
1172
1173
            return false;
1174
        }
1175
    }
1176
1177
    /**
1178
     * Truncate/clear an index (remove all documents).
1179
     * Implements SearchServiceInterface::truncateIndex
1180
     *
1181
     * @param  array|string  $indexes  Index name(s) to truncate
1182
     */
1183
    public function truncateIndex(array|string $indexes): void
1184
    {
1185
        if (! $this->isElasticsearchAvailable()) {
1186
            return;
1187
        }
1188
1189
        $indexArray = is_array($indexes) ? $indexes : [$indexes];
0 ignored issues
show
introduced by
The condition is_array($indexes) is always true.
Loading history...
1190
1191
        foreach ($indexArray as $index) {
1192
            try {
1193
                $client = $this->getClient();
1194
1195
                // Check if index exists
1196
                if (! $client->indices()->exists(['index' => $index])) {
1197
                    Log::info("ElasticSearch truncateIndex: index {$index} does not exist, skipping");
1198
                    continue;
1199
                }
1200
1201
                // Delete all documents from the index
1202
                $client->deleteByQuery([
1203
                    'index' => $index,
1204
                    'body' => [
1205
                        'query' => ['match_all' => (object) []],
1206
                    ],
1207
                    'conflicts' => 'proceed',
1208
                ]);
1209
1210
                // Force refresh to ensure deletions are visible
1211
                $client->indices()->refresh(['index' => $index]);
1212
1213
                Log::info("ElasticSearch: Truncated index {$index}");
1214
1215
            } catch (\Throwable $e) {
1216
                Log::error("ElasticSearch truncateIndex error for {$index}: ".$e->getMessage());
1217
            }
1218
        }
1219
    }
1220
1221
    /**
1222
     * Optimize index for better search performance.
1223
     * Implements SearchServiceInterface::optimizeIndex
1224
     */
1225
    public function optimizeIndex(): void
1226
    {
1227
        if (! $this->isElasticsearchAvailable()) {
1228
            return;
1229
        }
1230
1231
        try {
1232
            $client = $this->getClient();
1233
1234
            // Force merge the releases index
1235
            $client->indices()->forcemerge([
1236
                'index' => $this->getReleasesIndex(),
1237
                'max_num_segments' => 1,
1238
            ]);
1239
1240
            // Force merge the predb index
1241
            $client->indices()->forcemerge([
1242
                'index' => $this->getPredbIndex(),
1243
                'max_num_segments' => 1,
1244
            ]);
1245
1246
            // Refresh both indexes
1247
            $client->indices()->refresh(['index' => $this->getReleasesIndex()]);
1248
            $client->indices()->refresh(['index' => $this->getPredbIndex()]);
1249
1250
            Log::info('ElasticSearch: Optimized indexes');
1251
1252
        } catch (\Throwable $e) {
1253
            Log::error('ElasticSearch optimizeIndex error: '.$e->getMessage());
1254
        }
1255
    }
1256
1257
    /**
1258
     * Get cluster health information.
1259
     *
1260
     * @return array Health information or empty array on failure
1261
     */
1262
    public function getClusterHealth(): array
1263
    {
1264
        if (! $this->isElasticsearchAvailable()) {
1265
            return [];
1266
        }
1267
1268
        try {
1269
            $client = $this->getClient();
1270
1271
            return $client->cluster()->health();
1272
        } catch (\Throwable $e) {
1273
            Log::error('ElasticSearch getClusterHealth error: '.$e->getMessage());
1274
1275
            return [];
1276
        }
1277
    }
1278
1279
    /**
1280
     * Get index statistics.
1281
     *
1282
     * @param  string  $index  Index name
1283
     * @return array Statistics or empty array on failure
1284
     */
1285
    public function getIndexStats(string $index): array
1286
    {
1287
        if (! $this->isElasticsearchAvailable()) {
1288
            return [];
1289
        }
1290
1291
        try {
1292
            $client = $this->getClient();
1293
1294
            return $client->indices()->stats(['index' => $index]);
1295
        } catch (\Throwable $e) {
1296
            Log::error('ElasticSearch getIndexStats error: '.$e->getMessage(), ['index' => $index]);
1297
1298
            return [];
1299
        }
1300
    }
1301
1302
    /**
1303
     * Sanitize search terms for Elasticsearch.
1304
     *
1305
     * Uses the global sanitize() helper function which properly escapes
1306
     * Elasticsearch query string special characters.
1307
     *
1308
     * @param  array|string  $terms  Search terms
1309
     * @return string Sanitized search string
1310
     */
1311
    private function sanitizeSearchTerms(array|string $terms): string
1312
    {
1313
        if (is_array($terms)) {
0 ignored issues
show
introduced by
The condition is_array($terms) is always true.
Loading history...
1314
            $terms = implode(' ', array_filter($terms));
1315
        }
1316
1317
        $terms = trim($terms);
1318
        if (empty($terms)) {
1319
            return '';
1320
        }
1321
1322
        // Use the original sanitize() helper function that properly handles
1323
        // Elasticsearch query string escaping
1324
        if (function_exists('sanitize')) {
1325
            return sanitize($terms);
1326
        }
1327
1328
        // Fallback if sanitize function doesn't exist
1329
        // Replace dots with spaces for release name searches
1330
        $terms = str_replace('.', ' ', $terms);
1331
1332
        // Remove multiple consecutive spaces
1333
        $terms = preg_replace('/\s+/', ' ', trim($terms));
1334
1335
        return $terms;
1336
    }
1337
1338
    /**
1339
     * Build a cache key for search results.
1340
     *
1341
     * @param  string  $prefix  Cache key prefix
1342
     * @param  array  $params  Parameters to include in key
1343
     */
1344
    private function buildCacheKey(string $prefix, array $params): string
1345
    {
1346
        return 'es_'.$prefix.'_'.md5(serialize($params));
1347
    }
1348
1349
    /**
1350
     * Build a search query array.
1351
     *
1352
     * @param  string  $index  Index name
1353
     * @param  string  $keywords  Sanitized keywords
1354
     * @param  array  $fields  Fields to search with boosts
1355
     * @param  int  $limit  Maximum results
1356
     * @param  array  $options  Additional query_string options
1357
     * @param  bool  $includeDateSort  Include date sorting
1358
     */
1359
    private function buildSearchQuery(
1360
        string $index,
1361
        string $keywords,
1362
        array $fields,
1363
        int $limit,
1364
        array $options = [],
1365
        bool $includeDateSort = true
1366
    ): array {
1367
        $queryString = array_merge([
1368
            'query' => $keywords,
1369
            'fields' => $fields,
1370
            'analyze_wildcard' => true,
1371
            'default_operator' => 'and',
1372
        ], $options);
1373
1374
        $sort = [['_score' => ['order' => 'desc']]];
1375
1376
        if ($includeDateSort) {
1377
            $sort[] = ['add_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']];
1378
            $sort[] = ['post_date' => ['order' => 'desc', 'unmapped_type' => 'date', 'missing' => '_last']];
1379
        }
1380
1381
        return [
1382
            'scroll' => self::SCROLL_TIMEOUT,
1383
            'index' => $index,
1384
            'body' => [
1385
                'query' => [
1386
                    'query_string' => $queryString,
1387
                ],
1388
                'size' => min($limit, self::MAX_RESULTS),
1389
                'sort' => $sort,
1390
                '_source' => ['id'],
1391
                'track_total_hits' => true,
1392
            ],
1393
        ];
1394
    }
1395
1396
    /**
1397
     * Build a release document for indexing.
1398
     *
1399
     * @param  array  $parameters  Release parameters
1400
     */
1401
    private function buildReleaseDocument(array $parameters): array
1402
    {
1403
        $searchNameDotless = $this->createPlainSearchName($parameters['searchname'] ?? '');
1404
1405
        return [
1406
            'body' => [
1407
                'id' => $parameters['id'],
1408
                'name' => $parameters['name'] ?? '',
1409
                'searchname' => $parameters['searchname'] ?? '',
1410
                'plainsearchname' => $searchNameDotless,
1411
                'fromname' => $parameters['fromname'] ?? '',
1412
                'categories_id' => $parameters['categories_id'] ?? 0,
1413
                'filename' => $parameters['filename'] ?? '',
1414
                'add_date' => now()->format('Y-m-d H:i:s'),
1415
                'post_date' => $parameters['postdate'] ?? now()->format('Y-m-d H:i:s'),
1416
            ],
1417
            'index' => $this->getReleasesIndex(),
1418
            'id' => $parameters['id'],
1419
        ];
1420
    }
1421
1422
    /**
1423
     * Create a plain search name by removing dots and dashes.
1424
     *
1425
     * @param  string  $searchName  Original search name
1426
     */
1427
    private function createPlainSearchName(string $searchName): string
1428
    {
1429
        return str_replace(['.', '-'], ' ', $searchName);
1430
    }
1431
1432
    /**
1433
     * Execute a search with scroll support.
1434
     *
1435
     * @param  array  $search  Search query
1436
     * @param  bool  $fullResults  Return full source documents instead of just IDs
1437
     */
1438
    protected function executeSearch(array $search, bool $fullResults = false): array
1439
    {
1440
        if (empty($search) || ! $this->isElasticsearchAvailable()) {
1441
            return [];
1442
        }
1443
1444
        $scrollId = null;
1445
1446
        try {
1447
            $client = $this->getClient();
1448
1449
            // Log the search query for debugging
1450
            if (config('app.debug')) {
1451
                Log::debug('ElasticSearch executing search', [
1452
                    'index' => $search['index'] ?? 'unknown',
1453
                    'query' => $search['body']['query'] ?? [],
1454
                ]);
1455
            }
1456
1457
            $results = $client->search($search);
1458
1459
            // Log the number of hits
1460
            if (config('app.debug')) {
1461
                $totalHits = $results['hits']['total']['value'] ?? $results['hits']['total'] ?? 0;
1462
                Log::debug('ElasticSearch search results', [
1463
                    'total_hits' => $totalHits,
1464
                    'returned_hits' => count($results['hits']['hits'] ?? []),
1465
                ]);
1466
            }
1467
1468
            $searchResult = [];
1469
1470
            while (isset($results['hits']['hits']) && count($results['hits']['hits']) > 0) {
1471
                foreach ($results['hits']['hits'] as $result) {
1472
                    if ($fullResults) {
1473
                        $searchResult[] = $result['_source'];
1474
                    } else {
1475
                        $searchResult[] = $result['_source']['id'] ?? $result['_id'];
1476
                    }
1477
                }
1478
1479
                // Handle scrolling for large result sets
1480
                if (! isset($results['_scroll_id'])) {
1481
                    break;
1482
                }
1483
1484
                $scrollId = $results['_scroll_id'];
1485
                $results = $client->scroll([
1486
                    'scroll_id' => $scrollId,
1487
                    'scroll' => self::SCROLL_TIMEOUT,
1488
                ]);
1489
            }
1490
1491
            return $searchResult;
1492
1493
        } catch (ElasticsearchException $e) {
1494
            Log::error('ElasticSearch search error: '.$e->getMessage());
1495
1496
            return [];
1497
        } catch (\Throwable $e) {
1498
            Log::error('ElasticSearch search unexpected error: '.$e->getMessage());
1499
1500
            return [];
1501
        } finally {
1502
            // Clean up scroll context
1503
            $this->clearScrollContext($scrollId);
1504
        }
1505
    }
1506
1507
    /**
1508
     * Clear a scroll context to free server resources.
1509
     *
1510
     * @param  string|null  $scrollId  Scroll ID to clear
1511
     */
1512
    private function clearScrollContext(?string $scrollId): void
1513
    {
1514
        if ($scrollId === null) {
1515
            return;
1516
        }
1517
1518
        try {
1519
            $client = $this->getClient();
1520
            $client->clearScroll(['scroll_id' => $scrollId]);
1521
        } catch (\Throwable $e) {
1522
            // Ignore errors when clearing scroll - it's just cleanup
1523
            if (config('app.debug')) {
1524
                Log::debug('Failed to clear scroll context: '.$e->getMessage());
1525
            }
1526
        }
1527
    }
1528
1529
    /**
1530
     * Execute a bulk operation.
1531
     *
1532
     * @param  array  $params  Bulk operation parameters
1533
     */
1534
    private function executeBulk(array $params): void
1535
    {
1536
        if (empty($params['body'])) {
1537
            return;
1538
        }
1539
1540
        try {
1541
            $client = $this->getClient();
1542
            $response = $client->bulk($params);
1543
1544
            if (! empty($response['errors'])) {
1545
                foreach ($response['items'] as $item) {
1546
                    $operation = $item['index'] ?? $item['update'] ?? $item['delete'] ?? [];
1547
                    if (isset($operation['error'])) {
1548
                        Log::error('ElasticSearch bulk operation error', [
1549
                            'id' => $operation['_id'] ?? 'unknown',
1550
                            'error' => $operation['error'],
1551
                        ]);
1552
                    }
1553
                }
1554
            }
1555
        } catch (ElasticsearchException $e) {
1556
            Log::error('ElasticSearch bulk error: '.$e->getMessage());
1557
        } catch (\Throwable $e) {
1558
            Log::error('ElasticSearch bulk unexpected error: '.$e->getMessage());
1559
        }
1560
    }
1561
}
1562
1563