ReleaseSearchService::buildCategoryCondition()   A
last analyzed

Complexity

Conditions 5
Paths 4

Size

Total Lines 16
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 7
c 1
b 0
f 0
dl 0
loc 16
rs 9.6111
cc 5
nc 4
nop 2
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 (only if enabled)
172
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
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 (only if enabled)
384
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
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 (only if enabled)
533
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
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 (only if enabled)
605
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
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 (only if enabled)
683
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
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 (only if enabled)
878
        if (config('nntmux.mysql_search_fallback', false) === true) {
879
            if (config('app.debug')) {
880
                Log::debug('performIndexSearch: Falling back to MySQL search');
881
            }
882
883
            return $this->performMySQLSearch($searchFields, $limit);
884
        }
885
886
        return [];
887
    }
888
889
    /**
890
     * Fallback MySQL search when full-text search engines are unavailable
891
     */
892
    private function performMySQLSearch(array $searchFields, int $limit): array
893
    {
894
        try {
895
            $query = Release::query()->select('id');
896
897
            foreach ($searchFields as $field => $value) {
898
                if (!empty($value)) {
899
                    // Split search terms and search for each
900
                    $terms = preg_split('/\s+/', trim($value));
901
                    foreach ($terms as $term) {
902
                        $term = trim($term);
903
                        if (strlen($term) >= 2) {
904
                            $query->where($field, 'LIKE', '%' . $term . '%');
905
                        }
906
                    }
907
                }
908
            }
909
910
            $results = $query->limit($limit)->pluck('id')->toArray();
911
912
            if (config('app.debug')) {
913
                Log::debug('performMySQLSearch: MySQL fallback result count', ['count' => count($results)]);
914
            }
915
916
            return $results;
917
        } catch (\Throwable $e) {
918
            Log::error('performMySQLSearch: MySQL fallback failed', [
919
                'error' => $e->getMessage(),
920
            ]);
921
            return [];
922
        }
923
    }
924
925
    /**
926
     * Build WHERE clause for search query
927
     */
928
    private function buildSearchWhereClause(
929
        array $searchResult,
930
        $groupName,
931
        $sizeFrom,
932
        $sizeTo,
933
        $daysNew,
934
        $daysOld,
935
        int $maxAge,
936
        array $excludedCats,
937
        string $type,
938
        array $cat,
939
        int $minSize
940
    ): string {
941
        $conditions = [
942
            sprintf('r.passwordstatus %s', $this->showPasswords()),
943
            sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))),
944
        ];
945
946
        // Add optional conditions
947
        if ($maxAge > 0) {
948
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
949
        }
950
951
        if ((int) $groupName !== -1) {
952
            $groupId = UsenetGroup::getIDByName($groupName);
953
            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...
954
                $conditions[] = sprintf('r.groups_id = %d', $groupId);
955
            }
956
        }
957
958
        // Size conditions
959
        $sizeConditions = $this->buildSizeConditions($sizeFrom, $sizeTo);
960
        if (! empty($sizeConditions)) {
961
            $conditions = array_merge($conditions, $sizeConditions);
962
        }
963
964
        if ($minSize > 0) {
965
            $conditions[] = sprintf('r.size >= %d', $minSize);
966
        }
967
968
        // Category conditions - only add if not empty
969
        $catQuery = $this->buildCategoryCondition($type, $cat);
970
        if (! empty($catQuery) && $catQuery !== '1=1') {
971
            $conditions[] = $catQuery;
972
        }
973
974
        // Date conditions
975
        if ((int) $daysNew !== -1) {
976
            $conditions[] = sprintf('r.postdate < (NOW() - INTERVAL %d DAY)', $daysNew);
977
        }
978
979
        if ((int) $daysOld !== -1) {
980
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $daysOld);
981
        }
982
983
        // Excluded categories
