Passed
Push — master ( a79f35...85b490 )
by Darko
11:19
created

ReleaseSearchService::search()   B

Complexity

Conditions 7
Paths 16

Size

Total Lines 87
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 41
c 1
b 0
f 0
dl 0
loc 87
rs 8.3306
cc 7
nc 16
nop 14

How to fix   Long Method    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
<?php
2
3
namespace App\Services\Releases;
4
5
use App\Models\Category;
6
use App\Models\Release;
7
use App\Models\Settings;
8
use App\Models\UsenetGroup;
9
use App\Services\Search\ElasticSearchService;
10
use App\Services\Search\ManticoreSearchService;
11
use Illuminate\Database\Eloquent\Collection;
12
use Illuminate\Support\Arr;
13
use Illuminate\Support\Facades\Cache;
14
use Illuminate\Support\Facades\DB;
15
use Illuminate\Support\Facades\Log;
16
17
/**
18
 * Service for searching releases.
19
 */
20
class ReleaseSearchService
21
{
22
    private const CACHE_VERSION_KEY = 'releases:cache_version';
23
24
    // RAR/ZIP Password indicator.
25
    public const PASSWD_NONE = 0;
26
    public const PASSWD_RAR = 1;
27
28
    private ManticoreSearchService $manticoreSearch;
29
    private ElasticSearchService $elasticSearch;
30
31
    public function __construct(
32
        ManticoreSearchService $manticoreSearch,
33
        ElasticSearchService $elasticSearch
34
    ) {
35
        $this->manticoreSearch = $manticoreSearch;
36
        $this->elasticSearch = $elasticSearch;
37
    }
38
39
    /**
40
     * Function for searching on the site (by subject, searchname or advanced).
41
     *
42
     * @return array|Collection|mixed
43
     */
44
    public function search(
45
        array $searchArr,
46
        $groupName,
47
        $sizeFrom,
48
        $sizeTo,
49
        $daysNew,
50
        $daysOld,
51
        int $offset = 0,
52
        int $limit = 1000,
53
        array|string $orderBy = '',
54
        int $maxAge = -1,
55
        array $excludedCats = [],
56
        string $type = 'basic',
57
        array $cat = [-1],
58
        int $minSize = 0
59
    ): mixed {
60
        if (config('app.debug')) {
61
            Log::debug('ReleaseSearchService::search called', [
62
                'searchArr' => $searchArr,
63
                'limit' => $limit,
64
            ]);
65
        }
66
67
        // Get search results from index
68
        $searchResult = $this->performIndexSearch($searchArr, $limit);
69
70
        if (config('app.debug')) {
71
            Log::debug('ReleaseSearchService::search after performIndexSearch', [
72
                'result_count' => count($searchResult),
73
            ]);
74
        }
75
76
        if (count($searchResult) === 0) {
77
            return collect();
78
        }
79
80
        // Build WHERE clause
81
        $whereSql = $this->buildSearchWhereClause(
82
            $searchResult,
83
            $groupName,
84
            $sizeFrom,
85
            $sizeTo,
86
            $daysNew,
87
            $daysOld,
88
            $maxAge,
89
            $excludedCats,
90
            $type,
91
            $cat,
92
            $minSize
93
        );
94
95
        // Build base SQL
96
        $baseSql = $this->buildSearchBaseSql($whereSql);
97
98
        // Get order by clause
99
        $orderBy = $this->getBrowseOrder($orderBy === '' ? 'posted_desc' : $orderBy);
100
101
        // Build final SQL with pagination
102
        $sql = sprintf(
103
            'SELECT * FROM (%s) r ORDER BY r.%s %s LIMIT %d OFFSET %d',
104
            $baseSql,
105
            $orderBy[0],
106
            $orderBy[1],
107
            $limit,
108
            $offset
109
        );
110
111
        // Check cache
112
        $cacheKey = md5($this->getCacheVersion().$sql);
113
        $releases = Cache::get($cacheKey);
114
        if ($releases !== null) {
115
            return $releases;
116
        }
117
118
        // Execute query
119
        $releases = Release::fromQuery($sql);
120
121
        // Add total count for pagination
122
        if ($releases->isNotEmpty()) {
123
            $releases[0]->_totalrows = $this->getPagerCount($baseSql);
124
        }
125
126
        // Cache results
127
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
128
        Cache::put($cacheKey, $releases, $expiresAt);
129
130
        return $releases;
131
    }
132
133
    /**
134
     * Search function for API.
135
     *
136
     * @return Collection|mixed
137
     */
138
    public function apiSearch($searchName, $groupName, int $offset = 0, int $limit = 1000, int $maxAge = -1, array $excludedCats = [], array $cat = [-1], int $minSize = 0): mixed
139
    {
140
        if (config('app.debug')) {
141
            Log::debug('ReleaseSearchService::apiSearch called', [
142
                'searchName' => $searchName,
143
                'groupName' => $groupName,
144
                'offset' => $offset,
145
                'limit' => $limit,
146
            ]);
147
        }
148
149
        // Early return if searching with no results
150
        $searchResult = [];
151
        if ($searchName !== -1 && $searchName !== '' && $searchName !== null) {
152
            // Try Elasticsearch first if enabled
153
            if (config('nntmux.elasticsearch_enabled') === true) {
154
                $searchResult = $this->elasticSearch->indexSearchApi($searchName, $limit);
155
            }
156
157
            // Fall back to Manticore if Elasticsearch didn't return results
158
            if (empty($searchResult)) {
159
                $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $searchName, ['searchname']);
160
                if (config('app.debug')) {
161
                    Log::debug('apiSearch: Manticore searchIndexes result', [
162
                        'searchName' => $searchName,
163
                        'result' => $searchResult,
164
                    ]);
165
                }
166
                if (! empty($searchResult)) {
167
                    $searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
168
                }
169
            }
170
171
            // Fall back to MySQL if both Elasticsearch and Manticore failed
172
            if (empty($searchResult)) {
173
                if (config('app.debug')) {
174
                    Log::debug('apiSearch: Falling back to MySQL search');
175
                }
176
                $searchResult = $this->performMySQLSearch(['searchname' => $searchName], $limit);
177
            }
178
179
            if (empty($searchResult)) {
180
                if (config('app.debug')) {
181
                    Log::debug('apiSearch: No results from any search engine');
182
                }
183
                return collect();
184
            }
185
        }
186
187
        $conditions = [
188
            sprintf('r.passwordstatus %s', $this->showPasswords()),
189
        ];
190
191
        if ($maxAge > 0) {
192
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
193
        }
194
195
        if ((int) $groupName !== -1) {
196
            $groupId = UsenetGroup::getIDByName($groupName);
197
            if ($groupId) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $groupId of type false|integer is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
198
                $conditions[] = sprintf('r.groups_id = %d', $groupId);
199
            }
200
        }
