Passed
Push — master ( 85b490...1de0a7 )
by Darko
10:33
created

ReleaseSearchService::buildSearchConditions()   F

Complexity

Conditions 15
Paths 1024

Size

Total Lines 65
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 28
c 1
b 0
f 0
dl 0
loc 65
rs 1.7499
cc 15
nc 1024
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
        // Get search results from index (Elasticsearch/Manticore/MySQL fallback)
61
        $searchResult = $this->performIndexSearch($searchArr, $limit);
62
63
        if (empty($searchResult)) {
64
            return collect();
65
        }
66
67
        // Build conditions array for efficient WHERE clause
68
        $conditions = $this->buildSearchConditions(
69
            $searchResult, $groupName, $sizeFrom, $sizeTo, $daysNew, $daysOld,
70
            $maxAge, $excludedCats, $type, $cat, $minSize
71
        );
72
73
        $whereSql = 'WHERE ' . implode(' AND ', $conditions);
74
        $orderBy = $this->getBrowseOrder($orderBy === '' ? 'posted_desc' : $orderBy);
75
76
        // Optimized query: direct query without subquery wrapper, filter on releases first using indexed columns
77
        $sql = sprintf(
78
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id,
79
                    r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments,
80
                    r.adddate, r.videos_id, r.tv_episodes_id, r.haspreview, r.jpgstatus,
81
                    cp.title AS parent_category, c.title AS sub_category,
82
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
83
                    g.name AS group_name, cp.id AS categoryparentid,
84
                    v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
85
                    tve.firstaired,
86
                    rn.releases_id AS nfoid
87
            FROM releases r
88
            INNER JOIN categories c ON c.id = r.categories_id
89
            INNER JOIN root_categories cp ON cp.id = c.root_categories_id
90
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
91
            LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0
92
            LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0
93
            LEFT JOIN release_nfos rn ON rn.releases_id = r.id
94
            %s
95
            ORDER BY r.%s %s
96
            LIMIT %d OFFSET %d",
97
            $whereSql,
98
            $orderBy[0],
99
            $orderBy[1],
100
            $limit,
101
            $offset
102
        );
103
104
        // Check cache
105
        $cacheKey = md5($this->getCacheVersion() . $sql);
106
        $releases = Cache::get($cacheKey);
107
        if ($releases !== null) {
108
            return $releases;
109
        }
110
111
        $releases = Release::fromQuery($sql);
112
113
        // Optimized count query - only uses releases table with indexed conditions
114
        if ($releases->isNotEmpty()) {
115
            $releases[0]->_totalrows = $this->getOptimizedCount($conditions);
116
        }
117
118
        Cache::put($cacheKey, $releases, now()->addMinutes(config('nntmux.cache_expiry_medium')));
119
120
        return $releases;
121
    }
122
123
    /**
124
     * Build search conditions array for WHERE clause.
125
     * Consolidates all condition building into a single method.
126
     */
127
    private function buildSearchConditions(
128
        array $searchResult,
129
        $groupName,
130
        $sizeFrom,
131
        $sizeTo,
132
        $daysNew,
133
        $daysOld,
134
        int $maxAge,
135
        array $excludedCats,
136
        string $type,
137
        array $cat,
138
        int $minSize
139
    ): array {
140
        // Size range multipliers (100MB base)
141
        $sizeRange = [1 => 1, 2 => 2.5, 3 => 5, 4 => 10, 5 => 20, 6 => 30, 7 => 40, 8 => 80, 9 => 160, 10 => 320, 11 => 640];
142
        $sizeBase = 104857600; // 100MB in bytes
143
144
        $conditions = [
145
            sprintf('r.passwordstatus %s', $this->showPasswords()),
146
            sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))),
147
        ];
148
149
        if ($maxAge > 0) {
150
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
151
        }
152
153
        if ((int) $groupName !== -1 && ($groupId = UsenetGroup::getIDByName($groupName))) {
154
            $conditions[] = sprintf('r.groups_id = %d', $groupId);
155
        }
156
157
        // Size range conditions - uses indexed size column
158
        if (isset($sizeRange[$sizeFrom])) {
159
            $conditions[] = sprintf('r.size > %d', (int)($sizeBase * $sizeRange[$sizeFrom]));
160
        }
161
        if (isset($sizeRange[$sizeTo])) {
162
            $conditions[] = sprintf('r.size < %d', (int)($sizeBase * $sizeRange[$sizeTo]));
163
        }
164
        if ($minSize > 0) {
165
            $conditions[] = sprintf('r.size >= %d', $minSize);
166
        }
167
168
        // Category conditions - uses indexed categories_id column
169
        if ($type === 'basic') {
170
            $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim(Category::getCategorySearch($cat)));
171
            if (!empty($catQuery) && $catQuery !== '1=1') {
172
                $conditions[] = $catQuery;
173
            }
174
        } elseif ($type === 'advanced' && (int) $cat[0] !== -1) {
175
            $conditions[] = sprintf('r.categories_id = %d', (int) $cat[0]);
176
        }
177
178
        // Date range conditions - uses indexed postdate column
179
        if ((int) $daysNew !== -1) {
180
            $conditions[] = sprintf('r.postdate < (NOW() - INTERVAL %d DAY)', (int) $daysNew);
181
        }
182
        if ((int) $daysOld !== -1) {
183
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', (int) $daysOld);
184
        }
185
186
        // Excluded categories
187
        if (!empty($excludedCats)) {
188
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCats)));
189
        }
190
191
        return $conditions;
192
    }
193
194
    /**
195
     * Get optimized count using releases table only with indexed columns.
196
     * Avoids expensive JOINs for count operations.
197
     */
198
    private function getOptimizedCount(array $conditions): int
