ReleaseSearchService   F
last analyzed

Complexity

Total Complexity 252

Size/Duplication

Total Lines 1305
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 252
eloc 711
c 2
b 0
f 0
dl 0
loc 1305
rs 1.889

18 Methods

Rating   Name   Duplication   Size   Complexity  
F tvSearch() 0 260 64
F apiTvSearch() 0 136 44
B animeSearch() 0 61 11
B searchSimilar() 0 27 8
F moviesSearch() 0 137 33
D buildSearchWhereClause() 0 62 11
F getPagerCount() 0 138 19
A buildSizeConditions() 0 27 3
A getBrowseOrder() 0 13 4
A __construct() 0 1 1
F apiSearch() 0 116 22
B performMySQLSearch() 0 31 7
A getCacheVersion() 0 3 1
B search() 0 87 7
B performIndexSearch() 0 52 10
A showPasswords() 0 8 1
A buildSearchBaseSql() 0 26 1
A buildCategoryCondition() 0 16 5

How to fix   Complexity   

Complex Class

Complex classes like ReleaseSearchService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ReleaseSearchService, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace App\Services\Releases;
4
5
use App\Facades\Search;
6
use App\Models\Category;
7
use App\Models\Release;
8
use App\Models\Settings;
9
use App\Models\UsenetGroup;
10
use Illuminate\Database\Eloquent\Collection;
11
use Illuminate\Support\Arr;
12
use Illuminate\Support\Facades\Cache;
13
use Illuminate\Support\Facades\DB;
14
use Illuminate\Support\Facades\Log;
15
16
/**
17
 * Service for searching releases.
18
 */
19
class ReleaseSearchService
20
{
21
    private const CACHE_VERSION_KEY = 'releases:cache_version';
22
23
    // RAR/ZIP Password indicator.
24
    public const PASSWD_NONE = 0;
25
26
    public const PASSWD_RAR = 1;
27
28
    public function __construct() {}
29
30
    /**
31
     * Function for searching on the site (by subject, searchname or advanced).
32
     *
33
     * @return array|Collection|mixed
34
     */
35
    public function search(
36
        array $searchArr,
37
        $groupName,
38
        $sizeFrom,
39
        $sizeTo,
40
        $daysNew,
41
        $daysOld,
42
        int $offset = 0,
43
        int $limit = 1000,
44
        array|string $orderBy = '',
45
        int $maxAge = -1,
46
        array $excludedCats = [],
47
        string $type = 'basic',
48
        array $cat = [-1],
49
        int $minSize = 0
50
    ): mixed {
51
        if (config('app.debug')) {
52
            Log::debug('ReleaseSearchService::search called', [
53
                'searchArr' => $searchArr,
54
                'limit' => $limit,
55
            ]);
56
        }
57
58
        // Get search results from index
59
        $searchResult = $this->performIndexSearch($searchArr, $limit);
60
61
        if (config('app.debug')) {
62
            Log::debug('ReleaseSearchService::search after performIndexSearch', [
63
                'result_count' => count($searchResult),
64
            ]);
65
        }
66
67
        if (count($searchResult) === 0) {
68
            return collect();
69
        }
70
71
        // Build WHERE clause
72
        $whereSql = $this->buildSearchWhereClause(
73
            $searchResult,
74
            $groupName,
75
            $sizeFrom,
76
            $sizeTo,
77
            $daysNew,
78
            $daysOld,
79
            $maxAge,
80
            $excludedCats,
81
            $type,
82
            $cat,
83
            $minSize
84
        );
85
86
        // Build base SQL
87
        $baseSql = $this->buildSearchBaseSql($whereSql);
88
89
        // Get order by clause
90
        $orderBy = $this->getBrowseOrder($orderBy === '' ? 'posted_desc' : $orderBy);
91
92
        // Build final SQL with pagination
93
        $sql = sprintf(
94
            'SELECT * FROM (%s) r ORDER BY r.%s %s LIMIT %d OFFSET %d',
95
            $baseSql,
96
            $orderBy[0],
97
            $orderBy[1],
98
            $limit,
99
            $offset
100
        );
101
102
        // Check cache
103
        $cacheKey = md5($this->getCacheVersion().$sql);
104
        $releases = Cache::get($cacheKey);
105
        if ($releases !== null) {
106
            return $releases;
107
        }
108
109
        // Execute query
110
        $releases = Release::fromQuery($sql);
111
112
        // Add total count for pagination
113
        if ($releases->isNotEmpty()) {
114
            $releases[0]->_totalrows = $this->getPagerCount($baseSql);
115
        }
116
117
        // Cache results
118
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
119
        Cache::put($cacheKey, $releases, $expiresAt);
120
121
        return $releases;
122
    }
123
124
    /**
125
     * Search function for API.
126
     *
127
     * @return Collection|mixed
128
     */
129
    public function apiSearch($searchName, $groupName, int $offset = 0, int $limit = 1000, int $maxAge = -1, array $excludedCats = [], array $cat = [-1], int $minSize = 0): mixed
130
    {
131
        if (config('app.debug')) {
132
            Log::debug('ReleaseSearchService::apiSearch called', [
133
                'searchName' => $searchName,
134
                'groupName' => $groupName,
135
                'offset' => $offset,
136
                'limit' => $limit,
137
            ]);
138
        }
139
140
        // Early return if searching with no results
141
        $searchResult = [];
142
        if ($searchName !== -1 && $searchName !== '' && $searchName !== null) {
143
            // Use the unified Search facade with fuzzy fallback
144
            $fuzzyResult = Search::searchReleasesWithFuzzy($searchName, $limit);
145
            $searchResult = $fuzzyResult['ids'] ?? [];
146
147
            if (config('app.debug') && ($fuzzyResult['fuzzy'] ?? false)) {
148
                Log::debug('apiSearch: Using fuzzy search results');
149
            }
150
151
            // Fall back to MySQL if search engine returned no results (only if enabled)
152
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
153
                if (config('app.debug')) {
154
                    Log::debug('apiSearch: Falling back to MySQL search');
155
                }
156
                $searchResult = $this->performMySQLSearch(['searchname' => $searchName], $limit);
157
            }
158
159
            if (empty($searchResult)) {
160
                if (config('app.debug')) {
161
                    Log::debug('apiSearch: No results from any search engine');
162
                }
163
164
                return collect();
165
            }
166
        }
167
168
        $conditions = [
169
            sprintf('r.passwordstatus %s', $this->showPasswords()),
170
        ];
171
172
        if ($maxAge > 0) {
173
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
174
        }
175
176
        if ((int) $groupName !== -1) {
177
            $groupId = UsenetGroup::getIDByName($groupName);
178
            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...
179
                $conditions[] = sprintf('r.groups_id = %d', $groupId);
180
            }
181
        }