201
202
        $catQuery = Category::getCategorySearch($cat);
203
        $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
204
        if (! empty($catQuery) && $catQuery !== '1=1') {
205
            $conditions[] = $catQuery;
206
        }
207
208
        if (! empty($excludedCats)) {
209
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCats)));
210
        }
211
212
        if (! empty($searchResult)) {
213
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
214
        }
215
216
        if ($minSize > 0) {
217
            $conditions[] = sprintf('r.size >= %d', $minSize);
218
        }
219
220
        $whereSql = 'WHERE '.implode(' AND ', $conditions);
221
222
        // Optimized query: remove unused columns/joins (haspreview, jpgstatus, *_id columns, nfo/video_data/failures)
223
        $sql = sprintf(
224
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
225
                    cp.title AS parent_category, c.title AS sub_category,
226
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
227
                    g.name AS group_name,
228
                    m.imdbid, m.tmdbid, m.traktid,
229
                    v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
230
                    tve.firstaired, tve.title, tve.series, tve.episode
231
            FROM releases r
232
            INNER JOIN categories c ON c.id = r.categories_id
233
            INNER JOIN root_categories cp ON cp.id = c.root_categories_id
234
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
235
            LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0
236
            LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0
237
            LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0
238
            %s
239
            ORDER BY r.postdate DESC
240
            LIMIT %d OFFSET %d",
241
            $whereSql,
242
            $limit,
243
            $offset
244
        );
245
246
        $cacheKey = md5($sql);
247
        $cachedReleases = Cache::get($cacheKey);
248
        if ($cachedReleases !== null) {
249
            return $cachedReleases;
250
        }
251
252
        $releases = Release::fromQuery($sql);
253
254
        if ($releases->isNotEmpty()) {
255
            $countSql = sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql);
256
            $countResult = Release::fromQuery($countSql);
257
            $releases[0]->_totalrows = $countResult[0]->count ?? 0;
258
        }
259
260
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
261
        Cache::put($cacheKey, $releases, $expiresAt);
262
263
        return $releases;
264
    }
265
266
    /**
267
     * Search for TV shows via API.
268
     *
269
     * @return array|\Illuminate\Cache\|\Illuminate\Database\Eloquent\Collection|\Illuminate\Support\Collection|mixed
270
     */
271
    public function tvSearch(array $siteIdArr = [], string $series = '', string $episode = '', string $airDate = '', int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, int $minSize = 0, array $excludedCategories = []): mixed
272
    {
273
        $shouldCache = ! (isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0);
274
        $rawCacheKey = md5(serialize(func_get_args()).'tvSearch');
275
        $cacheKey = null;
276
        if ($shouldCache) {
277
            $cacheKey = md5($this->getCacheVersion().$rawCacheKey);
278
            $cached = Cache::get($cacheKey);
279
            if ($cached !== null) {
280
                return $cached;
281
            }
282
        }
283
284
        $conditions = [
285
            sprintf('r.passwordstatus %s', $this->showPasswords()),
286
        ];
287
288
        $videoJoinCondition = '';
289
        $episodeJoinCondition = '';
290
        $needsEpisodeJoin = false;
291
292
        if (! empty($siteIdArr)) {
293
            $siteConditions = [];
294
            foreach ($siteIdArr as $column => $id) {
295
                if ($id > 0) {
296
                    $siteConditions[] = sprintf('v.%s = %d', $column, (int) $id);
297
                }
298
            }
299
300
            if (! empty($siteConditions)) {
301
                $siteUsesVideoIdOnly = count($siteConditions) === 1 && isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0;
302
303
                $seriesFilter = ($series !== '') ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : '';
304
                $episodeFilter = ($episode !== '') ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : '';
305
                $airDateFilter = ($airDate !== '') ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '';
306
307
                $lookupSql = sprintf(
308
                    'SELECT v.id AS video_id, tve.id AS episode_id FROM videos v LEFT JOIN tv_episodes tve ON v.id = tve.videos_id WHERE (%s) %s %s %s',
309
                    implode(' OR ', $siteConditions),
310
                    $seriesFilter,
311
                    $episodeFilter,
312
                    $airDateFilter
313
                );
314
315
                $results = Release::fromQuery($lookupSql);
316
317
                if ($results->isEmpty()) {
318
                    return collect();
319
                }
320
321
                $videoIds = $results->pluck('video_id')->filter()->unique()->toArray();
322
                $episodeIds = $results->pluck('episode_id')->filter()->unique()->toArray();
323
324
                if (! empty($videoIds)) {
325
                    $conditions[] = sprintf('r.videos_id IN (%s)', implode(',', array_map('intval', $videoIds)));
326
                    $videoJoinCondition = sprintf('AND v.id IN (%s)', implode(',', array_map('intval', $videoIds)));
327
                }
328
329
                if (! empty($episodeIds) && ! $siteUsesVideoIdOnly) {
330
                    $conditions[] = sprintf('r.tv_episodes_id IN (%s)', implode(',', array_map('intval', $episodeIds)));
331
                    $episodeJoinCondition = sprintf('AND tve.id IN (%s)', implode(',', array_map('intval', $episodeIds)));
332
                    $needsEpisodeJoin = true;
333
                }
334
335
                if ($siteUsesVideoIdOnly) {
336
                    $needsEpisodeJoin = false;
337
                }
338
            }
339
        }
340
341
        $searchResult = [];
342
        if (! empty($name)) {
343
            $searchName = $name;
344
            $hasValidSiteIds = false;
345
            foreach ($siteIdArr as $column => $id) {
346
                if ($id > 0) {
347
                    $hasValidSiteIds = true;
348
                    break;
349
                }
350
            }
351
352
            if (! $hasValidSiteIds) {
353
                if (! empty($series) && (int) $series < 1900) {
354
                    $searchName .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT));
355
                    $seriesNum = (int) preg_replace('/^s0*/i', '', $series);
356
                    $conditions[] = sprintf('tve.series = %d', $seriesNum);
357
                    $needsEpisodeJoin = true;
358
                    if (! empty($episode) && ! str_contains($episode, '/')) {
359
                        $searchName .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT));
360
                        $episodeNum = (int) preg_replace('/^e0*/i', '', $episode);
361
                        $conditions[] = sprintf('tve.episode = %d', $episodeNum);
362
                    }