199
    {
200
        $maxResults = (int) config('nntmux.max_pager_results');
201
        $whereSql = 'WHERE ' . implode(' AND ', $conditions);
202
        $cacheKey = 'count_' . md5($whereSql);
203
204
        $count = Cache::get($cacheKey);
205
        if ($count !== null) {
206
            return (int) $count;
207
        }
208
209
        try {
210
            // Simple count on releases table - uses indexes efficiently
211
            $countSql = sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql);
212
            $result = DB::selectOne($countSql);
213
            $count = $result->count ?? 0;
214
215
            if ($maxResults > 0 && $count > $maxResults) {
216
                $count = $maxResults;
217
            }
218
219
            Cache::put($cacheKey, $count, now()->addMinutes(config('nntmux.cache_expiry_short')));
220
            return (int) $count;
221
        } catch (\Throwable $e) {
222
            Log::error('getOptimizedCount failed', ['error' => $e->getMessage()]);
223
            return 0;
224
        }
225
    }
226
227
    /**
228
     * Search function for API.
229
     *
230
     * @return Collection|mixed
231
     */
232
    public function apiSearch($searchName, $groupName, int $offset = 0, int $limit = 1000, int $maxAge = -1, array $excludedCats = [], array $cat = [-1], int $minSize = 0): mixed
233
    {
234
        if (config('app.debug')) {
235
            Log::debug('ReleaseSearchService::apiSearch called', [
236
                'searchName' => $searchName,
237
                'groupName' => $groupName,
238
                'offset' => $offset,
239
                'limit' => $limit,
240
            ]);
241
        }
242
243
        // Early return if searching with no results
244
        $searchResult = [];
245
        if ($searchName !== -1 && $searchName !== '' && $searchName !== null) {
246
            // Try Elasticsearch first if enabled
247
            if (config('nntmux.elasticsearch_enabled') === true) {
248
                $searchResult = $this->elasticSearch->indexSearchApi($searchName, $limit);
249
            }
250
251
            // Fall back to Manticore if Elasticsearch didn't return results
252
            if (empty($searchResult)) {
253
                $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $searchName, ['searchname']);
254
                if (config('app.debug')) {
255
                    Log::debug('apiSearch: Manticore searchIndexes result', [
256
                        'searchName' => $searchName,
257
                        'result' => $searchResult,
258
                    ]);
259
                }
260
                if (! empty($searchResult)) {
261
                    $searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
262
                }
263
            }
264
265
            // Fall back to MySQL if both Elasticsearch and Manticore failed
266
            if (empty($searchResult)) {
267
                if (config('app.debug')) {
268
                    Log::debug('apiSearch: Falling back to MySQL search');
269
                }
270
                $searchResult = $this->performMySQLSearch(['searchname' => $searchName], $limit);
271
            }
272
273
            if (empty($searchResult)) {
274
                if (config('app.debug')) {
275
                    Log::debug('apiSearch: No results from any search engine');
276
                }
277
                return collect();
278
            }
279
        }
280
281
        $conditions = [
282
            sprintf('r.passwordstatus %s', $this->showPasswords()),
283
        ];
284
285
        if ($maxAge > 0) {
286
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
287
        }
288
289
        if ((int) $groupName !== -1) {
290
            $groupId = UsenetGroup::getIDByName($groupName);
291
            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...
292
                $conditions[] = sprintf('r.groups_id = %d', $groupId);
293
            }
294
        }
295
296
        $catQuery = Category::getCategorySearch($cat);
297
        $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
298
        if (! empty($catQuery) && $catQuery !== '1=1') {
299
            $conditions[] = $catQuery;
300
        }
301
302
        if (! empty($excludedCats)) {
303
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCats)));
304
        }
305
306
        if (! empty($searchResult)) {
307
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
308
        }
309
310
        if ($minSize > 0) {
311
            $conditions[] = sprintf('r.size >= %d', $minSize);
312
        }
313
314
        $whereSql = 'WHERE '.implode(' AND ', $conditions);
315
316
        // Optimized query: remove unused columns/joins (haspreview, jpgstatus, *_id columns, nfo/video_data/failures)
317
        $sql = sprintf(
318
            "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,
319
                    cp.title AS parent_category, c.title AS sub_category,
320
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
321
                    g.name AS group_name,
322
                    m.imdbid, m.tmdbid, m.traktid,
323
                    v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
324
                    tve.firstaired, tve.title, tve.series, tve.episode
325
            FROM releases r
326
            INNER JOIN categories c ON c.id = r.categories_id
327
            INNER JOIN root_categories cp ON cp.id = c.root_categories_id
328
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
329
            LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0
330
            LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0
331
            LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0
332
            %s
333
            ORDER BY r.postdate DESC
334
            LIMIT %d OFFSET %d",
335
            $whereSql,
336
            $limit,
337
            $offset
338
        );
339
340
        $cacheKey = md5($sql);
341
        $cachedReleases = Cache::get($cacheKey);
342
        if ($cachedReleases !== null) {
343
            return $cachedReleases;
344
        }
345
346
        $releases = Release::fromQuery($sql);
347
348
        if ($releases->isNotEmpty()) {
349
            $countSql = sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql);
350
            $countResult = Release::fromQuery($countSql);
351
            $releases[0]->_totalrows = $countResult[0]->count ?? 0;
352
        }
353
354
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
355
        Cache::put($cacheKey, $releases, $expiresAt);
356
357
        return $releases;
358
    }
359
360
    /**
361
     * Search for TV shows via API.
362
     *
363
     * @return array|\Illuminate\Cache\|\Illuminate\Database\Eloquent\Collection|\Illuminate\Support\Collection|mixed
364
     */
365
    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