182
183
        $catQuery = Category::getCategorySearch($cat);
184
        $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
185
        if (! empty($catQuery) && $catQuery !== '1=1') {
186
            $conditions[] = $catQuery;
187
        }
188
189
        if (! empty($excludedCats)) {
190
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCats)));
191
        }
192
193
        if (! empty($searchResult)) {
194
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
195
        }
196
197
        if ($minSize > 0) {
198
            $conditions[] = sprintf('r.size >= %d', $minSize);
199
        }
200
201
        $whereSql = 'WHERE '.implode(' AND ', $conditions);
202
203
        // Optimized query: remove unused columns/joins (haspreview, jpgstatus, *_id columns, nfo/video_data/failures)
204
        $sql = sprintf(
205
            "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,
206
                    cp.title AS parent_category, c.title AS sub_category,
207
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
208
                    g.name AS group_name,
209
                    m.imdbid, m.tmdbid, m.traktid,
210
                    v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
211
                    tve.firstaired, tve.title, tve.series, tve.episode
212
            FROM releases r
213
            INNER JOIN categories c ON c.id = r.categories_id
214
            INNER JOIN root_categories cp ON cp.id = c.root_categories_id
215
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
216
            LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0
217
            LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0
218
            LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0
219
            %s
220
            ORDER BY r.postdate DESC
221
            LIMIT %d OFFSET %d",
222
            $whereSql,
223
            $limit,
224
            $offset
225
        );
226
227
        $cacheKey = md5($sql);
228
        $cachedReleases = Cache::get($cacheKey);
229
        if ($cachedReleases !== null) {
230
            return $cachedReleases;
231
        }
232
233
        $releases = Release::fromQuery($sql);
234
235
        if ($releases->isNotEmpty()) {
236
            $countSql = sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql);
237
            $countResult = Release::fromQuery($countSql);
238
            $releases[0]->_totalrows = $countResult[0]->count ?? 0;
239
        }
240
241
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
242
        Cache::put($cacheKey, $releases, $expiresAt);
243
244
        return $releases;
245
    }
246
247
    /**
248
     * Search for TV shows via API.
249
     *
250
     * @return array|\Illuminate\Cache\|\Illuminate\Database\Eloquent\Collection|\Illuminate\Support\Collection|mixed
251
     */
252
    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