363
                } elseif (! empty($airDate)) {
364
                    $searchName .= ' '.str_replace(['/', '-', '.', '_'], ' ', $airDate);
365
                    $conditions[] = sprintf('DATE(tve.firstaired) = %s', escapeString($airDate));
366
                    $needsEpisodeJoin = true;
367
                }
368
            }
369
370
            // Try Elasticsearch first if enabled
371
            if (config('nntmux.elasticsearch_enabled') === true) {
372
                $searchResult = $this->elasticSearch->indexSearchTMA($searchName, $limit);
373
            }
374
375
            // Fall back to Manticore if Elasticsearch didn't return results
376
            if (empty($searchResult)) {
377
                $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $searchName, ['searchname']);
378
                if (! empty($searchResult)) {
379
                    $searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
380
                }
381
            }
382
383
            // Fall back to MySQL if both Elasticsearch and Manticore failed
384
            if (empty($searchResult)) {
385
                $searchResult = $this->performMySQLSearch(['searchname' => $searchName], $limit);
386
            }
387
388
            if (empty($searchResult)) {
389
                return collect();
390
            }
391
392
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
393
        }
394
395
        $catQuery = Category::getCategorySearch($cat, 'tv');
396
        $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
397
        if (! empty($catQuery) && $catQuery !== '1=1') {
398
            $conditions[] = $catQuery;
399
        }
400
401
        if ($maxAge > 0) {
402
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
403
        }
404
        if ($minSize > 0) {
405
            $conditions[] = sprintf('r.size >= %d', $minSize);
406
        }
407
        if (! empty($excludedCategories)) {
408
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories)));
409
        }
410
411
        $whereSql = 'WHERE '.implode(' AND ', $conditions);
412
413
        $joinType = $needsEpisodeJoin ? 'INNER' : 'LEFT';
414
415
        // Optimized select list – only fields required by XML (extended) and transformers
416
        $baseSql = sprintf(
417
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id,
418
                    r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments,
419
                    r.adddate, r.videos_id, r.tv_episodes_id,
420
                    v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage,
421
                    tve.series, tve.episode, tve.firstaired,
422
                    cp.title AS parent_category, c.title AS sub_category,
423
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
424
                    g.name AS group_name
425
            FROM releases r
426
            INNER JOIN categories c ON c.id = r.categories_id
427
            INNER JOIN root_categories cp ON cp.id = c.root_categories_id
428
            LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0 %s
429
            %s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s
430
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
431
            %s",
432
            $videoJoinCondition,
433
            $joinType,
434
            $episodeJoinCondition,
435
            $whereSql
436
        );
437
438
        $limitClause = '';
439
        if ($limit > 0) {
440
            $limitClause = sprintf(' LIMIT %d OFFSET %d', $limit, $offset);
441
        }
442
443
        $sql = sprintf('%s ORDER BY r.postdate DESC%s', $baseSql, $limitClause);
444
        $releases = Release::fromQuery($sql);
445
446
        if ($releases->isNotEmpty()) {
447
            $countSql = sprintf(
448
                'SELECT COUNT(*) as count FROM releases r %s %s %s',
449
                (! empty($videoJoinCondition) ? 'LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0' : ''),
450
                ($needsEpisodeJoin ? sprintf('%s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s', $joinType, $episodeJoinCondition) : ''),
451
                $whereSql
452
            );
453
            $countResult = Release::fromQuery($countSql);
454
            $releases[0]->_totalrows = $countResult[0]->count ?? 0;
455
        }
456
457
        if ($shouldCache && $cacheKey !== null) {
458
            $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
459
            Cache::put($cacheKey, $releases, $expiresAt);
460
        }
461
462
        return $releases;
463
    }
464
465
    /**
466
     * Search TV Shows via APIv2.
467
     *
468
     * @return Collection|mixed
469
     */
470
    public function apiTvSearch(array $siteIdArr = [], string $series = '', string $episode = '', string $airDate = '', int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, int $minSize = 0, array $excludedCategories = []): mixed