366
    {
367
        $shouldCache = ! (isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0);
368
        $rawCacheKey = md5(serialize(func_get_args()).'tvSearch');
369
        $cacheKey = null;
370
        if ($shouldCache) {
371
            $cacheKey = md5($this->getCacheVersion().$rawCacheKey);
372
            $cached = Cache::get($cacheKey);
373
            if ($cached !== null) {
374
                return $cached;
375
            }
376
        }
377
378
        $conditions = [
379
            sprintf('r.passwordstatus %s', $this->showPasswords()),
380
        ];
381
382
        $videoJoinCondition = '';
383
        $episodeJoinCondition = '';
384
        $needsEpisodeJoin = false;
385
386
        if (! empty($siteIdArr)) {
387
            $siteConditions = [];
388
            foreach ($siteIdArr as $column => $id) {
389
                if ($id > 0) {
390
                    $siteConditions[] = sprintf('v.%s = %d', $column, (int) $id);
391
                }
392
            }
393
394
            if (! empty($siteConditions)) {
395
                $siteUsesVideoIdOnly = count($siteConditions) === 1 && isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0;
396
397
                $seriesFilter = ($series !== '') ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : '';
398
                $episodeFilter = ($episode !== '') ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : '';
399
                $airDateFilter = ($airDate !== '') ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '';
400
401
                $lookupSql = sprintf(
402
                    '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',
403
                    implode(' OR ', $siteConditions),
404
                    $seriesFilter,
405
                    $episodeFilter,
406
                    $airDateFilter
407
                );
408
409
                $results = Release::fromQuery($lookupSql);
410
411
                if ($results->isEmpty()) {
412
                    return collect();
413
                }
414
415
                $videoIds = $results->pluck('video_id')->filter()->unique()->toArray();
416
                $episodeIds = $results->pluck('episode_id')->filter()->unique()->toArray();
417
418
                if (! empty($videoIds)) {
419
                    $conditions[] = sprintf('r.videos_id IN (%s)', implode(',', array_map('intval', $videoIds)));
420
                    $videoJoinCondition = sprintf('AND v.id IN (%s)', implode(',', array_map('intval', $videoIds)));
421
                }
422
423
                if (! empty($episodeIds) && ! $siteUsesVideoIdOnly) {
424
                    $conditions[] = sprintf('r.tv_episodes_id IN (%s)', implode(',', array_map('intval', $episodeIds)));
425
                    $episodeJoinCondition = sprintf('AND tve.id IN (%s)', implode(',', array_map('intval', $episodeIds)));
426
                    $needsEpisodeJoin = true;
427
                }
428
429
                if ($siteUsesVideoIdOnly) {
430
                    $needsEpisodeJoin = false;
431
                }
432
            }
433
        }
434
435
        $searchResult = [];
436
        if (! empty($name)) {
437
            $searchName = $name;
438
            $hasValidSiteIds = false;
439
            foreach ($siteIdArr as $column => $id) {
440
                if ($id > 0) {
441
                    $hasValidSiteIds = true;
442
                    break;
443
                }
444
            }
445
446
            if (! $hasValidSiteIds) {
447
                if (! empty($series) && (int) $series < 1900) {
448
                    $searchName .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT));
449
                    $seriesNum = (int) preg_replace('/^s0*/i', '', $series);
450
                    $conditions[] = sprintf('tve.series = %d', $seriesNum);
451
                    $needsEpisodeJoin = true;
452
                    if (! empty($episode) && ! str_contains($episode, '/')) {
453
                        $searchName .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT));
454
                        $episodeNum = (int) preg_replace('/^e0*/i', '', $episode);
455
                        $conditions[] = sprintf('tve.episode = %d', $episodeNum);
456
                    }
457
                } elseif (! empty($airDate)) {
458
                    $searchName .= ' '.str_replace(['/', '-', '.', '_'], ' ', $airDate);
459
                    $conditions[] = sprintf('DATE(tve.firstaired) = %s', escapeString($airDate));
460
                    $needsEpisodeJoin = true;
461
                }
462
            }
463
464
            // Try Elasticsearch first if enabled
465
            if (config('nntmux.elasticsearch_enabled') === true) {
466
                $searchResult = $this->elasticSearch->indexSearchTMA($searchName, $limit);
467
            }
468
469
            // Fall back to Manticore if Elasticsearch didn't return results
470
            if (empty($searchResult)) {
471
                $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $searchName, ['searchname']);
472
                if (! empty($searchResult)) {
473
                    $searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
474
                }
475
            }
476
477
            // Fall back to MySQL if both Elasticsearch and Manticore failed
478
            if (empty($searchResult)) {
479
                $searchResult = $this->performMySQLSearch(['searchname' => $searchName], $limit);
480
            }
481
482
            if (empty($searchResult)) {
483
                return collect();
484
            }
485
486
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
487
        }
488
489
        $catQuery = Category::getCategorySearch($cat, 'tv');
490
        $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
491
        if (! empty($catQuery) && $catQuery !== '1=1') {
492
            $conditions[] = $catQuery;
493
        }
494
495
        if ($maxAge > 0) {
496
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
497
        }
498
        if ($minSize > 0) {
499
            $conditions[] = sprintf('r.size >= %d', $minSize);
500
        }
501
        if (! empty($excludedCategories)) {
502
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories)));
503
        }
504
505
        $whereSql = 'WHERE '.implode(' AND ', $conditions);
506
507
        $joinType = $needsEpisodeJoin ? 'INNER' : 'LEFT';
508
509
        // Optimized select list – only fields required by XML (extended) and transformers
510
        $baseSql = sprintf(
511
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id,
512
                    r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments,
513
                    r.adddate, r.videos_id, r.tv_episodes_id,
514
                    v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage,
515
                    tve.series, tve.episode, tve.firstaired,
516
                    cp.title AS parent_category, c.title AS sub_category,
517
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
518
                    g.name AS group_name
519
            FROM releases r
520
            INNER JOIN categories c ON c.id = r.categories_id
521
            INNER JOIN root_categories cp ON cp.id = c.root_categories_id
522
            LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0 %s
523
            %s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s
524
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
525
            %s",
526
            $videoJoinCondition,
527
            $joinType,
528
            $episodeJoinCondition,
529
            $whereSql
530
        );
531
532
        $limitClause = '';
533
        if ($limit > 0) {
534
            $limitClause = sprintf(' LIMIT %d OFFSET %d', $limit, $offset);
535
        }
536
537
        $sql = sprintf('%s ORDER BY r.postdate DESC%s', $baseSql, $limitClause);
538
        $releases = Release::fromQuery($sql);
