ReleaseSearchService::buildSearchWhereClause()   D
last analyzed

Complexity

Conditions 11
Paths 384

Size

Total Lines 62
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 25
c 1
b 0
f 0
dl 0
loc 62
rs 4.1833
cc 11
nc 384
nop 11

How to fix   Long Method    Complexity    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 (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