471
    {
472
        $siteSQL = [];
473
        $showSql = '';
474
        foreach ($siteIdArr as $column => $Id) {
475
            if ($Id > 0) {
476
                $siteSQL[] = sprintf('v.%s = %d', $column, $Id);
477
            }
478
        }
479
480
        if (\count($siteSQL) > 0) {
481
            $showQry = sprintf(
482
                "\n\t\t\t\tSELECT v.id AS video, GROUP_CONCAT(tve.id SEPARATOR ',') AS episodes FROM videos v LEFT JOIN tv_episodes tve ON v.id = tve.videos_id WHERE (%s) %s %s %s GROUP BY v.id LIMIT 1",
483
                implode(' OR ', $siteSQL),
484
                ($series !== '' ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''),
485
                ($episode !== '' ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''),
486
                ($airDate !== '' ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '')
487
            );
488
            $show = Release::fromQuery($showQry);
489
            if ($show->isNotEmpty()) {
490
                if ((! empty($episode) && ! empty($series)) && $show[0]->episodes !== '') {
491
                    $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series;
492
                } elseif (! empty($episode) && $show[0]->episodes !== '') {
493
                    $showSql = sprintf('AND r.tv_episodes_id IN (%s)', $show[0]->episodes);
494
                } elseif (! empty($series) && empty($episode)) {
495
                    $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series;
496
                }
497
                if ($show[0]->video > 0) {
498
                    $showSql .= ' AND r.videos_id = '.$show[0]->video;
499
                }
500
            } else {
501
                return [];
502
            }
503
        }
504
        if (! empty($name) && $showSql === '') {
505
            if (! empty($series) && (int) $series < 1900) {
506
                $name .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT));
507
                if (! empty($episode) && ! str_contains($episode, '/')) {
508
                    $name .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT));
509
                }
510
                if (empty($episode)) {
511
                    $name .= '*';
512
                }
513
            } elseif (! empty($airDate)) {
514
                $name .= sprintf(' %s', str_replace(['/', '-', '.', '_'], ' ', $airDate));
515
            }
516
        }
517
        $searchResult = [];
518
        if (! empty($name)) {
519
            // Try Elasticsearch first if enabled
520
            if (config('nntmux.elasticsearch_enabled') === true) {
521
                $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit);
522
            }
523
524
            // Fall back to Manticore if Elasticsearch didn't return results
525
            if (empty($searchResult)) {
526
                $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']);
527
                if (! empty($searchResult)) {
528
                    $searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
529
                }
530
            }
531
532
            // Fall back to MySQL if both Elasticsearch and Manticore failed
533
            if (empty($searchResult)) {
534
                $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit);
535
            }
536
537
            if (count($searchResult) === 0) {
538
                return collect();
539
            }
540
        }
541
        $whereSql = sprintf(
542
            'WHERE r.passwordstatus %s %s %s %s %s %s %s',
543
            $this->showPasswords(),
544
            $showSql,
545
            (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''),
546
            Category::getCategorySearch($cat, 'tv'),
547
            ($maxAge > 0 ? sprintf('AND r.postdate > NOW() - INTERVAL %d DAY', $maxAge) : ''),
548
            ($minSize > 0 ? sprintf('AND r.size >= %d', $minSize) : ''),
549
            ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : ''
550
        );
551
        $baseSql = sprintf(
552
            "SELECT r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
553
                r.tv_episodes_id, v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage,
554
                tve.series, tve.episode, tve.firstaired, cp.title AS parent_category, c.title AS sub_category,
555
                CONCAT(cp.title, ' > ', c.title) AS category_name, g.name AS group_name
556
            FROM releases r
557
            LEFT OUTER JOIN videos v ON r.videos_id = v.id AND v.type = 0
558
            LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id
559
            LEFT JOIN categories c ON c.id = r.categories_id
560
            LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
561
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
562
            %s",
563
            $whereSql
564
        );
565
        $sql = sprintf('%s ORDER BY postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset);
566
        $releases = Cache::get(md5($sql));
567
        if ($releases !== null) {
568
            return $releases;
569
        }
570
        $releases = Release::fromQuery($sql);
571
        if ($releases->isNotEmpty()) {
572
            $releases[0]->_totalrows = $this->getPagerCount(
573
                preg_replace('#LEFT(\s+OUTER)?\s+JOIN\s+(?!tv_episodes)\s+.*ON.*=.*\n#i', ' ', $baseSql)
574
            );
575
        }
576
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
577
        Cache::put(md5($sql), $releases, $expiresAt);
578
579
        return $releases;
580
    }
581
582
    /**
583
     * Search anime releases.
584
     *
585
     * @return Collection|mixed
586
     */
587
    public function animeSearch($aniDbID, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, array $excludedCategories = []): mixed
588
    {
589
        $searchResult = [];
590
        if (! empty($name)) {
591
            // Try Elasticsearch first if enabled
592
            if (config('nntmux.elasticsearch_enabled') === true) {
593
                $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit);
594
            }
595
596
            // Fall back to Manticore if Elasticsearch didn't return results
597
            if (empty($searchResult)) {
598
                $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']);
599
                if (! empty($searchResult)) {
600
                    $searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
601
                }
602
            }
603
604
            // Fall back to MySQL if both Elasticsearch and Manticore failed
605
            if (empty($searchResult)) {
606
                $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit);
607
            }
608
609
            if (count($searchResult) === 0) {
610
                return collect();
611
            }
612
        }
613
614
        $whereSql = sprintf(
615
            'WHERE r.passwordstatus %s
616
			%s %s %s %s %s',
617
            $this->showPasswords(),
618
            ($aniDbID > -1 ? sprintf(' AND r.anidbid = %d ', $aniDbID) : ''),
619
            (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''),
620
            ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : '',
621
            Category::getCategorySearch($cat),
622
            ($maxAge > 0 ? sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge) : '')
623
        );