253
    {
254
        $shouldCache = ! (isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0);
255
        $rawCacheKey = md5(serialize(func_get_args()).'tvSearch');
256
        $cacheKey = null;
257
        if ($shouldCache) {
258
            $cacheKey = md5($this->getCacheVersion().$rawCacheKey);
259
            $cached = Cache::get($cacheKey);
260
            if ($cached !== null) {
261
                return $cached;
262
            }
263
        }
264
265
        $conditions = [
266
            sprintf('r.passwordstatus %s', $this->showPasswords()),
267
        ];
268
269
        $videoJoinCondition = '';
270
        $episodeJoinCondition = '';
271
        $needsEpisodeJoin = false;
272
        $needsDatabaseLookup = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $needsDatabaseLookup is dead and can be removed.
Loading history...
273
274
        // OPTIMIZATION: Try to find releases using search index external IDs first
275
        $externalIds = [];
276
        if (! empty($siteIdArr)) {
277
            foreach ($siteIdArr as $column => $id) {
278
                if ($id > 0 && $column !== 'id') {
279
                    // Map column names to search index field names
280
                    $fieldName = match ($column) {
281
                        'tvdb' => 'tvdb',
282
                        'trakt' => 'traktid', // Note: in releases index we use traktid
283
                        'tvmaze' => 'tvmaze',
284
                        'tvrage' => 'tvrage',
285
                        'imdb' => 'imdbid',
286
                        'tmdb' => 'tmdbid',
287
                        default => null,
288
                    };
289
                    if ($fieldName) {
290
                        $externalIds[$fieldName] = (int) $id;
291
                    }
292
                }
293
            }
294
        }
295
296
        // Try to get releases directly from search index using external IDs
297
        $searchResult = [];
298
        if (! empty($externalIds)) {
299
            $searchResult = Search::searchReleasesByExternalId($externalIds, $limit * 2);
300
301
            if (config('app.debug') && ! empty($searchResult)) {
302
                Log::debug('tvSearch: Found releases via search index by external IDs', [
303
                    'externalIds' => $externalIds,
304
                    'count' => count($searchResult),
305
                ]);
306
            }
307
        }
308
309
        // If search index didn't return results, fall back to database lookup
310
        if (empty($searchResult) && ! empty($siteIdArr)) {
311
            $siteConditions = [];
312
            foreach ($siteIdArr as $column => $id) {
313
                if ($id > 0) {
314
                    $siteConditions[] = sprintf('v.%s = %d', $column, (int) $id);
315
                }
316
            }
317
318
            if (! empty($siteConditions)) {
319
                $needsDatabaseLookup = true;
320
                $siteUsesVideoIdOnly = count($siteConditions) === 1 && isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0;
321
322
                $seriesFilter = ($series !== '') ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : '';
323
                $episodeFilter = ($episode !== '') ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : '';
324
                $airDateFilter = ($airDate !== '') ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '';
325
326
                $lookupSql = sprintf(
327
                    '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',
328
                    implode(' OR ', $siteConditions),
329
                    $seriesFilter,
330
                    $episodeFilter,
331
                    $airDateFilter
332
                );
333
334
                $results = Release::fromQuery($lookupSql);
335
336
                if ($results->isEmpty()) {
337
                    return collect();
338
                }
339
340
                $videoIds = $results->pluck('video_id')->filter()->unique()->toArray();
341
                $episodeIds = $results->pluck('episode_id')->filter()->unique()->toArray();
342
343
                if (! empty($videoIds)) {
344
                    $conditions[] = sprintf('r.videos_id IN (%s)', implode(',', array_map('intval', $videoIds)));
345
                    $videoJoinCondition = sprintf('AND v.id IN (%s)', implode(',', array_map('intval', $videoIds)));
346
                }
347
348
                if (! empty($episodeIds) && ! $siteUsesVideoIdOnly) {
349
                    $conditions[] = sprintf('r.tv_episodes_id IN (%s)', implode(',', array_map('intval', $episodeIds)));
350
                    $episodeJoinCondition = sprintf('AND tve.id IN (%s)', implode(',', array_map('intval', $episodeIds)));
351
                    $needsEpisodeJoin = true;
352
                }
353
354
                if ($siteUsesVideoIdOnly) {
355
                    $needsEpisodeJoin = false;
356
                }
357
            }
358
        }
359
360
        // If search index found releases via external IDs, add them to conditions
361
        $hasSearchResultFromExternalIds = ! empty($searchResult);
362
        if ($hasSearchResultFromExternalIds) {
363
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
364
        }
365
366
        // Only do name-based search if we don't already have results from external IDs
367
        if (! $hasSearchResultFromExternalIds && ! empty($name)) {
368
            $searchName = $name;
369
            $hasValidSiteIds = false;
370
            foreach ($siteIdArr as $column => $id) {
371
                if ($id > 0) {
372
                    $hasValidSiteIds = true;
373
                    break;
374
                }
375
            }
376
377
            if (! $hasValidSiteIds) {
378
                // Build search name with season/episode for the full-text search
379
                if (! empty($series) && (int) $series < 1900) {
380
                    $searchName .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT));
381
                    $seriesNum = (int) preg_replace('/^s0*/i', '', $series);
0 ignored issues
show
Unused Code introduced by
The assignment to $seriesNum is dead and can be removed.
Loading history...
382
                    if (! empty($episode) && ! str_contains($episode, '/')) {
383
                        $searchName .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT));
384
                        $episodeNum = (int) preg_replace('/^e0*/i', '', $episode);
0 ignored issues
show
Unused Code introduced by
The assignment to $episodeNum is dead and can be removed.
Loading history...
385
                    }
386
                } elseif (! empty($airDate)) {
387
                    $searchName .= ' '.str_replace(['/', '-', '.', '_'], ' ', $airDate);
388
                }
389
            }
390
391
            // Use the unified Search facade with fuzzy fallback
392
            $fuzzyResult = Search::searchReleasesWithFuzzy(['searchname' => $searchName], $limit);
393
            $searchResult = $fuzzyResult['ids'] ?? [];
394
395
            // Fall back to MySQL if search engine failed (only if enabled)
396
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
397
                $searchResult = $this->performMySQLSearch(['searchname' => $searchName], $limit);
398
            }
399
400
            if (empty($searchResult)) {
401
                return collect();
402
            }
403
404
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
405
406
            // Try to add episode conditions if season/episode data is provided and no valid site IDs
407
            // This will filter results to only those with matching episode data in tv_episodes table
408
            // If this results in no matches, we'll fall back to results without episode conditions