984
        if (! empty($excludedCats)) {
985
            $excludedCatsClean = array_map('intval', $excludedCats);
986
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', $excludedCatsClean));
987
        }
988
989
        return 'WHERE '.implode(' AND ', $conditions);
990
    }
991
992
    /**
993
     * Build size conditions for WHERE clause
994
     */
995
    private function buildSizeConditions($sizeFrom, $sizeTo): array
996
    {
997
        $sizeRange = [
998
            1 => 1,
999
            2 => 2.5,
1000
            3 => 5,
1001
            4 => 10,
1002
            5 => 20,
1003
            6 => 30,
1004
            7 => 40,
1005
            8 => 80,
1006
            9 => 160,
1007
            10 => 320,
1008
            11 => 640,
1009
        ];
1010
1011
        $conditions = [];
1012
1013
        if (array_key_exists($sizeFrom, $sizeRange)) {
1014
            $conditions[] = sprintf('r.size > %d', 104857600 * (int) $sizeRange[$sizeFrom]);
1015
        }
1016
1017
        if (array_key_exists($sizeTo, $sizeRange)) {
1018
            $conditions[] = sprintf('r.size < %d', 104857600 * (int) $sizeRange[$sizeTo]);
1019
        }
1020
1021
        return $conditions;
1022
    }
1023
1024
    /**
1025
     * Build category condition based on search type
1026
     */
1027
    private function buildCategoryCondition(string $type, array $cat): string
1028
    {
1029
        if ($type === 'basic') {
1030
            $catSearch = Category::getCategorySearch($cat);
1031
            // Remove WHERE and AND from the beginning as we're building it into a larger WHERE clause
1032
            $catSearch = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catSearch));
1033
1034
            // Don't return '1=1' as it's not needed
1035
            return ($catSearch === '1=1') ? '' : $catSearch;
1036
        }
1037
1038
        if ($type === 'advanced' && (int) $cat[0] !== -1) {
1039
            return sprintf('r.categories_id = %d', (int) $cat[0]);
1040
        }
1041
1042
        return '';
1043
    }
1044
1045
    /**
1046
     * Build base SQL for search query
1047
     */
1048
    private function buildSearchBaseSql(string $whereSql): string
1049
    {
1050
        return sprintf(
1051
            "SELECT r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id, r.size,
1052
                    r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
1053
                    r.videos_id, r.tv_episodes_id, r.haspreview, r.jpgstatus,
1054
                    cp.title AS parent_category, c.title AS sub_category,
1055
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
1056
                    df.failed AS failed,
1057
                    g.name AS group_name,
1058
                    rn.releases_id AS nfoid,
1059
                    re.releases_id AS reid,
1060
                    cp.id AS categoryparentid,
1061
                    v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
1062
                    tve.firstaired
1063
            FROM releases r
1064
            LEFT OUTER JOIN video_data re ON re.releases_id = r.id
1065
            LEFT OUTER JOIN videos v ON r.videos_id = v.id
1066
            LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id
1067
            LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id
1068
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
1069
            LEFT JOIN categories c ON c.id = r.categories_id
1070
            LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
1071
            LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id
1072
            %s",
1073
            $whereSql
1074
        );
1075
    }
1076
1077
    /**
1078
     * Get the passworded releases clause.
1079
     */
1080
    public function showPasswords(): string
1081
    {
1082
        $show = (int) Settings::settingValue('showpasswordedrelease');
1083
        $setting = $show ?? 0;
1084
1085
        return match ($setting) {
1086
            1 => '<= '.self::PASSWD_RAR,
1087
            default => '= '.self::PASSWD_NONE,
1088
        };
1089
    }
1090
1091
    /**
1092
     * Use to order releases on site.
1093
     */
1094
    public function getBrowseOrder(array|string $orderBy): array
1095
    {
1096
        $orderArr = explode('_', ($orderBy === '' ? 'posted_desc' : $orderBy));
0 ignored issues
show
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

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