624
        $baseSql = sprintf(
625
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, r.haspreview, r.jpgstatus,  cp.title AS parent_category, c.title AS sub_category,
626
				CONCAT(cp.title, ' > ', c.title) AS category_name,
627
				g.name AS group_name,
628
				rn.releases_id AS nfoid
629
			FROM releases r
630
			LEFT JOIN categories c ON c.id = r.categories_id
631
			LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
632
			LEFT JOIN usenet_groups g ON g.id = r.groups_id
633
			LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id
634
			%s",
635
            $whereSql
636
        );
637
        $sql = sprintf(
638
            '%s
639
			ORDER BY postdate DESC
640
			LIMIT %d OFFSET %d',
641
            $baseSql,
642
            $limit,
643
            $offset
644
        );
645
        $releases = Cache::get(md5($sql));
646
        if ($releases !== null) {
647
            return $releases;
648
        }
649
        $releases = Release::fromQuery($sql);
650
        if ($releases->isNotEmpty()) {
651
            $releases[0]->_totalrows = $this->getPagerCount($baseSql);
652
        }
653
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
654
        Cache::put(md5($sql), $releases, $expiresAt);
655
656
        return $releases;
657
    }
658
659
    /**
660
     * Movies search through API and site.
661
     *
662
     * @return Collection|mixed
663
     */
664
    public function moviesSearch(int $imDbId = -1, int $tmDbId = -1, int $traktId = -1, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, int $minSize = 0, array $excludedCategories = []): mixed
665
    {
666
        // Early return if searching by name yields no results
667
        $searchResult = [];
668
        if (! empty($name)) {
669
            // Try Elasticsearch first if enabled
670
            if (config('nntmux.elasticsearch_enabled') === true) {
671
                $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit);
672
            }
673
674
            // Fall back to Manticore if Elasticsearch didn't return results
675
            if (empty($searchResult)) {
676
                $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']);
677
                if (! empty($searchResult)) {
678
                    $searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
679
                }
680
            }
681
682
            // Fall back to MySQL if both Elasticsearch and Manticore failed
683
            if (empty($searchResult)) {
684
                $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit);
685
            }
686
687
            if (empty($searchResult)) {
688
                return collect();
689
            }
690
        }
691
692
        $conditions = [
693
            sprintf('r.categories_id BETWEEN %d AND %d', Category::MOVIE_ROOT, Category::MOVIE_OTHER),
694
            sprintf('r.passwordstatus %s', $this->showPasswords()),
695
        ];
696
697
        if (! empty($searchResult)) {
698
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
699
        }
700
701
        // Optimize: Join on imdbid directly (both tables have indexed imdbid columns)
702
        // This is more efficient than joining through movieinfo_id
703
        // Always join movieinfo to get imdbid, tmdbid, and traktid fields consistently
704
705
        if ($imDbId !== -1 && $imDbId) {
706
            // Filter on r.imdbid (uses index ix_releases_imdbid)
707
            // The join on m.imdbid = r.imdbid will also use the index
708
            $conditions[] = sprintf('r.imdbid = %d', $imDbId);
709
        }
710
711
        if ($tmDbId !== -1 && $tmDbId) {
712
            $conditions[] = sprintf('m.tmdbid = %d', $tmDbId);
713
        }
714
715
        if ($traktId !== -1 && $traktId) {
716
            $conditions[] = sprintf('m.traktid = %d', $traktId);
717
        }
718
719
        if (! empty($excludedCategories)) {
720
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories)));
721
        }
722
723
        $catQuery = Category::getCategorySearch($cat, 'movies');
724
        $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
725
        if (! empty($catQuery) && $catQuery !== '1=1') {
726
            $conditions[] = $catQuery;
727
        }
728
        if ($maxAge > 0) {
729
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
730
        }
731
        if ($minSize > 0) {
732
            $conditions[] = sprintf('r.size >= %d', $minSize);
733
        }
734
735
        $whereSql = 'WHERE '.implode(' AND ', $conditions);
736
        // Always join on imdbid directly (both tables have indexed imdbid) - more efficient than movieinfo_id
737
        // Both ix_releases_imdbid and ix_movieinfo_imdbid are indexed, making this join very fast
738
        // This ensures we always get imdbid, tmdbid, and traktid fields in results
739
        $joinSql = 'INNER JOIN movieinfo m ON m.imdbid = r.imdbid';
740
741
        // Select only fields required by XML/API transformers
742
        $baseSql = sprintf(
743
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id,
744
                    r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments,
745
                    r.adddate,
746
                    %s
747
                    cp.title AS parent_category, c.title AS sub_category,
748
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
749
                    g.name AS group_name
750
             FROM releases r
751
             INNER JOIN categories c ON c.id = r.categories_id
752
             INNER JOIN root_categories cp ON cp.id = c.root_categories_id
753
             %s
754
             LEFT JOIN usenet_groups g ON g.id = r.groups_id
755
             %s",
756
            'm.imdbid, m.tmdbid, m.traktid,',
757
            $joinSql,
758
            $whereSql
759
        );
760
761
        $sql = sprintf('%s ORDER BY r.postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset);
762
        $cacheKey = md5($sql.serialize(func_get_args()));
763
        if (($releases = Cache::get($cacheKey)) !== null) {
764
            return $releases;
765
        }
766
767
        $releases = Release::fromQuery($sql);
768
769
        if ($releases->isNotEmpty()) {
770
            // Optimize: Execute count query using same WHERE clause (uses same indexes)
771
            // The count query is lightweight and can use index-only scans when possible
772
            // Use same join logic as main query (join on imdbid when needed)
773
            $countSql = sprintf(
774
                'SELECT COUNT(*) as count FROM releases r %s %s',
775
                $joinSql,
776
                $whereSql
777
            );
778
            $countResult = DB::selectOne($countSql);
779
            $releases[0]->_totalrows = $countResult->count ?? 0;
780
        }
781
782
        Cache::put($cacheKey, $releases, now()->addMinutes(config('nntmux.cache_expiry_medium')));
783
784
        return $releases;
785
    }