539
540
        if ($releases->isNotEmpty()) {
541
            $countSql = sprintf(
542
                'SELECT COUNT(*) as count FROM releases r %s %s %s',
543
                (! empty($videoJoinCondition) ? 'LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0' : ''),
544
                ($needsEpisodeJoin ? sprintf('%s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s', $joinType, $episodeJoinCondition) : ''),
545
                $whereSql
546
            );
547
            $countResult = Release::fromQuery($countSql);
548
            $releases[0]->_totalrows = $countResult[0]->count ?? 0;
549
        }
550
551
        if ($shouldCache && $cacheKey !== null) {
552
            $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
553
            Cache::put($cacheKey, $releases, $expiresAt);
554
        }
555
556
        return $releases;
557
    }
558
559
    /**
560
     * Search TV Shows via APIv2.
561
     *
562
     * @return Collection|mixed
563
     */
564
    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
565
    {
566
        $siteSQL = [];
567
        $showSql = '';
568
        foreach ($siteIdArr as $column => $Id) {
569
            if ($Id > 0) {
570
                $siteSQL[] = sprintf('v.%s = %d', $column, $Id);
571
            }
572
        }
573
574
        if (\count($siteSQL) > 0) {
575
            $showQry = sprintf(
576
                "\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",
577
                implode(' OR ', $siteSQL),
578
                ($series !== '' ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''),
579
                ($episode !== '' ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''),
580
                ($airDate !== '' ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '')
581
            );
582
            $show = Release::fromQuery($showQry);
583
            if ($show->isNotEmpty()) {
584
                if ((! empty($episode) && ! empty($series)) && $show[0]->episodes !== '') {
585
                    $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series;
586
                } elseif (! empty($episode) && $show[0]->episodes !== '') {
587
                    $showSql = sprintf('AND r.tv_episodes_id IN (%s)', $show[0]->episodes);
588
                } elseif (! empty($series) && empty($episode)) {
589
                    $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series;
590
                }
591
                if ($show[0]->video > 0) {
592
                    $showSql .= ' AND r.videos_id = '.$show[0]->video;
593
                }
594
            } else {
595
                return [];
596
            }
597
        }
598
        if (! empty($name) && $showSql === '') {
599
            if (! empty($series) && (int) $series < 1900) {
600
                $name .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT));
601
                if (! empty($episode) && ! str_contains($episode, '/')) {
602
                    $name .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT));
603
                }
604
                if (empty($episode)) {
605
                    $name .= '*';
606
                }
607
            } elseif (! empty($airDate)) {
608
                $name .= sprintf(' %s', str_replace(['/', '-', '.', '_'], ' ', $airDate));
609
            }
610
        }
611
        $searchResult = [];
612
        if (! empty($name)) {
613
            // Try Elasticsearch first if enabled
614
            if (config('nntmux.elasticsearch_enabled') === true) {
615
                $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit);
616
            }
617
618
            // Fall back to Manticore if Elasticsearch didn't return results
619
            if (empty($searchResult)) {
620
                $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']);
621
                if (! empty($searchResult)) {
622
                    $searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
623
                }
624
            }
625
626
            // Fall back to MySQL if both Elasticsearch and Manticore failed
627
            if (empty($searchResult)) {
628
                $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit);
629
            }
630
631
            if (count($searchResult) === 0) {
632
                return collect();
633
            }
634
        }
635
        $whereSql = sprintf(
636
            'WHERE r.passwordstatus %s %s %s %s %s %s %s',
637
            $this->showPasswords(),
638
            $showSql,
639
            (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''),
640
            Category::getCategorySearch($cat, 'tv'),
641
            ($maxAge > 0 ? sprintf('AND r.postdate > NOW() - INTERVAL %d DAY', $maxAge) : ''),
642
            ($minSize > 0 ? sprintf('AND r.size >= %d', $minSize) : ''),
643
            ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : ''
644
        );
645
        $baseSql = sprintf(
646
            "SELECT r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
647
                r.tv_episodes_id, v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage,
648
                tve.series, tve.episode, tve.firstaired, cp.title AS parent_category, c.title AS sub_category,
649
                CONCAT(cp.title, ' > ', c.title) AS category_name, g.name AS group_name
650
            FROM releases r
651
            LEFT OUTER JOIN videos v ON r.videos_id = v.id AND v.type = 0
652
            LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id
653
            LEFT JOIN categories c ON c.id = r.categories_id
654
            LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
655
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
656
            %s",
657
            $whereSql
658
        );
659
        $sql = sprintf('%s ORDER BY postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset);
660
        $releases = Cache::get(md5($sql));
661
        if ($releases !== null) {
662
            return $releases;
663
        }
664
        $releases = Release::fromQuery($sql);
665
        if ($releases->isNotEmpty()) {
666
            $releases[0]->_totalrows = $this->getPagerCount(
667
                preg_replace('#LEFT(\s+OUTER)?\s+JOIN\s+(?!tv_episodes)\s+.*ON.*=.*\n#i', ' ', $baseSql)
668
            );
669
        }
670
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
671
        Cache::put(md5($sql), $releases, $expiresAt);
672
673
        return $releases;
674
    }
675
676
    /**
677
     * Search anime releases.
678
     *
679
     * @return Collection|mixed
680
     */
681
    public function animeSearch($aniDbID, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, array $excludedCategories = []): mixed
682
    {
683
        $searchResult = [];
684
        if (! empty($name)) {
685
            // Try Elasticsearch first if enabled
686
            if (config('nntmux.elasticsearch_enabled') === true) {
687
                $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit);
688
            }
689
690
            // Fall back to Manticore if Elasticsearch didn't return results
691
            if (empty($searchResult)) {
692
                $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']);
693
                if (! empty($searchResult)) {
694
                    $searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
695
                }
696
            }
697
698
            // Fall back to MySQL if both Elasticsearch and Manticore failed
699
            if (empty($searchResult)) {
700
                $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit);
701
            }
702
703
            if (count($searchResult) === 0) {
704
                return collect();
705
            }
706
        }
