ElasticSearchService   F
last analyzed

Complexity

Total Complexity 186

Size/Duplication

Total Lines 1402
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 186
eloc 638
c 1
b 0
f 0
dl 0
loc 1402
rs 1.962

36 Methods

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

How to fix   Complexity   

Complex Class

Complex classes like ElasticSearchService 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 ElasticSearchService, and based on these observations, apply Extract Interface, too.

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