786
787
    public function searchSimilar($currentID, $name, array $excludedCats = []): bool|array
788
    {
789
        // Get the category for the parent of this release.
790
        $ret = false;
791
        $currRow = Release::getCatByRelId($currentID);
792
        if ($currRow !== null) {
793
            $catRow = Category::find($currRow['categories_id']);
794
            $parentCat = $catRow !== null ? $catRow['root_categories_id'] : null;
795
796
            if ($parentCat === null) {
797
                return $ret;
798
            }
799
800
            $results = $this->search(['searchname' => getSimilarName($name)], -1, '', '', -1, -1, 0, config('nntmux.items_per_page'), '', -1, $excludedCats, 'basic', [$parentCat]);
801
            if (! $results) {
802
                return $ret;
803
            }
804
805
            $ret = [];
806
            foreach ($results as $res) {
807
                if ($res['id'] !== $currentID && $res['categoryparentid'] === $parentCat) {
808
                    $ret[] = $res;
809
                }
810
            }
811
        }
812
813
        return $ret;
814
    }
815
816
    /**
817
     * Perform index search using Elasticsearch or Manticore, with MySQL fallback
818
     */
819
    private function performIndexSearch(array $searchArr, int $limit): array
820
    {
821
        // Filter out -1 values and empty strings
822
        $searchFields = Arr::where($searchArr, static function ($value) {
823
            return $value !== -1 && $value !== '' && $value !== null;
824
        });
825
826
        if (empty($searchFields)) {
827
            if (config('app.debug')) {
828
                Log::debug('performIndexSearch: searchFields is empty after filtering', [
829
                    'original' => $searchArr,
830
                ]);
831
            }
832
            return [];
833
        }
834
835
        $phrases = array_values($searchFields);
836
837
        $esEnabled = config('nntmux.elasticsearch_enabled');
838
        if (config('app.debug')) {
839
            Log::debug('performIndexSearch: starting search', [
840
                'elasticsearch_enabled' => $esEnabled,
841
                'elasticsearch_enabled_type' => gettype($esEnabled),
842
                'searchFields' => $searchFields,
843
                'phrases' => $phrases,
844
                'limit' => $limit,
845
            ]);
846
        }
847
848
        // Try Elasticsearch first if enabled
849
        if ($esEnabled === true) {
850
            $result = $this->elasticSearch->indexSearch($phrases, $limit);
851
            if (config('app.debug')) {
852
                Log::debug('performIndexSearch: Elasticsearch result count', ['count' => count($result)]);
853
            }
854
            // If Elasticsearch returned results, use them
855
            if (!empty($result)) {
856
                return $result;
857
            }
858
            // Otherwise fall through to Manticore
859
            if (config('app.debug')) {
860
                Log::debug('performIndexSearch: Elasticsearch returned empty, falling back to Manticore');
861
            }
862
        }
863
864
        // Try Manticore search
865
        $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', '', [], $searchFields);
866
867
        if (config('app.debug')) {
868
            Log::debug('performIndexSearch: Manticore result', [
869
                'result' => $searchResult,
870
            ]);
871
        }
872
873
        if (!empty($searchResult) && !empty($searchResult['id'])) {
874
            return Arr::wrap(Arr::get($searchResult, 'id'));
875
        }
876
877
        // Fallback to MySQL LIKE search when both Elasticsearch and Manticore are unavailable
878
        if (config('app.debug')) {
879
            Log::debug('performIndexSearch: Falling back to MySQL search');
880
        }
881
882
        return $this->performMySQLSearch($searchFields, $limit);
883
    }
884
885
    /**
886
     * Fallback MySQL search when full-text search engines are unavailable
887
     */
888
    private function performMySQLSearch(array $searchFields, int $limit): array
889
    {
890
        try {
891
            $query = Release::query()->select('id');
892
893
            foreach ($searchFields as $field => $value) {
894
                if (!empty($value)) {
895
                    // Split search terms and search for each
896
                    $terms = preg_split('/\s+/', trim($value));
897
                    foreach ($terms as $term) {
898
                        $term = trim($term);
899
                        if (strlen($term) >= 2) {
900
                            $query->where($field, 'LIKE', '%' . $term . '%');
901
                        }
902
                    }
903
                }
904
            }
905
906
            $results = $query->limit($limit)->pluck('id')->toArray();
907
908
            if (config('app.debug')) {
909
                Log::debug('performMySQLSearch: MySQL fallback result count', ['count' => count($results)]);
910
            }
911
912
            return $results;
913
        } catch (\Throwable $e) {
914
            Log::error('performMySQLSearch: MySQL fallback failed', [
915
                'error' => $e->getMessage(),
916
            ]);
917
            return [];
918
        }
919
    }
920
921
    /**
922
     * Build WHERE clause for search query
923
     */
924
    private function buildSearchWhereClause(
925
        array $searchResult,
926
        $groupName,
927
        $sizeFrom,
928
        $sizeTo,
929
        $daysNew,
930
        $daysOld,
931
        int $maxAge,
932
        array $excludedCats,
933
        string $type,
934
        array $cat,
935
        int $minSize
936
    ): string {
937
        $conditions = [
938
            sprintf('r.passwordstatus %s', $this->showPasswords()),
939
            sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))),
940
        ];
941
942
        // Add optional conditions
943
        if ($maxAge > 0) {
944
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
945
        }
946
947
        if ((int) $groupName !== -1) {
948
            $groupId = UsenetGroup::getIDByName($groupName);
949
            if ($groupId) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $groupId of type false|integer is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
950
                $conditions[] = sprintf('r.groups_id = %d', $groupId);
951
            }
952
        }
953
954
        // Size conditions
955
        $sizeConditions = $this->buildSizeConditions($sizeFrom, $sizeTo);