409
            if (! $hasValidSiteIds && (! empty($series) || ! empty($airDate))) {
410
                $episodeConditions = [];
411
                if (! empty($series) && (int) $series < 1900) {
412
                    $seriesNum = (int) preg_replace('/^s0*/i', '', $series);
413
                    $episodeConditions[] = sprintf('tve.series = %d', $seriesNum);
414
                    if (! empty($episode) && ! str_contains($episode, '/')) {
415
                        $episodeNum = (int) preg_replace('/^e0*/i', '', $episode);
416
                        $episodeConditions[] = sprintf('tve.episode = %d', $episodeNum);
417
                    }
418
                } elseif (! empty($airDate)) {
419
                    $episodeConditions[] = sprintf('DATE(tve.firstaired) = %s', escapeString($airDate));
420
                }
421
422
                if (! empty($episodeConditions)) {
423
                    // Check if any of the found releases have matching episode data
424
                    $checkSql = sprintf(
425
                        'SELECT r.id FROM releases r INNER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id WHERE r.id IN (%s) AND %s LIMIT 1',
426
                        implode(',', array_map('intval', $searchResult)),
427
                        implode(' AND ', $episodeConditions)
428
                    );
429
                    $hasEpisodeMatches = Release::fromQuery($checkSql);
430
431
                    if ($hasEpisodeMatches->isNotEmpty()) {
432
                        // Some releases have matching episode data, add the conditions
433
                        foreach ($episodeConditions as $cond) {
434
                            $conditions[] = $cond;
435
                        }
436
                        $needsEpisodeJoin = true;
437
                    }
438
                    // If no matches with episode data, don't add episode conditions
439
                    // The search will return results based on searchname match only
440
                }
441
            }
442
        }
443
444
        $catQuery = Category::getCategorySearch($cat, 'tv');
445
        $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
446
        if (! empty($catQuery) && $catQuery !== '1=1') {
447
            $conditions[] = $catQuery;
448
        }
449
450
        if ($maxAge > 0) {
451
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
452
        }
453
        if ($minSize > 0) {
454
            $conditions[] = sprintf('r.size >= %d', $minSize);
455
        }
456
        if (! empty($excludedCategories)) {
457
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories)));
458
        }
459
460
        $whereSql = 'WHERE '.implode(' AND ', $conditions);
461
462
        $joinType = $needsEpisodeJoin ? 'INNER' : 'LEFT';
463
464
        // Optimized select list – only fields required by XML (extended) and transformers
465
        $baseSql = sprintf(
466
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id,
467
                    r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments,
468
                    r.adddate, r.videos_id, r.tv_episodes_id,
469
                    v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage,
470
                    tve.series, tve.episode, tve.firstaired,
471
                    cp.title AS parent_category, c.title AS sub_category,
472
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
473
                    g.name AS group_name
474
            FROM releases r
475
            INNER JOIN categories c ON c.id = r.categories_id
476
            INNER JOIN root_categories cp ON cp.id = c.root_categories_id
477
            LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0 %s
478
            %s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s
479
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
480
            %s",
481
            $videoJoinCondition,
482
            $joinType,
483
            $episodeJoinCondition,
484
            $whereSql
485
        );
486
487
        $limitClause = '';
488
        if ($limit > 0) {
489
            $limitClause = sprintf(' LIMIT %d OFFSET %d', $limit, $offset);
490
        }
491
492
        $sql = sprintf('%s ORDER BY r.postdate DESC%s', $baseSql, $limitClause);
493
        $releases = Release::fromQuery($sql);
494
495
        if ($releases->isNotEmpty()) {
496
            $countSql = sprintf(
497
                'SELECT COUNT(*) as count FROM releases r %s %s %s',
498
                (! empty($videoJoinCondition) ? 'LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0' : ''),
499
                ($needsEpisodeJoin ? sprintf('%s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s', $joinType, $episodeJoinCondition) : ''),
500
                $whereSql
501
            );
502
            $countResult = Release::fromQuery($countSql);
503
            $releases[0]->_totalrows = $countResult[0]->count ?? 0;
504
        }
505
506
        if ($shouldCache && $cacheKey !== null) {
507
            $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
508
            Cache::put($cacheKey, $releases, $expiresAt);
509
        }
510
511
        return $releases;
512
    }
513
514
    /**
515
     * Search TV Shows via APIv2.
516
     *
517
     * @return Collection|mixed
518
     */
519
    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
520
    {
521
        // OPTIMIZATION: Try to find releases using search index external IDs first
522
        $externalIds = [];
523
        foreach ($siteIdArr as $column => $Id) {
524
            if ($Id > 0 && $column !== 'id') {
525
                $fieldName = match ($column) {
526
                    'tvdb' => 'tvdb',
527
                    'trakt' => 'traktid',
528
                    'tvmaze' => 'tvmaze',
529
                    'tvrage' => 'tvrage',
530
                    'imdb' => 'imdbid',
531
                    'tmdb' => 'tmdbid',
532
                    default => null,
533
                };
534
                if ($fieldName) {
535
                    $externalIds[$fieldName] = (int) $Id;
536
                }
537
            }
538
        }
539
540
        // Try to get releases directly from search index using external IDs
541
        $indexSearchResult = [];
542
        if (! empty($externalIds)) {
543
            $indexSearchResult = Search::searchReleasesByExternalId($externalIds, $limit * 2);
544
545
            if (config('app.debug') && ! empty($indexSearchResult)) {
546
                Log::debug('apiTvSearch: Found releases via search index by external IDs', [
547
                    'externalIds' => $externalIds,
548
                    'count' => count($indexSearchResult),
549
                ]);
550
            }
551
        }
552
553
        // Fall back to database lookup if index search didn't return results
554
        $siteSQL = [];
555
        $showSql = '';
556
        if (empty($indexSearchResult)) {
557
            foreach ($siteIdArr as $column => $Id) {
558
                if ($Id > 0) {
559
                    $siteSQL[] = sprintf('v.%s = %d', $column, $Id);
560
                }
561
            }
562
563
            if (\count($siteSQL) > 0) {
564
                $showQry = sprintf(
565
                    "\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",
566
                    implode(' OR ', $siteSQL),
567
                    ($series !== '' ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''),
568
                    ($episode !== '' ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''),
569
                    ($airDate !== '' ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '')
570
                );
571
                $show = Release::fromQuery($showQry);
572
                if ($show->isNotEmpty()) {
573
                    if ((! empty($episode) && ! empty($series)) && $show[0]->episodes !== '') {
574
                        $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series;
575
                    } elseif (! empty($episode) && $show[0]->episodes !== '') {
576
                        $showSql = sprintf('AND r.tv_episodes_id IN (%s)', $show[0]->episodes);
577
                    } elseif (! empty($series) && empty($episode)) {
578
                        $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series;
579
                    }
580
                    if ($show[0]->video > 0) {
581
                        $showSql .= ' AND r.videos_id = '.$show[0]->video;
582
                    }
583
                } else {
584
                    return [];
585
                }
586
            }
587
        }
588
        if (! empty($name) && $showSql === '' && empty($indexSearchResult)) {
589
            if (! empty($series) && (int) $series < 1900) {
590
                $name .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT));
591
                if (! empty($episode) && ! str_contains($episode, '/')) {
592
                    $name .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT));