707
708
        $whereSql = sprintf(
709
            'WHERE r.passwordstatus %s
710
			%s %s %s %s %s',
711
            $this->showPasswords(),
712
            ($aniDbID > -1 ? sprintf(' AND r.anidbid = %d ', $aniDbID) : ''),
713
            (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''),
714
            ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : '',
715
            Category::getCategorySearch($cat),
716
            ($maxAge > 0 ? sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge) : '')
717
        );
718
        $baseSql = sprintf(
719
            "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,
720
				CONCAT(cp.title, ' > ', c.title) AS category_name,
721
				g.name AS group_name,
722
				rn.releases_id AS nfoid
723
			FROM releases r
724
			LEFT JOIN categories c ON c.id = r.categories_id
725
			LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
726
			LEFT JOIN usenet_groups g ON g.id = r.groups_id
727
			LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id
728
			%s",
729
            $whereSql
730
        );
731
        $sql = sprintf(
732
            '%s
733
			ORDER BY postdate DESC
734
			LIMIT %d OFFSET %d',
735
            $baseSql,
736
            $limit,
737
            $offset
738
        );
739
        $releases = Cache::get(md5($sql));
740
        if ($releases !== null) {
741
            return $releases;
742
        }
743
        $releases = Release::fromQuery($sql);
744
        if ($releases->isNotEmpty()) {
745
            $releases[0]->_totalrows = $this->getPagerCount($baseSql);
746
        }
747
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
748
        Cache::put(md5($sql), $releases, $expiresAt);
749
750
        return $releases;
751
    }
752
753
    /**
754
     * Movies search through API and site.
755
     *
756
     * @return Collection|mixed
757
     */
758
    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
759
    {
760
        // Early return if searching by name yields no results
761
        $searchResult = [];
762
        if (! empty($name)) {
763
            // Try Elasticsearch first if enabled
764
            if (config('nntmux.elasticsearch_enabled') === true) {
765
                $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit);
766
            }
767
768
            // Fall back to Manticore if Elasticsearch didn't return results
769
            if (empty($searchResult)) {
770
                $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']);
771
                if (! empty($searchResult)) {
772
                    $searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
773
                }
774
            }
775
776
            // Fall back to MySQL if both Elasticsearch and Manticore failed
777
            if (empty($searchResult)) {
778
                $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit);
779
            }
780
781
            if (empty($searchResult)) {
782
                return collect();
783
            }
784
        }
785
786
        $conditions = [
787
            sprintf('r.categories_id BETWEEN %d AND %d', Category::MOVIE_ROOT, Category::MOVIE_OTHER),
788
            sprintf('r.passwordstatus %s', $this->showPasswords()),
789
        ];
790
791
        if (! empty($searchResult)) {
792
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
793
        }
794
795
        // Optimize: Join on imdbid directly (both tables have indexed imdbid columns)
796
        // This is more efficient than joining through movieinfo_id
797
        // Always join movieinfo to get imdbid, tmdbid, and traktid fields consistently
798
799
        if ($imDbId !== -1 && $imDbId) {
800
            // Filter on r.imdbid (uses index ix_releases_imdbid)
801
            // The join on m.imdbid = r.imdbid will also use the index
802
            $conditions[] = sprintf('r.imdbid = %d', $imDbId);
803
        }
804
805
        if ($tmDbId !== -1 && $tmDbId) {
806
            $conditions[] = sprintf('m.tmdbid = %d', $tmDbId);
807
        }
808
809
        if ($traktId !== -1 && $traktId) {
810
            $conditions[] = sprintf('m.traktid = %d', $traktId);
811
        }
812
813
        if (! empty($excludedCategories)) {
814
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories)));
815
        }
816
817
        $catQuery = Category::getCategorySearch($cat, 'movies');
818
        $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
819
        if (! empty($catQuery) && $catQuery !== '1=1') {
820
            $conditions[] = $catQuery;
821
        }
822
        if ($maxAge > 0) {
823
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
824
        }
825
        if ($minSize > 0) {
826
            $conditions[] = sprintf('r.size >= %d', $minSize);
827
        }
828
829
        $whereSql = 'WHERE '.implode(' AND ', $conditions);
830
        // Always join on imdbid directly (both tables have indexed imdbid) - more efficient than movieinfo_id
831
        // Both ix_releases_imdbid and ix_movieinfo_imdbid are indexed, making this join very fast
832
        // This ensures we always get imdbid, tmdbid, and traktid fields in results
833
        $joinSql = 'INNER JOIN movieinfo m ON m.imdbid = r.imdbid';
834
835
        // Select only fields required by XML/API transformers
836
        $baseSql = sprintf(
837
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id,
838
                    r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments,
839
                    r.adddate,
840
                    %s
841
                    cp.title AS parent_category, c.title AS sub_category,
842
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
843
                    g.name AS group_name
844
             FROM releases r
845
             INNER JOIN categories c ON c.id = r.categories_id
846
             INNER JOIN root_categories cp ON cp.id = c.root_categories_id
847
             %s
848
             LEFT JOIN usenet_groups g ON g.id = r.groups_id
849
             %s",
850
            'm.imdbid, m.tmdbid, m.traktid,',
851
            $joinSql,
852
            $whereSql
853
        );
854
855
        $sql = sprintf('%s ORDER BY r.postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset);
856
        $cacheKey = md5($sql.serialize(func_get_args()));
857
        if (($releases = Cache::get($cacheKey)) !== null) {
858
            return $releases;
859
        }
860
861
        $releases = Release::fromQuery($sql);
862
863
        if ($releases->isNotEmpty()) {
864
            // Optimize: Execute count query using same WHERE clause (uses same indexes)
865
            // The count query is lightweight and can use index-only scans when possible
866
            // Use same join logic as main query (join on imdbid when needed)
867
            $countSql = sprintf(
868
                'SELECT COUNT(*) as count FROM releases r %s %s',
869
                $joinSql,
870
                $whereSql
871
            );
872
            $countResult = DB::selectOne($countSql);
873
            $releases[0]->_totalrows = $countResult->count ?? 0;
874
        }
875
876
        Cache::put($cacheKey, $releases, now()->addMinutes(config('nntmux.cache_expiry_medium')));
877
878
        return $releases;
879
    }