956
        if (! empty($sizeConditions)) {
957
            $conditions = array_merge($conditions, $sizeConditions);
958
        }
959
960
        if ($minSize > 0) {
961
            $conditions[] = sprintf('r.size >= %d', $minSize);
962
        }
963
964
        // Category conditions - only add if not empty
965
        $catQuery = $this->buildCategoryCondition($type, $cat);
966
        if (! empty($catQuery) && $catQuery !== '1=1') {
967
            $conditions[] = $catQuery;
968
        }
969
970
        // Date conditions
971
        if ((int) $daysNew !== -1) {
972
            $conditions[] = sprintf('r.postdate < (NOW() - INTERVAL %d DAY)', $daysNew);
973
        }
974
975
        if ((int) $daysOld !== -1) {
976
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $daysOld);
977
        }
978
979
        // Excluded categories
980
        if (! empty($excludedCats)) {
981
            $excludedCatsClean = array_map('intval', $excludedCats);
982
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', $excludedCatsClean));
983
        }
984
985
        return 'WHERE '.implode(' AND ', $conditions);
986
    }
987
988
    /**
989
     * Build size conditions for WHERE clause
990
     */
991
    private function buildSizeConditions($sizeFrom, $sizeTo): array
992
    {
993
        $sizeRange = [
994
            1 => 1,
995
            2 => 2.5,
996
            3 => 5,
997
            4 => 10,
998
            5 => 20,
999
            6 => 30,
1000
            7 => 40,
1001
            8 => 80,
1002
            9 => 160,
1003
            10 => 320,
1004
            11 => 640,
1005
        ];
1006
1007
        $conditions = [];
1008
1009
        if (array_key_exists($sizeFrom, $sizeRange)) {
1010
            $conditions[] = sprintf('r.size > %d', 104857600 * (int) $sizeRange[$sizeFrom]);
1011
        }
1012
1013
        if (array_key_exists($sizeTo, $sizeRange)) {
1014
            $conditions[] = sprintf('r.size < %d', 104857600 * (int) $sizeRange[$sizeTo]);
1015
        }
1016
1017
        return $conditions;
1018
    }
1019
1020
    /**
1021
     * Build category condition based on search type
1022
     */
1023
    private function buildCategoryCondition(string $type, array $cat): string
1024
    {
1025
        if ($type === 'basic') {
1026
            $catSearch = Category::getCategorySearch($cat);
1027
            // Remove WHERE and AND from the beginning as we're building it into a larger WHERE clause
1028
            $catSearch = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catSearch));
1029
1030
            // Don't return '1=1' as it's not needed
1031
            return ($catSearch === '1=1') ? '' : $catSearch;
1032
        }
1033
1034
        if ($type === 'advanced' && (int) $cat[0] !== -1) {
1035
            return sprintf('r.categories_id = %d', (int) $cat[0]);
1036
        }
1037
1038
        return '';
1039
    }
1040
1041
    /**
1042
     * Build base SQL for search query
1043
     */
1044
    private function buildSearchBaseSql(string $whereSql): string
1045
    {
1046
        return sprintf(
1047
            "SELECT r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id, r.size,
1048
                    r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
1049
                    r.videos_id, r.tv_episodes_id, r.haspreview, r.jpgstatus,
1050
                    cp.title AS parent_category, c.title AS sub_category,
1051
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
1052
                    df.failed AS failed,
1053
                    g.name AS group_name,
1054
                    rn.releases_id AS nfoid,
1055
                    re.releases_id AS reid,
1056
                    cp.id AS categoryparentid,
1057
                    v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
1058
                    tve.firstaired
1059
            FROM releases r
1060
            LEFT OUTER JOIN video_data re ON re.releases_id = r.id
1061
            LEFT OUTER JOIN videos v ON r.videos_id = v.id
1062
            LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id
1063
            LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id
1064
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
1065
            LEFT JOIN categories c ON c.id = r.categories_id
1066
            LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
1067
            LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id
1068
            %s",
1069
            $whereSql
1070
        );
1071
    }
1072
1073
    /**
1074
     * Get the passworded releases clause.
1075
     */
1076
    public function showPasswords(): string
1077
    {
1078
        $show = (int) Settings::settingValue('showpasswordedrelease');
1079
        $setting = $show ?? 0;
1080
1081
        return match ($setting) {
1082
            1 => '<= '.self::PASSWD_RAR,
1083
            default => '= '.self::PASSWD_NONE,
1084
        };
1085
    }
1086
1087
    /**
1088
     * Use to order releases on site.
1089
     */
1090
    public function getBrowseOrder(array|string $orderBy): array