593
                }
594
                if (empty($episode)) {
595
                    $name .= '*';
596
                }
597
            } elseif (! empty($airDate)) {
598
                $name .= sprintf(' %s', str_replace(['/', '-', '.', '_'], ' ', $airDate));
599
            }
600
        }
601
        $searchResult = $indexSearchResult; // Use index search result if we have it
602
        if (empty($searchResult) && ! empty($name)) {
603
            // Use the unified Search facade with fuzzy fallback
604
            $fuzzyResult = Search::searchReleasesWithFuzzy(['searchname' => $name], $limit);
605
            $searchResult = $fuzzyResult['ids'] ?? [];
606
607
            // Fall back to MySQL if search engine failed (only if enabled)
608
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
609
                $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit);
610
            }
611
612
            if (count($searchResult) === 0) {
613
                return collect();
614
            }
615
        }
616
        $whereSql = sprintf(
617
            'WHERE r.passwordstatus %s %s %s %s %s %s %s',
618
            $this->showPasswords(),
619
            $showSql,
620
            (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''),
621
            Category::getCategorySearch($cat, 'tv'),
622
            ($maxAge > 0 ? sprintf('AND r.postdate > NOW() - INTERVAL %d DAY', $maxAge) : ''),
623
            ($minSize > 0 ? sprintf('AND r.size >= %d', $minSize) : ''),
624
            ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : ''
625
        );
626
        $baseSql = sprintf(
627
            "SELECT r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
628
                r.tv_episodes_id, v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage,
629
                tve.series, tve.episode, tve.firstaired, cp.title AS parent_category, c.title AS sub_category,
630
                CONCAT(cp.title, ' > ', c.title) AS category_name, g.name AS group_name
631
            FROM releases r
632
            LEFT OUTER JOIN videos v ON r.videos_id = v.id AND v.type = 0
633
            LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id
634
            LEFT JOIN categories c ON c.id = r.categories_id
635
            LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
636
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
637
            %s",
638
            $whereSql
639
        );
640
        $sql = sprintf('%s ORDER BY postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset);
641
        $releases = Cache::get(md5($sql));
642
        if ($releases !== null) {
643
            return $releases;
644
        }
645
        $releases = Release::fromQuery($sql);
646
        if ($releases->isNotEmpty()) {
647
            $releases[0]->_totalrows = $this->getPagerCount(
648
                preg_replace('#LEFT(\s+OUTER)?\s+JOIN\s+(?!tv_episodes)\s+.*ON.*=.*\n#i', ' ', $baseSql)
649
            );
650
        }
651
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
652
        Cache::put(md5($sql), $releases, $expiresAt);
653
654
        return $releases;
655
    }
656
657
    /**
658
     * Search anime releases.
659
     *
660
     * @return Collection|mixed
661
     */
662
    public function animeSearch($aniDbID, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, array $excludedCategories = []): mixed
663
    {
664
        $searchResult = [];
665
        if (! empty($name)) {
666
            // Use the unified Search facade with fuzzy fallback
667
            $fuzzyResult = Search::searchReleasesWithFuzzy($name, $limit);
668
            $searchResult = $fuzzyResult['ids'] ?? [];
669
670
            // Fall back to MySQL if search engine returned no results (only if enabled)
671
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
672
                $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit);
673
            }
674
675
            if (count($searchResult) === 0) {
676
                return collect();
677
            }
678
        }
679
680
        $whereSql = sprintf(
681
            'WHERE r.passwordstatus %s
682
			%s %s %s %s %s',
683
            $this->showPasswords(),
684
            ($aniDbID > -1 ? sprintf(' AND r.anidbid = %d ', $aniDbID) : ''),
685
            (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''),
686
            ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : '',
687
            Category::getCategorySearch($cat),
688
            ($maxAge > 0 ? sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge) : '')
689
        );
690
        $baseSql = sprintf(
691
            "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,
692
				CONCAT(cp.title, ' > ', c.title) AS category_name,
693
				g.name AS group_name,
694
				rn.releases_id AS nfoid
695
			FROM releases r
696
			LEFT JOIN categories c ON c.id = r.categories_id
697
			LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
698
			LEFT JOIN usenet_groups g ON g.id = r.groups_id
699
			LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id
700
			%s",
701
            $whereSql
702
        );