880
881
    public function searchSimilar($currentID, $name, array $excludedCats = []): bool|array
882
    {
883
        // Get the category for the parent of this release.
884
        $ret = false;
885
        $currRow = Release::getCatByRelId($currentID);
886
        if ($currRow !== null) {
887
            $catRow = Category::find($currRow['categories_id']);
888
            $parentCat = $catRow !== null ? $catRow['root_categories_id'] : null;
889
890
            if ($parentCat === null) {
891
                return $ret;
892
            }
893
894
            $results = $this->search(['searchname' => getSimilarName($name)], -1, '', '', -1, -1, 0, config('nntmux.items_per_page'), '', -1, $excludedCats, 'basic', [$parentCat]);
895
            if (! $results) {
896
                return $ret;
897
            }
898
899
            $ret = [];
900
            foreach ($results as $res) {
901
                if ($res['id'] !== $currentID && $res['categoryparentid'] === $parentCat) {
902
                    $ret[] = $res;
903
                }
904
            }
905
        }
906
907
        return $ret;
908
    }
909
910
    /**
911
     * Perform index search using Elasticsearch or Manticore, with MySQL fallback
912
     */
913
    private function performIndexSearch(array $searchArr, int $limit): array
914
    {
915
        // Filter out -1 values and empty strings
916
        $searchFields = Arr::where($searchArr, static function ($value) {
917
            return $value !== -1 && $value !== '' && $value !== null;
918
        });
919
920
        if (empty($searchFields)) {
921
            if (config('app.debug')) {
922
                Log::debug('performIndexSearch: searchFields is empty after filtering', [
923
                    'original' => $searchArr,
924
                ]);
925
            }
926
            return [];
927
        }
928
929
        $phrases = array_values($searchFields);
930
931
        $esEnabled = config('nntmux.elasticsearch_enabled');
932
        if (config('app.debug')) {
933
            Log::debug('performIndexSearch: starting search', [
934
                'elasticsearch_enabled' => $esEnabled,
935
                'elasticsearch_enabled_type' => gettype($esEnabled),
936
                'searchFields' => $searchFields,
937
                'phrases' => $phrases,
938
                'limit' => $limit,
939
            ]);
940
        }
941
942
        // Try Elasticsearch first if enabled
943
        if ($esEnabled === true) {
944
            $result = $this->elasticSearch->indexSearch($phrases, $limit);
945
            if (config('app.debug')) {
946
                Log::debug('performIndexSearch: Elasticsearch result count', ['count' => count($result)]);
947
            }
948
            // If Elasticsearch returned results, use them
949
            if (!empty($result)) {
950
                return $result;
951
            }
952
            // Otherwise fall through to Manticore
953
            if (config('app.debug')) {
954
                Log::debug('performIndexSearch: Elasticsearch returned empty, falling back to Manticore');
955
            }
956
        }
957
958
        // Try Manticore search
959
        $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', '', [], $searchFields);
960
961
        if (config('app.debug')) {
962
            Log::debug('performIndexSearch: Manticore result', [
963
                'result' => $searchResult,
964
            ]);
965
        }
966
967
        if (!empty($searchResult) && !empty($searchResult['id'])) {
968
            return Arr::wrap(Arr::get($searchResult, 'id'));
969
        }
970
971
        // Fallback to MySQL LIKE search when both Elasticsearch and Manticore are unavailable
972
        if (config('app.debug')) {
973
            Log::debug('performIndexSearch: Falling back to MySQL search');
974
        }
975
976
        return $this->performMySQLSearch($searchFields, $limit);
977
    }
978
979
    /**
980
     * Fallback MySQL search when full-text search engines are unavailable
981
     */
982
    private function performMySQLSearch(array $searchFields, int $limit): array
983
    {
984
        try {
985
            $query = Release::query()->select('id');
986
987
            foreach ($searchFields as $field => $value) {
988
                if (!empty($value)) {
989
                    // Split search terms and search for each
990
                    $terms = preg_split('/\s+/', trim($value));
991
                    foreach ($terms as $term) {
992
                        $term = trim($term);
993
                        if (strlen($term) >= 2) {
994
                            $query->where($field, 'LIKE', '%' . $term . '%');
995
                        }
996
                    }
997
                }
998
            }
999
1000
            $results = $query->limit($limit)->pluck('id')->toArray();
1001
1002
            if (config('app.debug')) {
1003
                Log::debug('performMySQLSearch: MySQL fallback result count', ['count' => count($results)]);
1004
            }
1005
1006
            return $results;
1007
        } catch (\Throwable $e) {
1008
            Log::error('performMySQLSearch: MySQL fallback failed', [
1009
                'error' => $e->getMessage(),
1010
            ]);
1011
            return [];
1012
        }
1013
    }
1014
1015
    /**
1016
     * Build WHERE clause for search query
1017
     */
1018
    private function buildSearchWhereClause(
0 ignored issues
show
Unused Code introduced by
The method buildSearchWhereClause() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
1019
        array $searchResult,
1020
        $groupName,
1021
        $sizeFrom,
1022
        $sizeTo,
1023
        $daysNew,
1024
        $daysOld,
1025
        int $maxAge,
1026
        array $excludedCats,
1027
        string $type,
1028
        array $cat,
1029
        int $minSize
1030
    ): string {
1031
        $conditions = [
1032
            sprintf('r.passwordstatus %s', $this->showPasswords()),
1033
            sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))),
1034
        ];
1035
1036
        // Add optional conditions
1037
        if ($maxAge > 0) {
1038
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
1039
        }
1040
1041
        if ((int) $groupName !== -1) {
1042
            $groupId = UsenetGroup::getIDByName($groupName);
1043
            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...
1044
                $conditions[] = sprintf('r.groups_id = %d', $groupId);
1045
            }
1046
        }
1047
1048
        // Size conditions