1091
    {
1092
        $orderArr = explode('_', ($orderBy === '' ? 'posted_desc' : $orderBy));
0 ignored issues
show
introduced by
The condition $orderBy === '' is always false.
Loading history...
Bug introduced by
$orderBy === '' ? 'posted_desc' : $orderBy of type array is incompatible with the type string expected by parameter $string of explode(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1092
        $orderArr = explode('_', /** @scrutinizer ignore-type */ ($orderBy === '' ? 'posted_desc' : $orderBy));
Loading history...
1093
        $orderField = match ($orderArr[0]) {
1094
            'cat' => 'categories_id',
1095
            'name' => 'searchname',
1096
            'size' => 'size',
1097
            'files' => 'totalpart',
1098
            'stats' => 'grabs',
1099
            default => 'postdate',
1100
        };
1101
1102
        return [$orderField, isset($orderArr[1]) && preg_match('/^(asc|desc)$/i', $orderArr[1]) ? $orderArr[1] : 'desc'];
1103
    }
1104
1105
    private function getCacheVersion(): int
1106
    {
1107
        return Cache::get(self::CACHE_VERSION_KEY, 1);
1108
    }
1109
1110
    /**
1111
     * Get the count of releases for pager.
1112
     *
1113
     * @param  string  $query  The query to get the count from.
1114
     */
1115
    private function getPagerCount(string $query): int
1116
    {
1117
        $maxResults = (int) config('nntmux.max_pager_results');
1118
        $cacheExpiry = config('nntmux.cache_expiry_short');
1119
1120
        // Generate cache key from original query
1121
        $cacheKey = 'pager_count_'.md5($query);
1122
1123
        // Check cache first
1124
        $count = Cache::get($cacheKey);
1125
        if ($count !== null) {
1126
            return (int) $count;
1127
        }
1128
1129
        // Check if this is already a COUNT query
1130
        if (preg_match('/SELECT\s+COUNT\s*\(/is', $query)) {
1131
            // It's already a COUNT query, just execute it
1132
            try {
1133
                $result = DB::select($query);
1134
                if (isset($result[0])) {
1135
                    // Handle different possible column names
1136
                    $count = $result[0]->count ?? $result[0]->total ?? 0;
1137
                    // Check for COUNT(*) result without alias
1138
                    if ($count === 0) {
1139
                        foreach ($result[0] as $value) {
1140
                            $count = (int) $value;
1141
                            break;
1142
                        }
1143
                    }
1144
                } else {
1145
                    $count = 0;
1146
                }
1147
1148
                // Cap the count at max results if applicable
1149
                if ($maxResults > 0 && $count > $maxResults) {
1150
                    $count = $maxResults;
1151
                }
1152
1153
                // Cache the result
1154
                Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry));
1155
1156
                return $count;
1157
            } catch (\Exception $e) {
1158
                return 0;
1159
            }
1160
        }
1161
1162
        // For regular SELECT queries, optimize for counting
1163
        $countQuery = $query;
1164
1165
        // Remove ORDER BY clause (not needed for COUNT)
1166
        $countQuery = preg_replace('/ORDER\s+BY\s+[^)]+$/is', '', $countQuery);
1167
1168
        // Remove GROUP BY if it's only grouping by r.id
1169
        $countQuery = preg_replace('/GROUP\s+BY\s+r\.id\s*$/is', '', $countQuery);
1170
1171
        // Check if query has DISTINCT in SELECT
1172
        $hasDistinct = preg_match('/SELECT\s+DISTINCT/is', $countQuery);
1173
1174
        // Replace SELECT clause with COUNT
1175
        if ($hasDistinct || preg_match('/GROUP\s+BY/is', $countQuery)) {
1176
            // For queries with DISTINCT or GROUP BY, count distinct r.id
1177
            $countQuery = preg_replace(
1178
                '/SELECT\s+.+?\s+FROM/is',
1179
                'SELECT COUNT(DISTINCT r.id) as count FROM',
1180
                $countQuery
1181
            );
1182
        } else {
1183
            // For simple queries, use COUNT(*)
1184
            $countQuery = preg_replace(
1185
                '/SELECT\s+.+?\s+FROM/is',
1186
                'SELECT COUNT(*) as count FROM',
1187
                $countQuery
1188
            );
1189
        }
1190
1191
        // Remove LIMIT/OFFSET from the count query
1192
        $countQuery = preg_replace('/LIMIT\s+\d+(\s+OFFSET\s+\d+)?$/is', '', $countQuery);
1193
1194
        try {
1195
            // If max results is set and query might return too many results
1196
            if ($maxResults > 0) {
1197
                // First check if count would exceed max
1198
                $testQuery = sprintf('SELECT 1 FROM (%s) as test LIMIT %d',
1199
                    preg_replace('/SELECT\s+COUNT.+?\s+FROM/is', 'SELECT 1 FROM', $countQuery),
1200
                    $maxResults + 1
1201
                );
1202
1203
                $testResult = DB::select($testQuery);
1204
                if (count($testResult) > $maxResults) {
1205
                    Cache::put($cacheKey, $maxResults, now()->addMinutes($cacheExpiry));
1206
1207
                    return $maxResults;
1208
                }
1209
            }
1210
1211
            // Execute the count query
1212
            $result = DB::select($countQuery);
1213
            $count = isset($result[0]) ? (int) $result[0]->count : 0;
1214
1215
            // Cache the result
1216
            Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry));
1217
1218
            return $count;
1219
        } catch (\Exception $e) {
1220
            // If optimization fails, try a simpler approach
1221
            try {
1222
                // Extract the core table and WHERE conditions
1223
                if (preg_match('/FROM\s+releases\s+r\s+(.+?)(?:ORDER\s+BY|LIMIT|$)/is', $query, $matches)) {
1224
                    $conditions = $matches[1];
1225
                    // Remove JOINs but keep WHERE
1226
                    $conditions = preg_replace('/(?:LEFT\s+|INNER\s+)?(?:OUTER\s+)?JOIN\s+.+?(?=WHERE|LEFT|INNER|JOIN|$)/is', '', $conditions);
1227
1228
                    $fallbackQuery = sprintf('SELECT COUNT(*) as count FROM releases r %s', trim($conditions));
1229
1230
                    if ($maxResults > 0) {
1231
                        $fallbackQuery = sprintf('SELECT COUNT(*) as count FROM (SELECT 1 FROM releases r %s LIMIT %d) as limited',
1232
                            trim($conditions),
1233
                            $maxResults
1234
                        );
1235
                    }
1236
1237
                    $result = DB::select($fallbackQuery);
1238
                    $count = isset($result[0]) ? (int) $result[0]->count : 0;
1239
1240
                    Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry));
1241
1242
                    return $count;
1243
                }
1244
            } catch (\Exception $fallbackException) {
1245
                // Log the error for debugging
1246
                Log::error('getPagerCount failed', [
1247
                    'query' => $query,
1248
                    'error' => $fallbackException->getMessage(),
1249
                ]);
1250
            }
1251
1252
            return 0;
1253
        }
1254
    }
1255
}
1256
1257