703
        $sql = sprintf(
704
            '%s
705
			ORDER BY postdate DESC
706
			LIMIT %d OFFSET %d',
707
            $baseSql,
708
            $limit,
709
            $offset
710
        );
711
        $releases = Cache::get(md5($sql));
712
        if ($releases !== null) {
713
            return $releases;
714
        }
715
        $releases = Release::fromQuery($sql);
716
        if ($releases->isNotEmpty()) {
717
            $releases[0]->_totalrows = $this->getPagerCount($baseSql);
718
        }
719
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
720
        Cache::put(md5($sql), $releases, $expiresAt);
721
722
        return $releases;
723
    }
724
725
    /**
726
     * Movies search through API and site.
727
     *
728
     * @return Collection|mixed
729
     */
730
    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
731
    {
732
        $searchResult = [];
733
734
        // OPTIMIZATION: If we have external IDs, use the search index to find releases directly
735
        // This avoids expensive database JOINs by using indexed external ID fields in releases_rt
736
        $externalIds = [];
737
        if ($imDbId !== -1 && $imDbId > 0) {
738
            $externalIds['imdbid'] = $imDbId;
739
        }
740
        if ($tmDbId !== -1 && $tmDbId > 0) {
741
            $externalIds['tmdbid'] = $tmDbId;
742
        }
743
        if ($traktId !== -1 && $traktId > 0) {
744
            $externalIds['traktid'] = $traktId;
745
        }
746
747
        // Use search index for external ID lookups (much faster than database JOINs)
748
        if (! empty($externalIds)) {
749
            $searchResult = Search::searchReleasesByExternalId($externalIds, $limit * 2);
750
751
            if (config('app.debug') && ! empty($searchResult)) {
752
                Log::debug('moviesSearch: Found releases via search index by external IDs', [
753
                    'externalIds' => $externalIds,
754
                    'count' => count($searchResult),
755
                ]);
756
            }
757
        }
758
759
        // If no external IDs provided or index search failed, search by name
760
        if (empty($searchResult) && ! empty($name)) {
761
            // Use the unified Search facade with fuzzy fallback
762
            $fuzzyResult = Search::searchReleasesWithFuzzy($name, $limit);
763
            $searchResult = $fuzzyResult['ids'] ?? [];
764
765
            // Fall back to MySQL if search engine returned no results (only if enabled)
766
            if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
767
                $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit);
768
            }
769
770
            // Only return empty if we were specifically searching by name but found nothing
771
            if (empty($searchResult)) {
772
                return collect();
773
            }
774
        }
775
776
        // Build the base conditions for movie search
777
        // Note: we don't have MOVIE_ROOT constant that marks a parent category,
778
        // so we'll rely on the category search logic instead
779
        $conditions = [
780
            sprintf('r.passwordstatus %s', $this->showPasswords()),
781
        ];
782
783
        if (! empty($searchResult)) {
784
            $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
785
        }
786
787
        // When we have external IDs but no index results, fall back to database query
788
        // This handles the case where the index might be empty/out of sync
789
        $needsMovieJoin = false;
790
        if (empty($searchResult) && ! empty($externalIds)) {
791
            $needsMovieJoin = true;
792
            if ($imDbId !== -1 && $imDbId > 0) {
793
                $conditions[] = sprintf('r.imdbid = %d', $imDbId);
794
            }
795
            if ($tmDbId !== -1 && $tmDbId > 0) {
796
                $conditions[] = sprintf('m.tmdbid = %d', $tmDbId);
797
            }
798
            if ($traktId !== -1 && $traktId > 0) {
799
                $conditions[] = sprintf('m.traktid = %d', $traktId);
800
            }
801
        }
802
803
        if (! empty($excludedCategories)) {
804
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories)));
805
        }
806
807
        $catQuery = Category::getCategorySearch($cat, 'movies');
808
        $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery));
809
        if (! empty($catQuery) && $catQuery !== '1=1') {
810
            $conditions[] = $catQuery;
811
        }
812
        if ($maxAge > 0) {
813
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
814
        }
815
        if ($minSize > 0) {
816
            $conditions[] = sprintf('r.size >= %d', $minSize);
817
        }
818
819
        $whereSql = 'WHERE '.implode(' AND ', $conditions);
820
821
        // Only join movieinfo if we need to filter by tmdbid/traktid (database fallback)
822
        // When using search index, we already have the release IDs and don't need the join
823
        $joinSql = $needsMovieJoin ? 'INNER JOIN movieinfo m ON m.imdbid = r.imdbid' : 'LEFT JOIN movieinfo m ON m.id = r.movieinfo_id';
824
825
        // Select only fields required by XML/API transformers
826
        $baseSql = sprintf(
827
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.categories_id,
828
                    r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments,
829
                    r.adddate,
830
                    %s
831
                    cp.title AS parent_category, c.title AS sub_category,
832
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
833
                    g.name AS group_name
834
             FROM releases r
835
             INNER JOIN categories c ON c.id = r.categories_id
836
             INNER JOIN root_categories cp ON cp.id = c.root_categories_id
837
             %s
838
             LEFT JOIN usenet_groups g ON g.id = r.groups_id
839
             %s",
840
            'm.imdbid, m.tmdbid, m.traktid,',
841
            $joinSql,
842
            $whereSql
843
        );