1049
        $sizeConditions = $this->buildSizeConditions($sizeFrom, $sizeTo);
1050
        if (! empty($sizeConditions)) {
1051
            $conditions = array_merge($conditions, $sizeConditions);
1052
        }
1053
1054
        if ($minSize > 0) {
1055
            $conditions[] = sprintf('r.size >= %d', $minSize);
1056
        }
1057
1058
        // Category conditions - only add if not empty
1059
        $catQuery = $this->buildCategoryCondition($type, $cat);
1060
        if (! empty($catQuery) && $catQuery !== '1=1') {
1061
            $conditions[] = $catQuery;
1062
        }
1063
1064
        // Date conditions
1065
        if ((int) $daysNew !== -1) {
1066
            $conditions[] = sprintf('r.postdate < (NOW() - INTERVAL %d DAY)', $daysNew);
1067
        }
1068
1069
        if ((int) $daysOld !== -1) {
1070
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $daysOld);
1071
        }
1072
1073
        // Excluded categories
1074
        if (! empty($excludedCats)) {
1075
            $excludedCatsClean = array_map('intval', $excludedCats);
1076
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', $excludedCatsClean));
1077
        }
1078
1079
        return 'WHERE '.implode(' AND ', $conditions);
1080
    }
1081
1082
    /**
1083
     * Build size conditions for WHERE clause
1084
     */
1085
    private function buildSizeConditions($sizeFrom, $sizeTo): array
1086
    {
1087
        $sizeRange = [
1088
            1 => 1,
1089
            2 => 2.5,
1090
            3 => 5,
1091
            4 => 10,
1092
            5 => 20,
1093
            6 => 30,
1094
            7 => 40,
1095
            8 => 80,
1096
            9 => 160,
1097
            10 => 320,
1098
            11 => 640,
1099
        ];
1100
1101
        $conditions = [];
1102
1103
        if (array_key_exists($sizeFrom, $sizeRange)) {
1104
            $conditions[] = sprintf('r.size > %d', 104857600 * (int) $sizeRange[$sizeFrom]);
1105
        }
1106
1107
        if (array_key_exists($sizeTo, $sizeRange)) {
1108
            $conditions[] = sprintf('r.size < %d', 104857600 * (int) $sizeRange[$sizeTo]);
1109
        }
1110
1111
        return $conditions;
1112
    }
1113
1114
    /**
1115
     * Build category condition based on search type
1116
     */
1117
    private function buildCategoryCondition(string $type, array $cat): string
1118
    {
1119
        if ($type === 'basic') {
1120
            $catSearch = Category::getCategorySearch($cat);
1121
            // Remove WHERE and AND from the beginning as we're building it into a larger WHERE clause
1122
            $catSearch = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catSearch));
1123
1124
            // Don't return '1=1' as it's not needed
1125
            return ($catSearch === '1=1') ? '' : $catSearch;
1126
        }
1127
1128
        if ($type === 'advanced' && (int) $cat[0] !== -1) {
1129
            return sprintf('r.categories_id = %d', (int) $cat[0]);
1130
        }
1131
1132
        return '';
1133
    }
1134
1135
    /**
1136
     * Build base SQL for search query
1137
     */
1138
    private function buildSearchBaseSql(string $whereSql): string
0 ignored issues
show
Unused Code introduced by
The method buildSearchBaseSql() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
1139
    {
1140
        return sprintf(
1141
            "SELECT r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id, r.size,
1142
                    r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
1143
                    r.videos_id, r.tv_episodes_id, r.haspreview, r.jpgstatus,
1144
                    cp.title AS parent_category, c.title AS sub_category,
1145
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
1146
                    df.failed AS failed,
1147
                    g.name AS group_name,
1148
                    rn.releases_id AS nfoid,
1149
                    re.releases_id AS reid,
1150
                    cp.id AS categoryparentid,
1151
                    v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
1152
                    tve.firstaired
1153
            FROM releases r
1154
            LEFT OUTER JOIN video_data re ON re.releases_id = r.id
1155
            LEFT OUTER JOIN videos v ON r.videos_id = v.id
1156
            LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id
1157
            LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id
1158
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
1159
            LEFT JOIN categories c ON c.id = r.categories_id
1160
            LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
1161
            LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id
1162
            %s",
1163
            $whereSql
1164
        );
1165
    }
1166
1167
    /**
1168
     * Get the passworded releases clause.
1169
     */
1170
    public function showPasswords(): string
1171
    {
1172
        $show = (int) Settings::settingValue('showpasswordedrelease');
1173
        $setting = $show ?? 0;
1174
1175
        return match ($setting) {
1176
            1 => '<= '.self::PASSWD_RAR,
1177
            default => '= '.self::PASSWD_NONE,
1178
        };
1179
    }
1180
1181
    /**
1182
     * Use to order releases on site.
1183
     */
1184
    public function getBrowseOrder(array|string $orderBy): array
1185
    {
1186
        $orderArr = explode('_', ($orderBy === '' ? 'posted_desc' : $orderBy));
0 ignored issues
show
introduced by
The condition $orderBy === '' is always false.
Loading history...
Bug introduced by
$orderBy === '' ? 'posted_desc' : $orderBy of type array is incompatible with the type string expected by parameter $string of explode(). ( Ignorable by Annotation )

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

1186
        $orderArr = explode('_', /** @scrutinizer ignore-type */ ($orderBy === '' ? 'posted_desc' : $orderBy));
Loading history...
1187
        $orderField = match ($orderArr[0]) {
1188
            'cat' => 'categories_id',
1189
            'name' => 'searchname',
1190
            'size' => 'size',
1191
            'files' => 'totalpart',
1192
            'stats' => 'grabs',
1193
            default => 'postdate',
1194
        };
1195
1196
        return [$orderField, isset($orderArr[1]) && preg_match('/^(asc|desc)$/i', $orderArr[1]) ? $orderArr[1] : 'desc'];
1197
    }
1198
1199
    private function getCacheVersion(): int
