ReleaseSearchService::apiSearch()   F
last analyzed

Complexity

Conditions 22
Paths 4056

Size

Total Lines 116
Code Lines 72

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 72
c 1
b 0
f 0
dl 0
loc 116
rs 0
cc 22
nc 4056
nop 8

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\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