844
845
        $sql = sprintf('%s ORDER BY r.postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset);
846
        $cacheKey = md5($sql.serialize(func_get_args()));
847
        if (($releases = Cache::get($cacheKey)) !== null) {
848
            return $releases;
849
        }
850
851
        $releases = Release::fromQuery($sql);
852
853
        if ($releases->isNotEmpty()) {
854
            // Optimize: Execute count query using same WHERE clause
855
            $countSql = sprintf(
856
                'SELECT COUNT(*) as count FROM releases r %s %s',
857
                $needsMovieJoin ? $joinSql : '',
858
                $whereSql
859
            );
860
            $countResult = DB::selectOne($countSql);
861
            $releases[0]->_totalrows = $countResult->count ?? 0;
862
        }
863
864
        Cache::put($cacheKey, $releases, now()->addMinutes(config('nntmux.cache_expiry_medium')));
865
866
        return $releases;
867
    }
868
869
    public function searchSimilar($currentID, $name, array $excludedCats = []): bool|array
870
    {
871
        // Get the category for the parent of this release.
872
        $ret = false;
873
        $currRow = Release::getCatByRelId($currentID);
874
        if ($currRow !== null) {
875
            $catRow = Category::find($currRow['categories_id']);
876
            $parentCat = $catRow !== null ? $catRow['root_categories_id'] : null;
877
878
            if ($parentCat === null) {
879
                return $ret;
880
            }
881
882
            $results = $this->search(['searchname' => getSimilarName($name)], -1, '', '', -1, -1, 0, config('nntmux.items_per_page'), '', -1, $excludedCats, 'basic', [$parentCat]);
883
            if (! $results) {
884
                return $ret;
885
            }
886
887
            $ret = [];
888
            foreach ($results as $res) {
889
                if ($res['id'] !== $currentID && $res['categoryparentid'] === $parentCat) {
890
                    $ret[] = $res;
891
                }
892
            }
893
        }
894
895
        return $ret;
896
    }
897
898
    /**
899
     * Perform index search using Elasticsearch or Manticore, with MySQL fallback
900
     */
901
    private function performIndexSearch(array $searchArr, int $limit): array
902
    {
903
        // Filter out -1 values and empty strings
904
        $searchFields = Arr::where($searchArr, static function ($value) {
905
            return $value !== -1 && $value !== '' && $value !== null;
906
        });
907
908
        if (empty($searchFields)) {
909
            if (config('app.debug')) {
910
                Log::debug('performIndexSearch: searchFields is empty after filtering', [
911
                    'original' => $searchArr,
912
                ]);
913
            }
914
915
            return [];
916
        }
917
918
        if (config('app.debug')) {
919
            Log::debug('performIndexSearch: starting search', [
920
                'search_driver' => config('search.default'),
921
                'searchFields' => $searchFields,
922
                'limit' => $limit,
923
            ]);
924
        }
925
926
        // Use the unified Search facade with fuzzy fallback
927
        // This will try exact search first, then fuzzy if no results
928
        $searchResult = Search::searchReleasesWithFuzzy($searchFields, $limit);
929
        $result = $searchResult['ids'] ?? [];
930
931
        if (config('app.debug')) {
932
            Log::debug('performIndexSearch: Search result', [
933
                'count' => count($result),
934
                'fuzzy_used' => $searchResult['fuzzy'] ?? false,
935
            ]);
936
        }
937
938
        // If search returned results, use them
939
        if (! empty($result)) {
940
            return $result;
941
        }
942
943
        // Fallback to MySQL LIKE search when search engine is unavailable (only if enabled)
944
        if (config('nntmux.mysql_search_fallback', false) === true) {
945
            if (config('app.debug')) {
946
                Log::debug('performIndexSearch: Falling back to MySQL search');
947
            }
948
949
            return $this->performMySQLSearch($searchFields, $limit);
950
        }
951
952
        return [];
953
    }
954
955
    /**
956
     * Fallback MySQL search when full-text search engines are unavailable
957
     */
958
    private function performMySQLSearch(array $searchFields, int $limit): array
959
    {
960
        try {
961
            $query = Release::query()->select('id');
962
963
            foreach ($searchFields as $field => $value) {
964
                if (! empty($value)) {
965
                    // Split search terms and search for each
966
                    $terms = preg_split('/\s+/', trim($value));
967
                    foreach ($terms as $term) {
968
                        $term = trim($term);
969
                        if (strlen($term) >= 2) {
970
                            $query->where($field, 'LIKE', '%'.$term.'%');
971
                        }
972
                    }
973
                }
974
            }
975
976
            $results = $query->limit($limit)->pluck('id')->toArray();
977
978
            if (config('app.debug')) {
979
                Log::debug('performMySQLSearch: MySQL fallback result count', ['count' => count($results)]);
980
            }
981
982
            return $results;
983
        } catch (\Throwable $e) {
984
            Log::error('performMySQLSearch: MySQL fallback failed', [
985
                'error' => $e->getMessage(),
986
            ]);
987
988
            return [];
989
        }
990
    }
991
992
    /**
993
     * Build WHERE clause for search query
994
     */
995
    private function buildSearchWhereClause(
996
        array $searchResult,
997
        $groupName,
998
        $sizeFrom,
999
        $sizeTo,
1000
        $daysNew,
1001
        $daysOld,
1002
        int $maxAge,
1003
        array $excludedCats,
1004
        string $type,
1005
        array $cat,
1006
        int $minSize
1007
    ): string {
1008
        $conditions = [
1009
            sprintf('r.passwordstatus %s', $this->showPasswords()),
1010
            sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))),
1011
        ];
1012
1013
        // Add optional conditions
1014
        if ($maxAge > 0) {
1015
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge);
1016
        }