1200
    {
1201
        return Cache::get(self::CACHE_VERSION_KEY, 1);
1202
    }
1203
1204
    /**
1205
     * Get the count of releases for pager.
1206
     *
1207
     * @param  string  $query  The query to get the count from.
1208
     */
1209
    private function getPagerCount(string $query): int
1210
    {
1211
        $maxResults = (int) config('nntmux.max_pager_results');
1212
        $cacheExpiry = config('nntmux.cache_expiry_short');
1213
1214
        // Generate cache key from original query
1215
        $cacheKey = 'pager_count_'.md5($query);
1216
1217
        // Check cache first
1218
        $count = Cache::get($cacheKey);
1219
        if ($count !== null) {
1220
            return (int) $count;
1221
        }
1222
1223
        // Check if this is already a COUNT query
1224
        if (preg_match('/SELECT\s+COUNT\s*\(/is', $query)) {
1225
            // It's already a COUNT query, just execute it
1226
            try {
1227
                $result = DB::select($query);
1228
                if (isset($result[0])) {
1229
                    // Handle different possible column names
1230
                    $count = $result[0]->count ?? $result[0]->total ?? 0;
1231
                    // Check for COUNT(*) result without alias
1232
                    if ($count === 0) {
1233
                        foreach ($result[0] as $value) {
1234
                            $count = (int) $value;
1235
                            break;
1236
                        }
1237
                    }
1238
                } else {
1239
                    $count = 0;
1240
                }
1241
1242
                // Cap the count at max results if applicable
1243
                if ($maxResults > 0 && $count > $maxResults) {
1244
                    $count = $maxResults;
1245
                }
1246
1247
                // Cache the result
1248
                Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry));
1249
1250
                return $count;
1251
            } catch (\Exception $e) {
1252
                return 0;
1253
            }
1254
        }
1255
1256
        // For regular SELECT queries, optimize for counting
1257
        $countQuery = $query;
1258
1259
        // Remove ORDER BY clause (not needed for COUNT)
1260
        $countQuery = preg_replace('/ORDER\s+BY\s+[^)]+$/is', '', $countQuery);
1261
1262
        // Remove GROUP BY if it's only grouping by r.id
1263
        $countQuery = preg_replace('/GROUP\s+BY\s+r\.id\s*$/is', '', $countQuery);
1264
1265
        // Check if query has DISTINCT in SELECT
1266
        $hasDistinct = preg_match('/SELECT\s+DISTINCT/is', $countQuery);
1267
1268
        // Replace SELECT clause with COUNT
1269
        if ($hasDistinct || preg_match('/GROUP\s+BY/is', $countQuery)) {
1270
            // For queries with DISTINCT or GROUP BY, count distinct r.id
1271
            $countQuery = preg_replace(
1272
                '/SELECT\s+.+?\s+FROM/is',
1273
                'SELECT COUNT(DISTINCT r.id) as count FROM',
1274
                $countQuery
1275
            );
1276
        } else {
1277
            // For simple queries, use COUNT(*)
1278
            $countQuery = preg_replace(
1279
                '/SELECT\s+.+?\s+FROM/is',
1280
                'SELECT COUNT(*) as count FROM',
1281
                $countQuery
1282
            );
1283
        }
1284
1285
        // Remove LIMIT/OFFSET from the count query
1286
        $countQuery = preg_replace('/LIMIT\s+\d+(\s+OFFSET\s+\d+)?$/is', '', $countQuery);
1287
1288
        try {
1289
            // If max results is set and query might return too many results
1290
            if ($maxResults > 0) {
1291
                // First check if count would exceed max
1292
                $testQuery = sprintf('SELECT 1 FROM (%s) as test LIMIT %d',
1293
                    preg_replace('/SELECT\s+COUNT.+?\s+FROM/is', 'SELECT 1 FROM', $countQuery),
1294
                    $maxResults + 1
1295
                );
1296
1297
                $testResult = DB::select($testQuery);
1298
                if (count($testResult) > $maxResults) {
1299
                    Cache::put($cacheKey, $maxResults, now()->addMinutes($cacheExpiry));
1300
1301
                    return $maxResults;
1302
                }
1303
            }
1304
1305
            // Execute the count query
1306
            $result = DB::select($countQuery);
1307
            $count = isset($result[0]) ? (int) $result[0]->count : 0;
1308
1309
            // Cache the result
1310
            Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry));
1311
1312
            return $count;
1313
        } catch (\Exception $e) {
1314
            // If optimization fails, try a simpler approach
1315
            try {
1316
                // Extract the core table and WHERE conditions
1317
                if (preg_match('/FROM\s+releases\s+r\s+(.+?)(?:ORDER\s+BY|LIMIT|$)/is', $query, $matches)) {
1318
                    $conditions = $matches[1];
1319
                    // Remove JOINs but keep WHERE
1320
                    $conditions = preg_replace('/(?:LEFT\s+|INNER\s+)?(?:OUTER\s+)?JOIN\s+.+?(?=WHERE|LEFT|INNER|JOIN|$)/is', '', $conditions);
1321
1322
                    $fallbackQuery = sprintf('SELECT COUNT(*) as count FROM releases r %s', trim($conditions));
1323
1324
                    if ($maxResults > 0) {
1325
                        $fallbackQuery = sprintf('SELECT COUNT(*) as count FROM (SELECT 1 FROM releases r %s LIMIT %d) as limited',
1326
                            trim($conditions),
1327
                            $maxResults
1328
                        );
1329
                    }
1330
1331
                    $result = DB::select($fallbackQuery);
1332
                    $count = isset($result[0]) ? (int) $result[0]->count : 0;
1333
1334
                    Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry));
1335
1336
                    return $count;
1337
                }
1338
            } catch (\Exception $fallbackException) {
1339
                // Log the error for debugging
1340
                Log::error('getPagerCount failed', [
1341
                    'query' => $query,
1342
                    'error' => $fallbackException->getMessage(),
1343
                ]);
1344
            }
1345
1346
            return 0;
1347
        }
1348
    }
1349
}
1350
1351