1017
1018
        if ((int) $groupName !== -1) {
1019
            $groupId = UsenetGroup::getIDByName($groupName);
1020
            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...
1021
                $conditions[] = sprintf('r.groups_id = %d', $groupId);
1022
            }
1023
        }
1024
1025
        // Size conditions
1026
        $sizeConditions = $this->buildSizeConditions($sizeFrom, $sizeTo);
1027
        if (! empty($sizeConditions)) {
1028
            $conditions = array_merge($conditions, $sizeConditions);
1029
        }
1030
1031
        if ($minSize > 0) {
1032
            $conditions[] = sprintf('r.size >= %d', $minSize);
1033
        }
1034
1035
        // Category conditions - only add if not empty
1036
        $catQuery = $this->buildCategoryCondition($type, $cat);
1037
        if (! empty($catQuery) && $catQuery !== '1=1') {
1038
            $conditions[] = $catQuery;
1039
        }
1040
1041
        // Date conditions
1042
        if ((int) $daysNew !== -1) {
1043
            $conditions[] = sprintf('r.postdate < (NOW() - INTERVAL %d DAY)', $daysNew);
1044
        }
1045
1046
        if ((int) $daysOld !== -1) {
1047
            $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $daysOld);
1048
        }
1049
1050
        // Excluded categories
1051
        if (! empty($excludedCats)) {
1052
            $excludedCatsClean = array_map('intval', $excludedCats);
1053
            $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', $excludedCatsClean));
1054
        }
1055
1056
        return 'WHERE '.implode(' AND ', $conditions);
1057
    }
1058
1059
    /**
1060
     * Build size conditions for WHERE clause
1061
     */
1062
    private function buildSizeConditions($sizeFrom, $sizeTo): array
1063
    {
1064
        $sizeRange = [
1065
            1 => 1,
1066
            2 => 2.5,
1067
            3 => 5,
1068
            4 => 10,
1069
            5 => 20,
1070
            6 => 30,
1071
            7 => 40,
1072
            8 => 80,
1073
            9 => 160,
1074
            10 => 320,
1075
            11 => 640,
1076
        ];
1077
1078
        $conditions = [];
1079
1080
        if (array_key_exists($sizeFrom, $sizeRange)) {
1081
            $conditions[] = sprintf('r.size > %d', 104857600 * (int) $sizeRange[$sizeFrom]);
1082
        }
1083
1084
        if (array_key_exists($sizeTo, $sizeRange)) {
1085
            $conditions[] = sprintf('r.size < %d', 104857600 * (int) $sizeRange[$sizeTo]);
1086
        }
1087
1088
        return $conditions;
1089
    }
1090
1091
    /**
1092
     * Build category condition based on search type
1093
     */
1094
    private function buildCategoryCondition(string $type, array $cat): string
1095
    {
1096
        if ($type === 'basic') {
1097
            $catSearch = Category::getCategorySearch($cat);
1098
            // Remove WHERE and AND from the beginning as we're building it into a larger WHERE clause
1099
            $catSearch = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catSearch));
1100
1101
            // Don't return '1=1' as it's not needed
1102
            return ($catSearch === '1=1') ? '' : $catSearch;
1103
        }
1104
1105
        if ($type === 'advanced' && (int) $cat[0] !== -1) {
1106
            return sprintf('r.categories_id = %d', (int) $cat[0]);
1107
        }
1108
1109
        return '';
1110
    }
1111
1112
    /**
1113
     * Build base SQL for search query
1114
     */
1115
    private function buildSearchBaseSql(string $whereSql): string
1116
    {
1117
        return sprintf(
1118
            "SELECT r.id, r.searchname, r.guid, r.postdate, r.groups_id, r.categories_id, r.size,
1119
                    r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate,
1120
                    r.videos_id, r.tv_episodes_id, r.haspreview, r.jpgstatus,
1121
                    cp.title AS parent_category, c.title AS sub_category,
1122
                    CONCAT(cp.title, ' > ', c.title) AS category_name,
1123
                    df.failed AS failed,
1124
                    g.name AS group_name,
1125
                    rn.releases_id AS nfoid,
1126
                    re.releases_id AS reid,
1127
                    cp.id AS categoryparentid,
1128
                    v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb,
1129
                    tve.firstaired
1130
            FROM releases r
1131
            LEFT OUTER JOIN video_data re ON re.releases_id = r.id
1132
            LEFT OUTER JOIN videos v ON r.videos_id = v.id
1133
            LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id
1134
            LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id
1135
            LEFT JOIN usenet_groups g ON g.id = r.groups_id
1136
            LEFT JOIN categories c ON c.id = r.categories_id
1137
            LEFT JOIN root_categories cp ON cp.id = c.root_categories_id
1138
            LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id
1139
            %s",
1140
            $whereSql
1141
        );
1142
    }
1143
1144
    /**
1145
     * Get the passworded releases clause.
1146
     */
1147
    public function showPasswords(): string
1148
    {
1149
        $show = (int) Settings::settingValue('showpasswordedrelease');
1150
        $setting = $show ?? 0;
1151
1152
        return match ($setting) {
1153
            1 => '<= '.self::PASSWD_RAR,
1154
            default => '= '.self::PASSWD_NONE,
1155
        };
1156
    }
1157
1158
    /**
1159
     * Use to order releases on site.
1160
     */
1161
    public function getBrowseOrder(array|string $orderBy): array
1162
    {
1163
        $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

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