From 85b4904e6fc5b208b828dc2805b9d0fa357652d9 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Sat, 20 Dec 2025 21:47:01 +0100 Subject: [PATCH] Separate Blacklight/Releases into specific services --- Blacklight/Books.php | 2 +- Blacklight/Console.php | 2 +- Blacklight/Games.php | 2 +- Blacklight/Movie.php | 2 +- Blacklight/Music.php | 2 +- Blacklight/NZBExport.php | 7 +- Blacklight/ReleaseRemover.php | 7 +- Blacklight/XXX.php | 2 +- app/Console/Commands/CleanNZB.php | 8 +- .../Admin/AdminReleasesController.php | 13 +- app/Http/Controllers/Api/ApiController.php | 23 +- app/Http/Controllers/Api/ApiV2Controller.php | 26 +- app/Http/Controllers/Api/RSS.php | 8 +- app/Http/Controllers/BrowseController.php | 24 +- app/Http/Controllers/DetailsController.php | 15 +- app/Http/Controllers/MyMoviesController.php | 12 +- app/Http/Controllers/MyShowsController.php | 16 +- app/Http/Controllers/SearchController.php | 23 +- app/Http/Controllers/SeriesController.php | 13 +- app/Models/UsenetGroup.php | 4 +- .../AdultProcessingPipeline.php | 3 +- app/Services/ReleaseProcessingService.php | 10 +- .../Releases/ReleaseBrowseService.php | 411 ++++ .../Releases/ReleaseManagementService.php | 190 ++ .../Releases/ReleaseSearchService.php | 1816 +++++++---------- .../Search/ManticoreSearchService.php | 21 + 26 files changed, 1553 insertions(+), 1109 deletions(-) create mode 100644 app/Services/Releases/ReleaseBrowseService.php create mode 100644 app/Services/Releases/ReleaseManagementService.php rename Blacklight/Releases.php => app/Services/Releases/ReleaseSearchService.php (74%) diff --git a/Blacklight/Books.php b/Blacklight/Books.php index bf52ccc2d..b00885bac 100755 --- a/Blacklight/Books.php +++ b/Blacklight/Books.php @@ -127,7 +127,7 @@ class Books %s %s %s GROUP BY boo.id ORDER BY %s %s %s", - (new Releases)->showPasswords(), + app(\App\Services\Releases\ReleaseBrowseService::class)->showPasswords(), $browseby, $catsrch, $exccatlist, diff --git a/Blacklight/Console.php b/Blacklight/Console.php index 368343f45..b2bd818df 100755 --- a/Blacklight/Console.php +++ b/Blacklight/Console.php @@ -163,7 +163,7 @@ class Console %s %s %s GROUP BY con.id ORDER BY %s %s %s", - (new Releases)->showPasswords(), + app(\App\Services\Releases\ReleaseBrowseService::class)->showPasswords(), $browseBy, $catsrch, $exccatlist, diff --git a/Blacklight/Games.php b/Blacklight/Games.php index 0c435c1a2..5ae2e0ecd 100755 --- a/Blacklight/Games.php +++ b/Blacklight/Games.php @@ -205,7 +205,7 @@ class Games $order = $this->getGamesOrder($orderBy); $gamesSql = "SELECT SQL_CALC_FOUND_ROWS gi.id, GROUP_CONCAT(r.id ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_id FROM gamesinfo gi LEFT JOIN releases r ON gi.id = r.gamesinfo_id WHERE gi.title != '' AND gi.cover = 1 AND r.passwordstatus " - .(new Releases)->showPasswords(). + .app(\App\Services\Releases\ReleaseBrowseService::class)->showPasswords(). $browseBy. $catsrch. $maxAge. diff --git a/Blacklight/Movie.php b/Blacklight/Movie.php index 32f550ea2..0697f4008 100755 --- a/Blacklight/Movie.php +++ b/Blacklight/Movie.php @@ -129,7 +129,7 @@ class Movie $this->imdburl = (int) Settings::settingValue('imdburl') !== 0; $this->movieqty = Settings::settingValue('maximdbprocessed') !== '' ? (int) Settings::settingValue('maximdbprocessed') : 100; - $this->showPasswords = (new Releases)->showPasswords(); + $this->showPasswords = app(\App\Services\Releases\ReleaseBrowseService::class)->showPasswords(); $this->echooutput = config('nntmux.echocli'); $this->imgSavePath = storage_path('covers/movies/'); diff --git a/Blacklight/Music.php b/Blacklight/Music.php index 3d3fa0cfb..004bbe1b5 100755 --- a/Blacklight/Music.php +++ b/Blacklight/Music.php @@ -151,7 +151,7 @@ class Music %s %s %s GROUP BY m.id ORDER BY %s %s %s", - (new Releases)->showPasswords(), + app(\App\Services\Releases\ReleaseBrowseService::class)->showPasswords(), $browseby, $catsrch, $exccatlist, diff --git a/Blacklight/NZBExport.php b/Blacklight/NZBExport.php index b80ec02fd..e04f5587d 100755 --- a/Blacklight/NZBExport.php +++ b/Blacklight/NZBExport.php @@ -3,6 +3,7 @@ namespace Blacklight; use App\Models\UsenetGroup; +use App\Services\Releases\ReleaseManagementService; use Illuminate\Support\Facades\File; use Illuminate\Support\Str; @@ -17,7 +18,7 @@ class NZBExport protected NZB $nzb; - protected Releases $releases; + protected ReleaseManagementService $releaseManagement; protected bool $echoCLI; @@ -30,7 +31,7 @@ class NZBExport public function __construct() { $this->echoCLI = config('nntmux.echocli'); - $this->releases = new Releases; + $this->releaseManagement = app(ReleaseManagementService::class); $this->nzb = new NZB; } @@ -104,7 +105,7 @@ class NZBExport foreach ($groups as $group) { $currentExport = 0; // Get all the releases based on the parameters. - $releases = $this->releases->getForExport($fromDate, $toDate, $group['id']); + $releases = $this->releaseManagement->getForExport($fromDate, $toDate, $group['id']); $totalFound = \count($releases); if ($totalFound === 0) { if ($this->echoCLI) { diff --git a/Blacklight/ReleaseRemover.php b/Blacklight/ReleaseRemover.php index 5cad8cf56..f7b3261e4 100755 --- a/Blacklight/ReleaseRemover.php +++ b/Blacklight/ReleaseRemover.php @@ -5,6 +5,7 @@ namespace Blacklight; use App\Enums\BlacklistConstants; use App\Models\Category; use App\Models\Settings; +use App\Services\Releases\ReleaseManagementService; use Exception; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; @@ -41,7 +42,7 @@ class ReleaseRemover protected bool $ignoreUserCheck = false; protected string $method = ''; protected string $query = ''; - protected Releases $releases; + protected ReleaseManagementService $releaseManagement; protected array $result = []; private NZB $nzb; private ReleaseImage $releaseImage; @@ -54,7 +55,7 @@ class ReleaseRemover public function __construct() { $this->colorCLI = new ColorCLI; - $this->releases = new Releases; + $this->releaseManagement = app(ReleaseManagementService::class); $this->nzb = new NZB; $this->releaseImage = new ReleaseImage; $this->echoCLI = config('nntmux.echocli'); @@ -844,7 +845,7 @@ class ReleaseRemover $deletedCount = 0; foreach ($this->result as $release) { if ($this->delete) { - $this->releases->deleteSingle(['g' => $release->guid, 'i' => $release->id], $this->nzb, $this->releaseImage); + $this->releaseManagement->deleteSingle(['g' => $release->guid, 'i' => $release->id], $this->nzb, $this->releaseImage); if ($this->echoCLI) { $this->colorCLI->primary('Deleting: '.$this->method.': '.$release->searchname, true); } diff --git a/Blacklight/XXX.php b/Blacklight/XXX.php index e30514cc6..bbefb4030 100755 --- a/Blacklight/XXX.php +++ b/Blacklight/XXX.php @@ -48,7 +48,7 @@ class XXX $this->colorCli = new ColorCLI; $this->movieQty = Settings::settingValue('maxxxxprocessed') ?? 100; - $this->showPasswords = (new Releases)->showPasswords(); + $this->showPasswords = app(\App\Services\Releases\ReleaseBrowseService::class)->showPasswords(); $this->echoOutput = config('nntmux.echocli'); $this->imgSavePath = storage_path('covers/xxx/'); $this->cookie = resource_path('tmp/xxx.cookie'); diff --git a/app/Console/Commands/CleanNZB.php b/app/Console/Commands/CleanNZB.php index 3cbba41a9..ceff06484 100644 --- a/app/Console/Commands/CleanNZB.php +++ b/app/Console/Commands/CleanNZB.php @@ -3,9 +3,9 @@ namespace App\Console\Commands; use App\Models\Release; +use App\Services\Releases\ReleaseManagementService; use Blacklight\NZB; use Blacklight\ReleaseImage; -use Blacklight\Releases; use Illuminate\Console\Command; use Illuminate\Support\Collection; use Illuminate\Support\Facades\File; @@ -80,20 +80,20 @@ class CleanNZB extends Command { // Setup $nzb = new NZB; - $rel = new Releases; + $releaseManagement = app(ReleaseManagementService::class); $checked = $deleted = 0; $this->info('Getting list of releases from database to check if they have a corresponding NZB on disk'); $total = Release::count(); $this->alert("Total releases to check: $total"); - Release::where('nzbstatus', 1)->chunkById((int) $this->option('chunksize'), function (Collection $releases) use ($delete, &$checked, &$deleted, $nzb, $rel) { + Release::where('nzbstatus', 1)->chunkById((int) $this->option('chunksize'), function (Collection $releases) use ($delete, &$checked, &$deleted, $nzb, $releaseManagement) { echo 'Total done: '.$checked."\r"; foreach ($releases as $r) { if (! $nzb->NZBPath($r->guid)) { if ($delete) { - $rel->deleteSingle(['g' => $r->guid, 'i' => $r->id], $nzb, new ReleaseImage); + $releaseManagement->deleteSingle(['g' => $r->guid, 'i' => $r->id], $nzb, new ReleaseImage); } $deleted++; $this->line("Deleted: $r->searchname -> $r->guid"); diff --git a/app/Http/Controllers/Admin/AdminReleasesController.php b/app/Http/Controllers/Admin/AdminReleasesController.php index 328340bd2..c70b6d1da 100644 --- a/app/Http/Controllers/Admin/AdminReleasesController.php +++ b/app/Http/Controllers/Admin/AdminReleasesController.php @@ -5,11 +5,19 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; use App\Models\Category; use App\Models\Release; -use Blacklight\Releases; +use App\Services\Releases\ReleaseManagementService; use Illuminate\Http\Request; class AdminReleasesController extends BasePageController { + private ReleaseManagementService $releaseManagement; + + public function __construct(ReleaseManagementService $releaseManagement) + { + parent::__construct(); + $this->releaseManagement = $releaseManagement; + } + /** * @throws \Exception */ @@ -88,8 +96,7 @@ class AdminReleasesController extends BasePageController { try { if ($id) { - $releases = new Releases; - $releases->deleteMultiple($id); + $this->releaseManagement->deleteMultiple($id); // Handle AJAX requests if (request()->wantsJson() || request()->ajax()) { diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 854e5cb14..6fed59b55 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -12,8 +12,9 @@ use App\Models\UsenetGroup; use App\Models\User; use App\Models\UserDownload; use App\Models\UserRequest; +use App\Services\Releases\ReleaseBrowseService; +use App\Services\Releases\ReleaseSearchService; use Blacklight\NZB; -use Blacklight\Releases; use Illuminate\Contracts\Foundation\Application; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -27,6 +28,17 @@ use Symfony\Component\HttpFoundation\StreamedResponse; class ApiController extends BasePageController { private string $type; + private ReleaseSearchService $releaseSearchService; + private ReleaseBrowseService $releaseBrowseService; + + public function __construct( + ReleaseSearchService $releaseSearchService, + ReleaseBrowseService $releaseBrowseService + ) { + parent::__construct(); + $this->releaseSearchService = $releaseSearchService; + $this->releaseBrowseService = $releaseBrowseService; + } /** * @return Application|\Illuminate\Foundation\Application|RedirectResponse|Redirector|StreamedResponse|void @@ -119,7 +131,6 @@ class ApiController extends BasePageController } } - $releases = new Releases; // Set Query Parameters based on Request objects $outputXML = ! ($request->has('o') && $request->input('o') === 'json'); @@ -155,7 +166,7 @@ class ApiController extends BasePageController ]; if ($request->has('q')) { - $relData = $releases->search( + $relData = $this->releaseSearchService->search( $searchArr, $groupName, -1, @@ -172,7 +183,7 @@ class ApiController extends BasePageController $minSize ); } else { - $relData = $releases->getBrowseRange( + $relData = $this->releaseBrowseService->getBrowseRange( 1, $categoryID, $offset, @@ -220,7 +231,7 @@ class ApiController extends BasePageController $airDate = str_replace('/', '-', $year[0].'-'.$episode); } - $relData = $releases->tvSearch( + $relData = $this->releaseSearchService->tvSearch( $siteIdArr, $series, $episode, @@ -248,7 +259,7 @@ class ApiController extends BasePageController $tmdbId = $request->has('tmdbid') && $request->filled('tmdbid') ? (int) $request->input('tmdbid') : -1; $traktId = $request->has('traktid') && $request->filled('traktid') ? (int) $request->input('traktid') : -1; - $relData = $releases->moviesSearch( + $relData = $this->releaseSearchService->moviesSearch( $imdbId, $tmdbId, $traktId, diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 70e97d379..b03a60c81 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -10,10 +10,11 @@ use App\Models\Settings; use App\Models\User; use App\Models\UserDownload; use App\Models\UserRequest; +use App\Services\Releases\ReleaseBrowseService; +use App\Services\Releases\ReleaseSearchService; use App\Transformers\ApiTransformer; use App\Transformers\CategoryTransformer; use App\Transformers\DetailsTransformer; -use Blacklight\Releases; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -23,10 +24,17 @@ use Illuminate\Support\Facades\DB; class ApiV2Controller extends BasePageController { private ApiController $api; + private ReleaseSearchService $releaseSearchService; + private ReleaseBrowseService $releaseBrowseService; - public function __construct() - { - $this->api = new ApiController; + public function __construct( + ApiController $api, + ReleaseSearchService $releaseSearchService, + ReleaseBrowseService $releaseBrowseService + ) { + $this->api = $api; + $this->releaseSearchService = $releaseSearchService; + $this->releaseBrowseService = $releaseBrowseService; } public function capabilities(): JsonResponse @@ -93,7 +101,7 @@ class ApiV2Controller extends BasePageController ]; // Perform search - $relData = (new Releases)->moviesSearch( + $relData = $this->releaseSearchService->moviesSearch( $params['imdbId'], $params['tmdbId'], $params['traktId'], @@ -137,7 +145,6 @@ class ApiV2Controller extends BasePageController if ($request->missing('api_token') || $request->isNotFilled('api_token')) { return response()->json(['error' => 'Missing parameter (api_token)'], 403); } - $releases = new Releases; $user = User::query()->where('api_token', $request->input('api_token'))->first(); $offset = $this->api->offset($request); $catExclusions = User::getCategoryExclusionForApi($request); @@ -150,7 +157,7 @@ class ApiV2Controller extends BasePageController $limit = $this->api->limit($request); if ($request->has('id')) { - $relData = $releases->apiSearch( + $relData = $this->releaseSearchService->apiSearch( $request->input('id'), $groupName, $offset, @@ -161,7 +168,7 @@ class ApiV2Controller extends BasePageController $minSize ); } else { - $relData = $releases->getBrowseRange( + $relData = $this->releaseBrowseService->getBrowseRange( 1, $categoryID, $offset, @@ -202,7 +209,6 @@ class ApiV2Controller extends BasePageController if ($request->missing('api_token') || $request->isNotFilled('api_token')) { return response()->json(['error' => 'Missing parameter (api_token)'], 403); } - $releases = new Releases; $user = User::query()->where('api_token', $request->input('api_token'))->first(); if ($user === null) { return response()->json(['error' => 'Invalid API Token'], 403); @@ -242,7 +248,7 @@ class ApiV2Controller extends BasePageController $airDate = str_replace('/', '-', $year[0].'-'.$episode); } - $relData = $releases->apiTvSearch( + $relData = $this->releaseSearchService->apiTvSearch( $siteIdArr, $series, $episode, diff --git a/app/Http/Controllers/Api/RSS.php b/app/Http/Controllers/Api/RSS.php index f477d8b74..1011000db 100644 --- a/app/Http/Controllers/Api/RSS.php +++ b/app/Http/Controllers/Api/RSS.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers\Api; use App\Models\Category; use App\Models\Release; -use Blacklight\Releases; +use App\Services\Releases\ReleaseBrowseService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Cache; @@ -15,7 +15,7 @@ use Illuminate\Support\Facades\DB; */ class RSS extends ApiController { - public Releases $releases; + public ReleaseBrowseService $releaseBrowseService; /** * @throws \Exception @@ -23,7 +23,7 @@ class RSS extends ApiController public function __construct() { parent::__construct(); - $this->releases = new Releases; + $this->releaseBrowseService = app(ReleaseBrowseService::class); } /** @@ -72,7 +72,7 @@ class RSS extends ApiController %s %s %s %s ORDER BY postdate DESC %s", $cartSearch, - $this->releases->showPasswords(), + $this->releaseBrowseService->showPasswords(), $catSearch, ($videosId > 0 ? sprintf('AND r.videos_id = %d %s', $videosId, ($catSearch === '' ? $catLimit : '')) : ''), ($aniDbID > 0 ? sprintf('AND r.anidbid = %d %s', $aniDbID, ($catSearch === '' ? $catLimit : '')) : ''), diff --git a/app/Http/Controllers/BrowseController.php b/app/Http/Controllers/BrowseController.php index 3bc9320e2..56f4a6b74 100644 --- a/app/Http/Controllers/BrowseController.php +++ b/app/Http/Controllers/BrowseController.php @@ -4,24 +4,30 @@ namespace App\Http\Controllers; use App\Models\Category; use App\Models\RootCategory; -use Blacklight\Releases; +use App\Services\Releases\ReleaseBrowseService; use Illuminate\Http\Request; class BrowseController extends BasePageController { + private ReleaseBrowseService $releaseBrowseService; + + public function __construct(ReleaseBrowseService $releaseBrowseService) + { + parent::__construct(); + $this->releaseBrowseService = $releaseBrowseService; + } + /** * @throws \Exception */ public function index(Request $request) { - $releases = new Releases; - - $ordering = $releases->getBrowseOrdering(); + $ordering = $this->releaseBrowseService->getBrowseOrdering(); $orderBy = $request->has('ob') && ! empty($request->input('ob')) ? $request->input('ob') : ''; $page = $request->has('page') && is_numeric($request->input('page')) ? $request->input('page') : 1; $offset = ($page - 1) * config('nntmux.items_per_page'); - $rslt = $releases->getBrowseRange($page, [-1], $offset, config('nntmux.items_per_page'), $orderBy, -1, $this->userdata->categoryexclusions, -1); + $rslt = $this->releaseBrowseService->getBrowseRange($page, [-1], $offset, config('nntmux.items_per_page'), $orderBy, -1, $this->userdata->categoryexclusions, -1); $results = $this->paginate($rslt ?? [], $rslt[0]->_totalcount ?? 0, config('nntmux.items_per_page'), $page, $request->url(), $request->query()); // Build order by URLs @@ -48,7 +54,6 @@ class BrowseController extends BasePageController */ public function show(Request $request, string $parentCategory, string $id = 'All') { - $releases = new Releases; $parentId = RootCategory::query()->where('title', $parentCategory)->value('id'); @@ -65,12 +70,12 @@ class BrowseController extends BasePageController $catarray = []; $catarray[] = $category; - $ordering = $releases->getBrowseOrdering(); + $ordering = $this->releaseBrowseService->getBrowseOrdering(); $orderBy = $request->has('ob') && ! empty($request->input('ob')) ? $request->input('ob') : ''; $page = $request->has('page') && is_numeric($request->input('page')) ? $request->input('page') : 1; $offset = ($page - 1) * config('nntmux.items_per_page'); - $rslt = $releases->getBrowseRange($page, $catarray, $offset, config('nntmux.items_per_page'), $orderBy, -1, $this->userdata->categoryexclusions, $grp); + $rslt = $this->releaseBrowseService->getBrowseRange($page, $catarray, $offset, config('nntmux.items_per_page'), $orderBy, -1, $this->userdata->categoryexclusions, $grp); $results = $this->paginate($rslt ?? [], $rslt[0]->_totalcount ?? 0, config('nntmux.items_per_page'), $page, $request->url(), $request->query()); $covgroup = ''; @@ -139,12 +144,11 @@ class BrowseController extends BasePageController */ public function group(Request $request) { - $releases = new Releases; if ($request->has('g')) { $group = $request->input('g'); $page = $request->has('page') && is_numeric($request->input('page')) ? $request->input('page') : 1; $offset = ($page - 1) * config('nntmux.items_per_page'); - $rslt = $releases->getBrowseRange($page, [-1], $offset, config('nntmux.items_per_page'), '', -1, $this->userdata->categoryexclusions, $group); + $rslt = $this->releaseBrowseService->getBrowseRange($page, [-1], $offset, config('nntmux.items_per_page'), '', -1, $this->userdata->categoryexclusions, $group); $results = $this->paginate($rslt ?? [], $rslt[0]->_totalcount ?? 0, config('nntmux.items_per_page'), $page, $request->url(), $request->query()); $this->viewData = array_merge($this->viewData, [ diff --git a/app/Http/Controllers/DetailsController.php b/app/Http/Controllers/DetailsController.php index 425298a7c..4cb3b27cc 100644 --- a/app/Http/Controllers/DetailsController.php +++ b/app/Http/Controllers/DetailsController.php @@ -12,6 +12,7 @@ use App\Models\ReleaseRegex; use App\Models\Settings; use App\Models\UserDownload; use App\Models\Video; +use App\Services\Releases\ReleaseSearchService; use Blacklight\AniDB; use Blacklight\Books; use Blacklight\Console; @@ -19,16 +20,22 @@ use Blacklight\Games; use Blacklight\Movie; use Blacklight\Music; use Blacklight\ReleaseExtra; -use Blacklight\Releases; use Blacklight\XXX; use Illuminate\Http\Request; class DetailsController extends BasePageController { + private ReleaseSearchService $releaseSearchService; + + public function __construct(ReleaseSearchService $releaseSearchService) + { + parent::__construct(); + $this->releaseSearchService = $releaseSearchService; + } + public function show(Request $request, string $guid) { if ($guid !== null) { - $releases = new Releases; $re = new ReleaseExtra; $data = Release::getByGuid($guid); $releaseRegex = ''; @@ -52,7 +59,7 @@ class DetailsController extends BasePageController $reAudio = $re->getAudio($data['id']); $reSubs = $re->getSubs($data['id']); $comments = ReleaseComment::getComments($data['id']); - $similars = $releases->searchSimilar($data['id'], $data['searchname'], $this->userdata->categoryexclusions); + $similars = $this->releaseSearchService->searchSimilar($data['id'], $data['searchname'], $this->userdata->categoryexclusions); $failed = DnzbFailure::getFailedCount($data['id']); $downloadedBy = UserDownload::query()->with('user')->where('releases_id', $data['id'])->get(['users_id']); @@ -121,7 +128,7 @@ class DetailsController extends BasePageController $AniDBAPIArray = ''; if ($data['anidbid'] > 0) { $AniDBAPIArray = (new AniDB)->getAnimeInfo($data['anidbid']); - + // If we have anilist_id but missing details, fetch from AniList if ($AniDBAPIArray && !empty($AniDBAPIArray->anilist_id)) { $anilistId = is_object($AniDBAPIArray) ? $AniDBAPIArray->anilist_id : ($AniDBAPIArray['anilist_id'] ?? null); diff --git a/app/Http/Controllers/MyMoviesController.php b/app/Http/Controllers/MyMoviesController.php index b2f7cfdf6..d4db47fc1 100644 --- a/app/Http/Controllers/MyMoviesController.php +++ b/app/Http/Controllers/MyMoviesController.php @@ -5,12 +5,20 @@ namespace App\Http\Controllers; use App\Models\Category; use App\Models\Settings; use App\Models\UserMovie; +use App\Services\Releases\ReleaseBrowseService; use Blacklight\Movie; -use Blacklight\Releases; use Illuminate\Http\Request; class MyMoviesController extends BasePageController { + private ReleaseBrowseService $releaseBrowseService; + + public function __construct(ReleaseBrowseService $releaseBrowseService) + { + parent::__construct(); + $this->releaseBrowseService = $releaseBrowseService; + } + public function show(Request $request) { $mv = new Movie; @@ -141,7 +149,7 @@ class MyMoviesController extends BasePageController } } - $ordering = (new Releases)->getBrowseOrdering(); + $ordering = $this->releaseBrowseService->getBrowseOrdering(); $page = $request->has('page') && is_numeric($request->input('page')) ? $request->input('page') : 1; diff --git a/app/Http/Controllers/MyShowsController.php b/app/Http/Controllers/MyShowsController.php index dad2673db..a9a83265f 100644 --- a/app/Http/Controllers/MyShowsController.php +++ b/app/Http/Controllers/MyShowsController.php @@ -6,11 +6,19 @@ use App\Models\Category; use App\Models\Settings; use App\Models\UserSerie; use App\Models\Video; -use Blacklight\Releases; +use App\Services\Releases\ReleaseBrowseService; use Illuminate\Http\Request; class MyShowsController extends BasePageController { + private ReleaseBrowseService $releaseBrowseService; + + public function __construct(ReleaseBrowseService $releaseBrowseService) + { + parent::__construct(); + $this->releaseBrowseService = $releaseBrowseService; + } + public function show(Request $request) { $action = $request->input('action') ?? ''; @@ -168,15 +176,13 @@ class MyShowsController extends BasePageController $shows = UserSerie::getShows($this->userdata->id); - $releases = new Releases; - $page = $request->has('page') && is_numeric($request->input('page')) ? $request->input('page') : 1; $offset = ($page - 1) * config('nntmux.items_per_page'); - $ordering = $releases->getBrowseOrdering(); + $ordering = $this->releaseBrowseService->getBrowseOrdering(); $orderby = $request->has('ob') && \in_array($request->input('ob'), $ordering, false) ? $request->input('ob') : ''; $browseCount = $shows ? $shows->count() : 0; - $rslt = $releases->getShowsRange($shows ?? [], $offset, config('nntmux.items_per_page'), $orderby, -1, $this->userdata->categoryexclusions); + $rslt = $this->releaseBrowseService->getShowsRange($shows ?? [], $offset, config('nntmux.items_per_page'), $orderby, -1, $this->userdata->categoryexclusions); $results = $this->paginate($rslt ?? [], $browseCount, config('nntmux.items_per_page'), $page, $request->url(), $request->query()); $this->viewData['covgroup'] = ''; diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 525fa7aba..6e4f4924f 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -4,18 +4,26 @@ namespace App\Http\Controllers; use App\Models\Category; use App\Models\UsenetGroup; +use App\Services\Releases\ReleaseBrowseService; +use App\Services\Releases\ReleaseSearchService; use App\Services\Search\Contracts\SearchServiceInterface; -use Blacklight\Releases; use Illuminate\Http\Request; class SearchController extends BasePageController { private SearchServiceInterface $searchService; + private ReleaseSearchService $releaseSearchService; + private ReleaseBrowseService $releaseBrowseService; - public function __construct(SearchServiceInterface $searchService) - { + public function __construct( + SearchServiceInterface $searchService, + ReleaseSearchService $releaseSearchService, + ReleaseBrowseService $releaseBrowseService + ) { parent::__construct(); $this->searchService = $searchService; + $this->releaseSearchService = $releaseSearchService; + $this->releaseBrowseService = $releaseBrowseService; } /** @@ -23,7 +31,6 @@ class SearchController extends BasePageController */ public function search(Request $request) { - $releases = new Releases; $results = []; @@ -32,7 +39,7 @@ class SearchController extends BasePageController $searchType = 'advanced'; } - $ordering = $releases->getBrowseOrdering(); + $ordering = $this->releaseBrowseService->getBrowseOrdering(); $orderBy = ($request->has('ob') && \in_array($request->input('ob'), $ordering, false) ? $request->input('ob') : ''); $page = $request->has('page') && is_numeric($request->input('page')) ? $request->input('page') : 1; $offset = ($page - 1) * config('nntmux.items_per_page'); @@ -68,11 +75,11 @@ class SearchController extends BasePageController } $orderByUrls = []; - foreach ($releases->getBrowseOrdering() as $orderType) { + foreach ($this->releaseBrowseService->getBrowseOrdering() as $orderType) { $orderByUrls['orderby'.$orderType] = url('/search?search='.htmlentities($searchString['searchname'], ENT_QUOTES | ENT_HTML5).'&t='.implode(',', $categoryID).'&ob='.$orderType); } - $rslt = $releases->search( + $rslt = $this->releaseSearchService->search( $searchString, -1, -1, @@ -177,7 +184,7 @@ class SearchController extends BasePageController 'filename' => $searchVars['searchadvfilename'] === '' ? -1 : $searchVars['searchadvfilename'], ]; - $rslt = $releases->search( + $rslt = $this->releaseSearchService->search( $searchArr, $searchVars['searchadvgroups'], $searchVars['searchadvsizefrom'], diff --git a/app/Http/Controllers/SeriesController.php b/app/Http/Controllers/SeriesController.php index 1e9633923..6758d9ad1 100644 --- a/app/Http/Controllers/SeriesController.php +++ b/app/Http/Controllers/SeriesController.php @@ -5,19 +5,26 @@ namespace App\Http\Controllers; use App\Models\TvEpisode; use App\Models\UserSerie; use App\Models\Video; -use Blacklight\Releases; +use App\Services\Releases\ReleaseSearchService; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Arr; class SeriesController extends BasePageController { + private ReleaseSearchService $releaseSearchService; + + public function __construct(ReleaseSearchService $releaseSearchService) + { + parent::__construct(); + $this->releaseSearchService = $releaseSearchService; + } + /** * @throws \Exception */ public function index(Request $request, string $id = '') { - $releases = new Releases; if ($id && ctype_digit($id)) { $category = -1; @@ -33,7 +40,7 @@ class SeriesController extends BasePageController $page = max($page, 1); $offset = $seriesLimit > 0 ? ($page - 1) * $seriesLimit : 0; - $rel = $releases->tvSearch(['id' => $id], '', '', '', $offset, $seriesLimit, '', $catarray, -1); + $rel = $this->releaseSearchService->tvSearch(['id' => $id], '', '', '', $offset, $seriesLimit, '', $catarray, -1); $show = Video::getByVideoID($id); diff --git a/app/Models/UsenetGroup.php b/app/Models/UsenetGroup.php index 79344543e..08c806c7e 100644 --- a/app/Models/UsenetGroup.php +++ b/app/Models/UsenetGroup.php @@ -400,11 +400,11 @@ class UsenetGroup extends Model $res->get(); - $releases = new Releases; + $releaseManagement = app(\App\Services\Releases\ReleaseManagementService::class); $nzb = new NZB; $releaseImage = new ReleaseImage; foreach ($res as $row) { - $releases->deleteSingle( + $releaseManagement->deleteSingle( [ 'g' => $row['guid'], 'i' => $row['id'], diff --git a/app/Services/AdultProcessing/AdultProcessingPipeline.php b/app/Services/AdultProcessing/AdultProcessingPipeline.php index a54aaa103..6910aa968 100644 --- a/app/Services/AdultProcessing/AdultProcessingPipeline.php +++ b/app/Services/AdultProcessing/AdultProcessingPipeline.php @@ -17,7 +17,6 @@ use App\Services\AdultProcessing\Pipes\IafdPipe; use App\Services\AdultProcessing\Pipes\PoppornPipe; use Blacklight\ColorCLI; use Blacklight\ReleaseImage; -use Blacklight\Releases; use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Concurrency; @@ -74,7 +73,7 @@ class AdultProcessingPipeline // Try to get settings from database, but handle failures gracefully (e.g., in child processes) try { $this->movieQty = (int) (Settings::settingValue('maxxxxprocessed') ?? 100); - $this->showPasswords = (new Releases())->showPasswords(); + $this->showPasswords = app(\App\Services\Releases\ReleaseBrowseService::class)->showPasswords(); } catch (\Exception $e) { // Fallback values for child processes where DB might not be available $this->movieQty = 100; diff --git a/app/Services/ReleaseProcessingService.php b/app/Services/ReleaseProcessingService.php index 1f455472c..91281e653 100644 --- a/app/Services/ReleaseProcessingService.php +++ b/app/Services/ReleaseProcessingService.php @@ -13,6 +13,7 @@ use App\Models\Release; use App\Models\Settings; use App\Models\UsenetGroup; use App\Services\Categorization\CategorizationService; +use App\Services\Releases\ReleaseManagementService; use App\Support\DTOs\ProcessReleasesSettings; use App\Support\DTOs\ReleaseCreationResult; use App\Support\DTOs\ReleaseDeleteStats; @@ -22,7 +23,6 @@ use Blacklight\NNTP; use Blacklight\NZB; use Blacklight\ReleaseCleaning; use Blacklight\ReleaseImage; -use Blacklight\Releases; use DateTimeInterface; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -53,7 +53,7 @@ final class ReleaseProcessingService private readonly ColorCLI $colorCLI; private readonly NZB $nzb; private readonly ReleaseCleaning $releaseCleaning; - private readonly Releases $releases; + private readonly ReleaseManagementService $releaseManagement; private readonly ReleaseImage $releaseImage; private readonly ReleaseCreationService $releaseCreationService; private readonly CollectionCleanupService $collectionCleanupService; @@ -63,7 +63,7 @@ final class ReleaseProcessingService ?ColorCLI $colorCLI = null, ?NZB $nzb = null, ?ReleaseCleaning $releaseCleaning = null, - ?Releases $releases = null, + ?ReleaseManagementService $releaseManagement = null, ?ReleaseImage $releaseImage = null, ?ReleaseCreationService $releaseCreationService = null, ?CollectionCleanupService $collectionCleanupService = null, @@ -74,7 +74,7 @@ final class ReleaseProcessingService $this->colorCLI = $colorCLI ?? new ColorCLI(); $this->nzb = $nzb ?? new NZB(); $this->releaseCleaning = $releaseCleaning ?? new ReleaseCleaning(); - $this->releases = $releases ?? new Releases(); + $this->releaseManagement = $releaseManagement ?? app(ReleaseManagementService::class); $this->releaseImage = $releaseImage ?? new ReleaseImage(); $this->releaseCreationService = $releaseCreationService @@ -1217,7 +1217,7 @@ final class ReleaseProcessingService private function deleteSingleRelease(object $release): void { - $this->releases->deleteSingle( + $this->releaseManagement->deleteSingle( ['g' => $release->guid, 'i' => $release->id], $this->nzb, $this->releaseImage diff --git a/app/Services/Releases/ReleaseBrowseService.php b/app/Services/Releases/ReleaseBrowseService.php new file mode 100644 index 000000000..a463398aa --- /dev/null +++ b/app/Services/Releases/ReleaseBrowseService.php @@ -0,0 +1,411 @@ +getCacheVersion(); + $page = max(1, $page); + $start = max(0, $start); + + $orderBy = $this->getBrowseOrder($orderBy); + + $qry = sprintf( + "SELECT r.id, r.searchname, r.groups_id, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, r.videos_id, r.tv_episodes_id, r.haspreview, r.jpgstatus, r.nfostatus, cp.title AS parent_category, c.title AS sub_category, r.group_name, + CONCAT(cp.title, ' > ', c.title) AS category_name, + CONCAT(cp.id, ',', c.id) AS category_ids, + df.failed AS failed, + rn.releases_id AS nfoid, + re.releases_id AS reid, + v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb, + m.imdbid, m.tmdbid, m.traktid, + tve.title, tve.firstaired + FROM + ( + 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.videos_id, r.tv_episodes_id, r.haspreview, r.jpgstatus, r.nfostatus, g.name AS group_name, r.movieinfo_id + FROM releases r + LEFT JOIN usenet_groups g ON g.id = r.groups_id + WHERE r.passwordstatus %s + %s %s %s %s %s + ORDER BY %s %s %s + ) r + LEFT JOIN categories c ON c.id = r.categories_id + LEFT JOIN root_categories cp ON cp.id = c.root_categories_id + LEFT OUTER JOIN videos v ON r.videos_id = v.id + LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id + LEFT OUTER JOIN movieinfo m ON m.id = r.movieinfo_id + LEFT OUTER JOIN video_data re ON re.releases_id = r.id + LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id + LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id + GROUP BY r.id + ORDER BY %7\$s %8\$s", + $this->showPasswords(), + Category::getCategorySearch($cat), + ($maxAge > 0 ? (' AND postdate > NOW() - INTERVAL '.$maxAge.' DAY ') : ''), + (\count($excludedCats) ? (' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')') : ''), + ((int) $groupName !== -1 ? sprintf(' AND g.name = %s ', escapeString($groupName)) : ''), + ($minSize > 0 ? sprintf('AND r.size >= %d', $minSize) : ''), + $orderBy[0], + $orderBy[1], + ($start === 0 ? ' LIMIT '.$num : ' LIMIT '.$num.' OFFSET '.$start) + ); + + $cacheKey = md5($cacheVersion.$qry.$page); + $releases = Cache::get($cacheKey); + if ($releases !== null) { + return $releases; + } + $sql = DB::select($qry); + if (\count($sql) > 0) { + $possibleRows = $this->getBrowseCount($cat, $maxAge, $excludedCats, $groupName); + $sql[0]->_totalcount = $sql[0]->_totalrows = $possibleRows; + } + $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); + Cache::put($cacheKey, $sql, $expiresAt); + + return $sql; + } + + /** + * Used for pager on browse page. + */ + public function getBrowseCount(array $cat, int $maxAge = -1, array $excludedCats = [], int|string $groupName = ''): int + { + return $this->getPagerCount(sprintf( + 'SELECT COUNT(r.id) AS count + FROM releases r + %s + WHERE r.passwordstatus %s + %s + %s %s %s ', + ($groupName !== -1 ? 'LEFT JOIN usenet_groups g ON g.id = r.groups_id' : ''), + $this->showPasswords(), + ($groupName !== -1 ? sprintf(' AND g.name = %s', escapeString($groupName)) : ''), + Category::getCategorySearch($cat), + ($maxAge > 0 ? (' AND r.postdate > NOW() - INTERVAL '.$maxAge.' DAY ') : ''), + (\count($excludedCats) ? (' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')') : '') + )); + } + + /** + * Get the passworded releases clause. + */ + public function showPasswords(): string + { + $show = (int) Settings::settingValue('showpasswordedrelease'); + $setting = $show ?? 0; + + return match ($setting) { + 1 => '<= '.self::PASSWD_RAR, + default => '= '.self::PASSWD_NONE, + }; + } + + /** + * Use to order releases on site. + */ + public function getBrowseOrder(array|string $orderBy): array + { + $orderArr = explode('_', ($orderBy === '' ? 'posted_desc' : $orderBy)); + $orderField = match ($orderArr[0]) { + 'cat' => 'categories_id', + 'name' => 'searchname', + 'size' => 'size', + 'files' => 'totalpart', + 'stats' => 'grabs', + default => 'postdate', + }; + + return [$orderField, isset($orderArr[1]) && preg_match('/^(asc|desc)$/i', $orderArr[1]) ? $orderArr[1] : 'desc']; + } + + /** + * Return ordering types usable on site. + * + * @return string[] + */ + public function getBrowseOrdering(): array + { + return [ + 'name_asc', + 'name_desc', + 'cat_asc', + 'cat_desc', + 'posted_asc', + 'posted_desc', + 'size_asc', + 'size_desc', + 'files_asc', + 'files_desc', + 'stats_asc', + 'stats_desc', + ]; + } + + /** + * @return \Illuminate\Cache\|\Illuminate\Database\Eloquent\Collection|mixed + */ + public function getShowsRange($userShows, $offset, $limit, $orderBy, int $maxAge = -1, array $excludedCats = []) + { + $orderBy = $this->getBrowseOrder($orderBy); + $sql = sprintf( + "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.videos_id, r.tv_episodes_id, r.haspreview, r.jpgstatus, cp.title AS parent_category, c.title AS sub_category, + CONCAT(cp.title, '->', c.title) AS category_name + FROM releases r + LEFT JOIN categories c ON c.id = r.categories_id + LEFT JOIN root_categories cp ON cp.id = c.root_categories_id + WHERE %s %s + AND r.categories_id BETWEEN %d AND %d + AND r.passwordstatus %s + %s + GROUP BY r.id + ORDER BY %s %s %s", + $this->uSQL($userShows, 'videos_id'), + (! empty($excludedCats) ? ' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')' : ''), + Category::TV_ROOT, + Category::TV_OTHER, + $this->showPasswords(), + ($maxAge > 0 ? sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge) : ''), + $orderBy[0], + $orderBy[1], + ($offset === false ? '' : (' LIMIT '.$limit.' OFFSET '.$offset)) + ); + $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_long')); + $result = Cache::get(md5($sql)); + if ($result !== null) { + return $result; + } + $result = Release::fromQuery($sql); + Cache::put(md5($sql), $result, $expiresAt); + + return $result; + } + + public function getShowsCount($userShows, int $maxAge = -1, array $excludedCats = []): int + { + return $this->getPagerCount( + sprintf( + 'SELECT r.id + FROM releases r + WHERE %s %s + AND r.categories_id BETWEEN %d AND %d + AND r.passwordstatus %s + %s', + $this->uSQL($userShows, 'videos_id'), + (\count($excludedCats) ? ' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')' : ''), + Category::TV_ROOT, + Category::TV_OTHER, + $this->showPasswords(), + ($maxAge > 0 ? sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge) : '') + ) + ); + } + + /** + * Creates part of a query for some functions. + */ + public function uSQL(Collection|array $userQuery, string $type): string + { + $sql = '(1=2 '; + foreach ($userQuery as $query) { + $sql .= sprintf('OR (r.%s = %d', $type, $query->$type); + if (! empty($query->categories)) { + $catsArr = explode('|', $query->categories); + if (\count($catsArr) > 1) { + $sql .= sprintf(' AND r.categories_id IN (%s)', implode(',', $catsArr)); + } else { + $sql .= sprintf(' AND r.categories_id = %d', $catsArr[0]); + } + } + $sql .= ') '; + } + $sql .= ') '; + + return $sql; + } + + public static function bumpCacheVersion(): void + { + $current = Cache::get(self::CACHE_VERSION_KEY, 1); + Cache::forever(self::CACHE_VERSION_KEY, $current + 1); + } + + private function getCacheVersion(): int + { + return Cache::get(self::CACHE_VERSION_KEY, 1); + } + + /** + * Get the count of releases for pager. + * + * @param string $query The query to get the count from. + */ + private function getPagerCount(string $query): int + { + $maxResults = (int) config('nntmux.max_pager_results'); + $cacheExpiry = config('nntmux.cache_expiry_short'); + + // Generate cache key from original query + $cacheKey = 'pager_count_'.md5($query); + + // Check cache first + $count = Cache::get($cacheKey); + if ($count !== null) { + return (int) $count; + } + + // Check if this is already a COUNT query + if (preg_match('/SELECT\s+COUNT\s*\(/is', $query)) { + // It's already a COUNT query, just execute it + try { + $result = DB::select($query); + if (isset($result[0])) { + // Handle different possible column names + $count = $result[0]->count ?? $result[0]->total ?? 0; + // Check for COUNT(*) result without alias + if ($count === 0) { + foreach ($result[0] as $value) { + $count = (int) $value; + break; + } + } + } else { + $count = 0; + } + + // Cap the count at max results if applicable + if ($maxResults > 0 && $count > $maxResults) { + $count = $maxResults; + } + + // Cache the result + Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry)); + + return $count; + } catch (\Exception $e) { + return 0; + } + } + + // For regular SELECT queries, optimize for counting + $countQuery = $query; + + // Remove ORDER BY clause (not needed for COUNT) + $countQuery = preg_replace('/ORDER\s+BY\s+[^)]+$/is', '', $countQuery); + + // Remove GROUP BY if it's only grouping by r.id + $countQuery = preg_replace('/GROUP\s+BY\s+r\.id\s*$/is', '', $countQuery); + + // Check if query has DISTINCT in SELECT + $hasDistinct = preg_match('/SELECT\s+DISTINCT/is', $countQuery); + + // Replace SELECT clause with COUNT + if ($hasDistinct || preg_match('/GROUP\s+BY/is', $countQuery)) { + // For queries with DISTINCT or GROUP BY, count distinct r.id + $countQuery = preg_replace( + '/SELECT\s+.+?\s+FROM/is', + 'SELECT COUNT(DISTINCT r.id) as count FROM', + $countQuery + ); + } else { + // For simple queries, use COUNT(*) + $countQuery = preg_replace( + '/SELECT\s+.+?\s+FROM/is', + 'SELECT COUNT(*) as count FROM', + $countQuery + ); + } + + // Remove LIMIT/OFFSET from the count query + $countQuery = preg_replace('/LIMIT\s+\d+(\s+OFFSET\s+\d+)?$/is', '', $countQuery); + + try { + // If max results is set and query might return too many results + if ($maxResults > 0) { + // First check if count would exceed max + $testQuery = sprintf('SELECT 1 FROM (%s) as test LIMIT %d', + preg_replace('/SELECT\s+COUNT.+?\s+FROM/is', 'SELECT 1 FROM', $countQuery), + $maxResults + 1 + ); + + $testResult = DB::select($testQuery); + if (count($testResult) > $maxResults) { + Cache::put($cacheKey, $maxResults, now()->addMinutes($cacheExpiry)); + + return $maxResults; + } + } + + // Execute the count query + $result = DB::select($countQuery); + $count = isset($result[0]) ? (int) $result[0]->count : 0; + + // Cache the result + Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry)); + + return $count; + } catch (\Exception $e) { + // If optimization fails, try a simpler approach + try { + // Extract the core table and WHERE conditions + if (preg_match('/FROM\s+releases\s+r\s+(.+?)(?:ORDER\s+BY|LIMIT|$)/is', $query, $matches)) { + $conditions = $matches[1]; + // Remove JOINs but keep WHERE + $conditions = preg_replace('/(?:LEFT\s+|INNER\s+)?(?:OUTER\s+)?JOIN\s+.+?(?=WHERE|LEFT|INNER|JOIN|$)/is', '', $conditions); + + $fallbackQuery = sprintf('SELECT COUNT(*) as count FROM releases r %s', trim($conditions)); + + if ($maxResults > 0) { + $fallbackQuery = sprintf('SELECT COUNT(*) as count FROM (SELECT 1 FROM releases r %s LIMIT %d) as limited', + trim($conditions), + $maxResults + ); + } + + $result = DB::select($fallbackQuery); + $count = isset($result[0]) ? (int) $result[0]->count : 0; + + Cache::put($cacheKey, $count, now()->addMinutes($cacheExpiry)); + + return $count; + } + } catch (\Exception $fallbackException) { + // Log the error for debugging + \Illuminate\Support\Facades\Log::error('getPagerCount failed', [ + 'query' => $query, + 'error' => $fallbackException->getMessage(), + ]); + } + + return 0; + } + } +} + diff --git a/app/Services/Releases/ReleaseManagementService.php b/app/Services/Releases/ReleaseManagementService.php new file mode 100644 index 000000000..348421232 --- /dev/null +++ b/app/Services/Releases/ReleaseManagementService.php @@ -0,0 +1,190 @@ +manticoreSearch = $manticoreSearch; + } + + /** + * @throws \Exception + */ + public function deleteMultiple(int|array|string $list): void + { + $list = (array) $list; + + $nzb = new NZB; + $releaseImage = new ReleaseImage; + + foreach ($list as $identifier) { + $this->deleteSingle(['g' => $identifier, 'i' => false], $nzb, $releaseImage); + } + } + + /** + * Deletes a single release by GUID, and all the corresponding files. + * + * @param array $identifiers ['g' => Release GUID(mandatory), 'id => ReleaseID(optional, pass + * false)] + * + * @throws \Exception + */ + public function deleteSingle(array $identifiers, NZB $nzb, ReleaseImage $releaseImage): void + { + // Delete NZB from disk. + $nzbPath = $nzb->NZBPath($identifiers['g']); + if (! empty($nzbPath)) { + File::delete($nzbPath); + } + + // Delete images. + $releaseImage->delete($identifiers['g']); + + if (config('nntmux.elasticsearch_enabled') === true) { + if ($identifiers['i'] === false) { + $identifiers['i'] = Release::query()->where('guid', $identifiers['g'])->first(['id']); + if ($identifiers['i'] !== null) { + $identifiers['i'] = $identifiers['i']['id']; + } + } + if ($identifiers['i'] !== null) { + $params = [ + 'index' => 'releases', + 'id' => $identifiers['i'], + ]; + + try { + Elasticsearch::delete($params); + } catch (Missing404Exception $e) { + // we do nothing here just catch the error, we don't care if release is missing from ES, we are deleting it anyway + } + } + } else { + // Delete from Manticore + if ($identifiers['i'] === false) { + $release = Release::query()->where('guid', $identifiers['g'])->first(['id']); + if ($release !== null) { + $identifiers['i'] = $release->id; + } + } + if (!empty($identifiers['i'])) { + $this->manticoreSearch->deleteRelease((int) $identifiers['i']); + } + } + + // Delete from DB. + Release::whereGuid($identifiers['g'])->delete(); + } + + /** + * @return bool|int + */ + public function updateMulti($guids, $category, $grabs, $videoId, $episodeId, $anidbId, $imdbId) + { + if (! \is_array($guids) || \count($guids) < 1) { + return false; + } + + $update = [ + 'categories_id' => $category === -1 ? 'categories_id' : $category, + 'grabs' => $grabs, + 'videos_id' => $videoId, + 'tv_episodes_id' => $episodeId, + 'anidbid' => $anidbId, + 'imdbid' => $imdbId, + ]; + + return Release::query()->whereIn('guid', $guids)->update($update); + } + + /** + * @return Release[]|\Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Query\Builder[]|\Illuminate\Support\Collection + */ + public function getForExport(string $postFrom = '', string $postTo = '', string $groupID = '') + { + $query = Release::query() + ->select(['r.searchname', 'r.guid', 'g.name as gname', DB::raw("CONCAT(cp.title,'_',c.title) AS catName")]) + ->from('releases as r') + ->leftJoin('categories as c', 'c.id', '=', 'r.categories_id') + ->leftJoin('root_categories as cp', 'cp.id', '=', 'c.root_categories_id') + ->leftJoin('usenet_groups as g', 'g.id', '=', 'r.groups_id'); + + if ($groupID !== '') { + $query->where('r.groups_id', $groupID); + } + + if ($postFrom !== '') { + $dateParts = explode('/', $postFrom); + if (\count($dateParts) === 3) { + $query->where('r.postdate', '>', $dateParts[2].'-'.$dateParts[1].'-'.$dateParts[0].'00:00:00'); + } + } + + if ($postTo !== '') { + $dateParts = explode('/', $postTo); + if (\count($dateParts) === 3) { + $query->where('r.postdate', '<', $dateParts[2].'-'.$dateParts[1].'-'.$dateParts[0].'23:59:59'); + } + } + + return $query->get(); + } + + /** + * @return mixed|string + */ + public function getEarliestUsenetPostDate(): mixed + { + $row = Release::query()->selectRaw("DATE_FORMAT(min(postdate), '%d/%m/%Y') AS postdate")->first(); + + return $row === null ? '01/01/2014' : $row['postdate']; + } + + /** + * @return mixed|string + */ + public function getLatestUsenetPostDate(): mixed + { + $row = Release::query()->selectRaw("DATE_FORMAT(max(postdate), '%d/%m/%Y') AS postdate")->first(); + + return $row === null ? '01/01/2014' : $row['postdate']; + } + + public function getReleasedGroupsForSelect(bool $blnIncludeAll = true): array + { + $groups = Release::query() + ->selectRaw('DISTINCT g.id, g.name') + ->leftJoin('usenet_groups as g', 'g.id', '=', 'releases.groups_id') + ->get(); + $temp_array = []; + + if ($blnIncludeAll) { + $temp_array[-1] = '--All Groups--'; + } + + foreach ($groups as $group) { + $temp_array[$group['id']] = $group['name']; + } + + return $temp_array; + } +} + diff --git a/Blacklight/Releases.php b/app/Services/Releases/ReleaseSearchService.php similarity index 74% rename from Blacklight/Releases.php rename to app/Services/Releases/ReleaseSearchService.php index b5ea8c28e..cb290d324 100644 --- a/Blacklight/Releases.php +++ b/app/Services/Releases/ReleaseSearchService.php @@ -1,6 +1,6 @@ manticoreSearch = app(ManticoreSearchService::class); - $this->elasticSearch = app(ElasticSearchService::class); - } - - /** - * Used for Browse results. - * - * - * @return Collection|mixed - */ - public function getBrowseRange($page, $cat, $start, $num, $orderBy, int $maxAge = -1, array $excludedCats = [], int|string $groupName = -1, int $minSize = 0): mixed - { - $cacheVersion = $this->getCacheVersion(); - $page = max(1, $page); - $start = max(0, $start); - - $orderBy = $this->getBrowseOrder($orderBy); - - $qry = sprintf( - "SELECT r.id, r.searchname, r.groups_id, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, r.videos_id, r.tv_episodes_id, r.haspreview, r.jpgstatus, r.nfostatus, cp.title AS parent_category, c.title AS sub_category, r.group_name, - CONCAT(cp.title, ' > ', c.title) AS category_name, - CONCAT(cp.id, ',', c.id) AS category_ids, - df.failed AS failed, - rn.releases_id AS nfoid, - re.releases_id AS reid, - v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb, - m.imdbid, m.tmdbid, m.traktid, - tve.title, tve.firstaired - FROM - ( - 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.videos_id, r.tv_episodes_id, r.haspreview, r.jpgstatus, r.nfostatus, g.name AS group_name, r.movieinfo_id - FROM releases r - LEFT JOIN usenet_groups g ON g.id = r.groups_id - WHERE r.passwordstatus %s - %s %s %s %s %s - ORDER BY %s %s %s - ) r - LEFT JOIN categories c ON c.id = r.categories_id - LEFT JOIN root_categories cp ON cp.id = c.root_categories_id - LEFT OUTER JOIN videos v ON r.videos_id = v.id - LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id - LEFT OUTER JOIN movieinfo m ON m.id = r.movieinfo_id - LEFT OUTER JOIN video_data re ON re.releases_id = r.id - LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id - LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id - GROUP BY r.id - ORDER BY %7\$s %8\$s", - $this->showPasswords(), - Category::getCategorySearch($cat), - ($maxAge > 0 ? (' AND postdate > NOW() - INTERVAL '.$maxAge.' DAY ') : ''), - (\count($excludedCats) ? (' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')') : ''), - ((int) $groupName !== -1 ? sprintf(' AND g.name = %s ', escapeString($groupName)) : ''), - ($minSize > 0 ? sprintf('AND r.size >= %d', $minSize) : ''), - $orderBy[0], - $orderBy[1], - ($start === 0 ? ' LIMIT '.$num : ' LIMIT '.$num.' OFFSET '.$start) - ); - - $cacheKey = md5($cacheVersion.$qry.$page); - $releases = Cache::get($cacheKey); - if ($releases !== null) { - return $releases; - } - $sql = DB::select($qry); - if (\count($sql) > 0) { - $possibleRows = $this->getBrowseCount($cat, $maxAge, $excludedCats, $groupName); - $sql[0]->_totalcount = $sql[0]->_totalrows = $possibleRows; - } - $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); - Cache::put($cacheKey, $sql, $expiresAt); - - return $sql; - } - - /** - * Used for pager on browse page. - */ - public function getBrowseCount(array $cat, int $maxAge = -1, array $excludedCats = [], int|string $groupName = ''): int - { - return $this->getPagerCount(sprintf( - 'SELECT COUNT(r.id) AS count - FROM releases r - %s - WHERE r.passwordstatus %s - %s - %s %s %s ', - ($groupName !== -1 ? 'LEFT JOIN usenet_groups g ON g.id = r.groups_id' : ''), - $this->showPasswords(), - ($groupName !== -1 ? sprintf(' AND g.name = %s', escapeString($groupName)) : ''), - Category::getCategorySearch($cat), - ($maxAge > 0 ? (' AND r.postdate > NOW() - INTERVAL '.$maxAge.' DAY ') : ''), - (\count($excludedCats) ? (' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')') : '') - )); - } - - public function showPasswords(): string - { - $show = (int) Settings::settingValue('showpasswordedrelease'); - $setting = $show ?? 0; - - return match ($setting) { - 1 => '<= '.self::PASSWD_RAR, - default => '= '.self::PASSWD_NONE, - }; - } - - /** - * Use to order releases on site. - */ - public function getBrowseOrder(array|string $orderBy): array - { - $orderArr = explode('_', ($orderBy === '' ? 'posted_desc' : $orderBy)); - $orderField = match ($orderArr[0]) { - 'cat' => 'categories_id', - 'name' => 'searchname', - 'size' => 'size', - 'files' => 'totalpart', - 'stats' => 'grabs', - default => 'postdate', - }; - - return [$orderField, isset($orderArr[1]) && preg_match('/^(asc|desc)$/i', $orderArr[1]) ? $orderArr[1] : 'desc']; - } - - /** - * Return ordering types usable on site. - * - * @return string[] - */ - public function getBrowseOrdering(): array - { - return [ - 'name_asc', - 'name_desc', - 'cat_asc', - 'cat_desc', - 'posted_asc', - 'posted_desc', - 'size_asc', - 'size_desc', - 'files_asc', - 'files_desc', - 'stats_asc', - 'stats_desc', - ]; - } - - /** - * @return Release[]|\Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Query\Builder[]|\Illuminate\Support\Collection - */ - public function getForExport(string $postFrom = '', string $postTo = '', string $groupID = '') - { - $query = self::query() - ->select(['r.searchname', 'r.guid', 'g.name as gname', DB::raw("CONCAT(cp.title,'_',c.title) AS catName")]) - ->from('releases as r') - ->leftJoin('categories as c', 'c.id', '=', 'r.categories_id') - ->leftJoin('root_categories as cp', 'cp.id', '=', 'c.root_categories_id') - ->leftJoin('usenet_groups as g', 'g.id', '=', 'r.groups_id'); - - if ($groupID !== '') { - $query->where('r.groups_id', $groupID); - } - - if ($postFrom !== '') { - $dateParts = explode('/', $postFrom); - if (\count($dateParts) === 3) { - $query->where('r.postdate', '>', $dateParts[2].'-'.$dateParts[1].'-'.$dateParts[0].'00:00:00'); - } - } - - if ($postTo !== '') { - $dateParts = explode('/', $postTo); - if (\count($dateParts) === 3) { - $query->where('r.postdate', '<', $dateParts[2].'-'.$dateParts[1].'-'.$dateParts[0].'23:59:59'); - } - } - - return $query->get(); - } - - /** - * @return mixed|string - */ - public function getEarliestUsenetPostDate(): mixed - { - $row = self::query()->selectRaw("DATE_FORMAT(min(postdate), '%d/%m/%Y') AS postdate")->first(); - - return $row === null ? '01/01/2014' : $row['postdate']; - } - - /** - * @return mixed|string - */ - public function getLatestUsenetPostDate(): mixed - { - $row = self::query()->selectRaw("DATE_FORMAT(max(postdate), '%d/%m/%Y') AS postdate")->first(); - - return $row === null ? '01/01/2014' : $row['postdate']; - } - - public function getReleasedGroupsForSelect(bool $blnIncludeAll = true): array - { - $groups = self::query() - ->selectRaw('DISTINCT g.id, g.name') - ->leftJoin('usenet_groups as g', 'g.id', '=', 'releases.groups_id') - ->get(); - $temp_array = []; - - if ($blnIncludeAll) { - $temp_array[-1] = '--All Groups--'; - } - - foreach ($groups as $group) { - $temp_array[$group['id']] = $group['name']; - } - - return $temp_array; - } - - /** - * @return \Illuminate\Cache\|\Illuminate\Database\Eloquent\Collection|mixed - */ - public function getShowsRange($userShows, $offset, $limit, $orderBy, int $maxAge = -1, array $excludedCats = []) - { - $orderBy = $this->getBrowseOrder($orderBy); - $sql = sprintf( - "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.videos_id, r.tv_episodes_id, r.haspreview, r.jpgstatus, cp.title AS parent_category, c.title AS sub_category, - CONCAT(cp.title, '->', c.title) AS category_name - FROM releases r - LEFT JOIN categories c ON c.id = r.categories_id - LEFT JOIN root_categories cp ON cp.id = c.root_categories_id - WHERE %s %s - AND r.categories_id BETWEEN %d AND %d - AND r.passwordstatus %s - %s - GROUP BY r.id - ORDER BY %s %s %s", - $this->uSQL($userShows, 'videos_id'), - (! empty($excludedCats) ? ' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')' : ''), - Category::TV_ROOT, - Category::TV_OTHER, - $this->showPasswords(), - ($maxAge > 0 ? sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge) : ''), - $orderBy[0], - $orderBy[1], - ($offset === false ? '' : (' LIMIT '.$limit.' OFFSET '.$offset)) - ); - $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_long')); - $result = Cache::get(md5($sql)); - if ($result !== null) { - return $result; - } - $result = $this->fromQuery($sql); - Cache::put(md5($sql), $result, $expiresAt); - - return $result; - } - - public function getShowsCount($userShows, int $maxAge = -1, array $excludedCats = []): int - { - return $this->getPagerCount( - sprintf( - 'SELECT r.id - FROM releases r - WHERE %s %s - AND r.categories_id BETWEEN %d AND %d - AND r.passwordstatus %s - %s', - $this->uSQL($userShows, 'videos_id'), - (\count($excludedCats) ? ' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')' : ''), - Category::TV_ROOT, - Category::TV_OTHER, - $this->showPasswords(), - ($maxAge > 0 ? sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge) : '') - ) - ); - } - - /** - * @throws \Exception - */ - public function deleteMultiple(int|array|string $list): void - { - $list = (array) $list; - - $nzb = new NZB; - $releaseImage = new ReleaseImage; - - foreach ($list as $identifier) { - $this->deleteSingle(['g' => $identifier, 'i' => false], $nzb, $releaseImage); - } - } - - /** - * Deletes a single release by GUID, and all the corresponding files. - * - * @param array $identifiers ['g' => Release GUID(mandatory), 'id => ReleaseID(optional, pass - * false)] - * - * @throws \Exception - */ - public function deleteSingle(array $identifiers, NZB $nzb, ReleaseImage $releaseImage): void - { - // Delete NZB from disk. - $nzbPath = $nzb->NZBPath($identifiers['g']); - if (! empty($nzbPath)) { - File::delete($nzbPath); - } - - // Delete images. - $releaseImage->delete($identifiers['g']); - - if (config('nntmux.elasticsearch_enabled') === true) { - if ($identifiers['i'] === false) { - $identifiers['i'] = Release::query()->where('guid', $identifiers['g'])->first(['id']); - if ($identifiers['i'] !== null) { - $identifiers['i'] = $identifiers['i']['id']; - } - } - if ($identifiers['i'] !== null) { - $params = [ - 'index' => 'releases', - 'id' => $identifiers['i'], - ]; - - try { - Elasticsearch::delete($params); - } catch (Missing404Exception $e) { - // we do nothing here just catch the error, we don't care if release is missing from ES, we are deleting it anyway - } - } - } else { - // Delete from Manticore - if ($identifiers['i'] === false) { - $release = Release::query()->where('guid', $identifiers['g'])->first(['id']); - if ($release !== null) { - $identifiers['i'] = $release->id; - } - } - if (!empty($identifiers['i'])) { - $this->manticoreSearch->deleteRelease((int) $identifiers['i']); - } - } - - // Delete from DB. - self::whereGuid($identifiers['g'])->delete(); - } - - /** - * @return bool|int - */ - public function updateMulti($guids, $category, $grabs, $videoId, $episodeId, $anidbId, $imdbId) - { - if (! \is_array($guids) || \count($guids) < 1) { - return false; - } - - $update = [ - 'categories_id' => $category === -1 ? 'categories_id' : $category, - 'grabs' => $grabs, - 'videos_id' => $videoId, - 'tv_episodes_id' => $episodeId, - 'anidbid' => $anidbId, - 'imdbid' => $imdbId, - ]; - - return self::query()->whereIn('guid', $guids)->update($update); - } - - /** - * Creates part of a query for some functions. - */ - public function uSQL(Collection|array $userQuery, string $type): string - { - $sql = '(1=2 '; - foreach ($userQuery as $query) { - $sql .= sprintf('OR (r.%s = %d', $type, $query->$type); - if (! empty($query->categories)) { - $catsArr = explode('|', $query->categories); - if (\count($catsArr) > 1) { - $sql .= sprintf(' AND r.categories_id IN (%s)', implode(',', $catsArr)); - } else { - $sql .= sprintf(' AND r.categories_id = %d', $catsArr[0]); - } - } - $sql .= ') '; - } - $sql .= ') '; - - return $sql; + public function __construct( + ManticoreSearchService $manticoreSearch, + ElasticSearchService $elasticSearch + ) { + $this->manticoreSearch = $manticoreSearch; + $this->elasticSearch = $elasticSearch; } /** @@ -457,7 +58,7 @@ class Releases extends Release int $minSize = 0 ): mixed { if (config('app.debug')) { - Log::debug('Releases::search called', [ + Log::debug('ReleaseSearchService::search called', [ 'searchArr' => $searchArr, 'limit' => $limit, ]); @@ -467,7 +68,7 @@ class Releases extends Release $searchResult = $this->performIndexSearch($searchArr, $limit); if (config('app.debug')) { - Log::debug('Releases::search after performIndexSearch', [ + Log::debug('ReleaseSearchService::search after performIndexSearch', [ 'result_count' => count($searchResult), ]); } @@ -515,7 +116,7 @@ class Releases extends Release } // Execute query - $releases = $this->fromQuery($sql); + $releases = Release::fromQuery($sql); // Add total count for pagination if ($releases->isNotEmpty()) { @@ -530,17 +131,703 @@ class Releases extends Release } /** - * Perform index search using Elasticsearch or Manticore + * Search function for API. + * + * @return Collection|mixed + */ + public function apiSearch($searchName, $groupName, int $offset = 0, int $limit = 1000, int $maxAge = -1, array $excludedCats = [], array $cat = [-1], int $minSize = 0): mixed + { + if (config('app.debug')) { + Log::debug('ReleaseSearchService::apiSearch called', [ + 'searchName' => $searchName, + 'groupName' => $groupName, + 'offset' => $offset, + 'limit' => $limit, + ]); + } + + // Early return if searching with no results + $searchResult = []; + if ($searchName !== -1 && $searchName !== '' && $searchName !== null) { + // Try Elasticsearch first if enabled + if (config('nntmux.elasticsearch_enabled') === true) { + $searchResult = $this->elasticSearch->indexSearchApi($searchName, $limit); + } + + // Fall back to Manticore if Elasticsearch didn't return results + if (empty($searchResult)) { + $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $searchName, ['searchname']); + if (config('app.debug')) { + Log::debug('apiSearch: Manticore searchIndexes result', [ + 'searchName' => $searchName, + 'result' => $searchResult, + ]); + } + if (! empty($searchResult)) { + $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); + } + } + + // Fall back to MySQL if both Elasticsearch and Manticore failed + if (empty($searchResult)) { + if (config('app.debug')) { + Log::debug('apiSearch: Falling back to MySQL search'); + } + $searchResult = $this->performMySQLSearch(['searchname' => $searchName], $limit); + } + + if (empty($searchResult)) { + if (config('app.debug')) { + Log::debug('apiSearch: No results from any search engine'); + } + return collect(); + } + } + + $conditions = [ + sprintf('r.passwordstatus %s', $this->showPasswords()), + ]; + + if ($maxAge > 0) { + $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge); + } + + if ((int) $groupName !== -1) { + $groupId = UsenetGroup::getIDByName($groupName); + if ($groupId) { + $conditions[] = sprintf('r.groups_id = %d', $groupId); + } + } + + $catQuery = Category::getCategorySearch($cat); + $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery)); + if (! empty($catQuery) && $catQuery !== '1=1') { + $conditions[] = $catQuery; + } + + if (! empty($excludedCats)) { + $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCats))); + } + + if (! empty($searchResult)) { + $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))); + } + + if ($minSize > 0) { + $conditions[] = sprintf('r.size >= %d', $minSize); + } + + $whereSql = 'WHERE '.implode(' AND ', $conditions); + + // Optimized query: remove unused columns/joins (haspreview, jpgstatus, *_id columns, nfo/video_data/failures) + $sql = sprintf( + "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, + cp.title AS parent_category, c.title AS sub_category, + CONCAT(cp.title, ' > ', c.title) AS category_name, + g.name AS group_name, + m.imdbid, m.tmdbid, m.traktid, + v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb, + tve.firstaired, tve.title, tve.series, tve.episode + FROM releases r + INNER JOIN categories c ON c.id = r.categories_id + INNER JOIN root_categories cp ON cp.id = c.root_categories_id + LEFT JOIN usenet_groups g ON g.id = r.groups_id + LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0 + LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0 + LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0 + %s + ORDER BY r.postdate DESC + LIMIT %d OFFSET %d", + $whereSql, + $limit, + $offset + ); + + $cacheKey = md5($sql); + $cachedReleases = Cache::get($cacheKey); + if ($cachedReleases !== null) { + return $cachedReleases; + } + + $releases = Release::fromQuery($sql); + + if ($releases->isNotEmpty()) { + $countSql = sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql); + $countResult = Release::fromQuery($countSql); + $releases[0]->_totalrows = $countResult[0]->count ?? 0; + } + + $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); + Cache::put($cacheKey, $releases, $expiresAt); + + return $releases; + } + + /** + * Search for TV shows via API. + * + * @return array|\Illuminate\Cache\|\Illuminate\Database\Eloquent\Collection|\Illuminate\Support\Collection|mixed + */ + 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 + { + $shouldCache = ! (isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0); + $rawCacheKey = md5(serialize(func_get_args()).'tvSearch'); + $cacheKey = null; + if ($shouldCache) { + $cacheKey = md5($this->getCacheVersion().$rawCacheKey); + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + } + + $conditions = [ + sprintf('r.passwordstatus %s', $this->showPasswords()), + ]; + + $videoJoinCondition = ''; + $episodeJoinCondition = ''; + $needsEpisodeJoin = false; + + if (! empty($siteIdArr)) { + $siteConditions = []; + foreach ($siteIdArr as $column => $id) { + if ($id > 0) { + $siteConditions[] = sprintf('v.%s = %d', $column, (int) $id); + } + } + + if (! empty($siteConditions)) { + $siteUsesVideoIdOnly = count($siteConditions) === 1 && isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0; + + $seriesFilter = ($series !== '') ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''; + $episodeFilter = ($episode !== '') ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''; + $airDateFilter = ($airDate !== '') ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : ''; + + $lookupSql = sprintf( + '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', + implode(' OR ', $siteConditions), + $seriesFilter, + $episodeFilter, + $airDateFilter + ); + + $results = Release::fromQuery($lookupSql); + + if ($results->isEmpty()) { + return collect(); + } + + $videoIds = $results->pluck('video_id')->filter()->unique()->toArray(); + $episodeIds = $results->pluck('episode_id')->filter()->unique()->toArray(); + + if (! empty($videoIds)) { + $conditions[] = sprintf('r.videos_id IN (%s)', implode(',', array_map('intval', $videoIds))); + $videoJoinCondition = sprintf('AND v.id IN (%s)', implode(',', array_map('intval', $videoIds))); + } + + if (! empty($episodeIds) && ! $siteUsesVideoIdOnly) { + $conditions[] = sprintf('r.tv_episodes_id IN (%s)', implode(',', array_map('intval', $episodeIds))); + $episodeJoinCondition = sprintf('AND tve.id IN (%s)', implode(',', array_map('intval', $episodeIds))); + $needsEpisodeJoin = true; + } + + if ($siteUsesVideoIdOnly) { + $needsEpisodeJoin = false; + } + } + } + + $searchResult = []; + if (! empty($name)) { + $searchName = $name; + $hasValidSiteIds = false; + foreach ($siteIdArr as $column => $id) { + if ($id > 0) { + $hasValidSiteIds = true; + break; + } + } + + if (! $hasValidSiteIds) { + if (! empty($series) && (int) $series < 1900) { + $searchName .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT)); + $seriesNum = (int) preg_replace('/^s0*/i', '', $series); + $conditions[] = sprintf('tve.series = %d', $seriesNum); + $needsEpisodeJoin = true; + if (! empty($episode) && ! str_contains($episode, '/')) { + $searchName .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT)); + $episodeNum = (int) preg_replace('/^e0*/i', '', $episode); + $conditions[] = sprintf('tve.episode = %d', $episodeNum); + } + } elseif (! empty($airDate)) { + $searchName .= ' '.str_replace(['/', '-', '.', '_'], ' ', $airDate); + $conditions[] = sprintf('DATE(tve.firstaired) = %s', escapeString($airDate)); + $needsEpisodeJoin = true; + } + } + + // Try Elasticsearch first if enabled + if (config('nntmux.elasticsearch_enabled') === true) { + $searchResult = $this->elasticSearch->indexSearchTMA($searchName, $limit); + } + + // Fall back to Manticore if Elasticsearch didn't return results + if (empty($searchResult)) { + $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $searchName, ['searchname']); + if (! empty($searchResult)) { + $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); + } + } + + // Fall back to MySQL if both Elasticsearch and Manticore failed + if (empty($searchResult)) { + $searchResult = $this->performMySQLSearch(['searchname' => $searchName], $limit); + } + + if (empty($searchResult)) { + return collect(); + } + + $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))); + } + + $catQuery = Category::getCategorySearch($cat, 'tv'); + $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery)); + if (! empty($catQuery) && $catQuery !== '1=1') { + $conditions[] = $catQuery; + } + + if ($maxAge > 0) { + $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge); + } + if ($minSize > 0) { + $conditions[] = sprintf('r.size >= %d', $minSize); + } + if (! empty($excludedCategories)) { + $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories))); + } + + $whereSql = 'WHERE '.implode(' AND ', $conditions); + + $joinType = $needsEpisodeJoin ? 'INNER' : 'LEFT'; + + // Optimized select list – only fields required by XML (extended) and transformers + $baseSql = sprintf( + "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.videos_id, r.tv_episodes_id, + v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage, + tve.series, tve.episode, tve.firstaired, + cp.title AS parent_category, c.title AS sub_category, + CONCAT(cp.title, ' > ', c.title) AS category_name, + g.name AS group_name + FROM releases r + INNER JOIN categories c ON c.id = r.categories_id + INNER JOIN root_categories cp ON cp.id = c.root_categories_id + LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0 %s + %s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s + LEFT JOIN usenet_groups g ON g.id = r.groups_id + %s", + $videoJoinCondition, + $joinType, + $episodeJoinCondition, + $whereSql + ); + + $limitClause = ''; + if ($limit > 0) { + $limitClause = sprintf(' LIMIT %d OFFSET %d', $limit, $offset); + } + + $sql = sprintf('%s ORDER BY r.postdate DESC%s', $baseSql, $limitClause); + $releases = Release::fromQuery($sql); + + if ($releases->isNotEmpty()) { + $countSql = sprintf( + 'SELECT COUNT(*) as count FROM releases r %s %s %s', + (! empty($videoJoinCondition) ? 'LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0' : ''), + ($needsEpisodeJoin ? sprintf('%s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s', $joinType, $episodeJoinCondition) : ''), + $whereSql + ); + $countResult = Release::fromQuery($countSql); + $releases[0]->_totalrows = $countResult[0]->count ?? 0; + } + + if ($shouldCache && $cacheKey !== null) { + $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); + Cache::put($cacheKey, $releases, $expiresAt); + } + + return $releases; + } + + /** + * Search TV Shows via APIv2. + * + * @return Collection|mixed + */ + 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 + { + $siteSQL = []; + $showSql = ''; + foreach ($siteIdArr as $column => $Id) { + if ($Id > 0) { + $siteSQL[] = sprintf('v.%s = %d', $column, $Id); + } + } + + if (\count($siteSQL) > 0) { + $showQry = sprintf( + "\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", + implode(' OR ', $siteSQL), + ($series !== '' ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''), + ($episode !== '' ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''), + ($airDate !== '' ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '') + ); + $show = Release::fromQuery($showQry); + if ($show->isNotEmpty()) { + if ((! empty($episode) && ! empty($series)) && $show[0]->episodes !== '') { + $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series; + } elseif (! empty($episode) && $show[0]->episodes !== '') { + $showSql = sprintf('AND r.tv_episodes_id IN (%s)', $show[0]->episodes); + } elseif (! empty($series) && empty($episode)) { + $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series; + } + if ($show[0]->video > 0) { + $showSql .= ' AND r.videos_id = '.$show[0]->video; + } + } else { + return []; + } + } + if (! empty($name) && $showSql === '') { + if (! empty($series) && (int) $series < 1900) { + $name .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT)); + if (! empty($episode) && ! str_contains($episode, '/')) { + $name .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT)); + } + if (empty($episode)) { + $name .= '*'; + } + } elseif (! empty($airDate)) { + $name .= sprintf(' %s', str_replace(['/', '-', '.', '_'], ' ', $airDate)); + } + } + $searchResult = []; + if (! empty($name)) { + // Try Elasticsearch first if enabled + if (config('nntmux.elasticsearch_enabled') === true) { + $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit); + } + + // Fall back to Manticore if Elasticsearch didn't return results + if (empty($searchResult)) { + $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']); + if (! empty($searchResult)) { + $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); + } + } + + // Fall back to MySQL if both Elasticsearch and Manticore failed + if (empty($searchResult)) { + $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit); + } + + if (count($searchResult) === 0) { + return collect(); + } + } + $whereSql = sprintf( + 'WHERE r.passwordstatus %s %s %s %s %s %s %s', + $this->showPasswords(), + $showSql, + (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''), + Category::getCategorySearch($cat, 'tv'), + ($maxAge > 0 ? sprintf('AND r.postdate > NOW() - INTERVAL %d DAY', $maxAge) : ''), + ($minSize > 0 ? sprintf('AND r.size >= %d', $minSize) : ''), + ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : '' + ); + $baseSql = sprintf( + "SELECT r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, + r.tv_episodes_id, v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage, + tve.series, tve.episode, tve.firstaired, cp.title AS parent_category, c.title AS sub_category, + CONCAT(cp.title, ' > ', c.title) AS category_name, g.name AS group_name + FROM releases r + LEFT OUTER JOIN videos v ON r.videos_id = v.id AND v.type = 0 + LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id + LEFT JOIN categories c ON c.id = r.categories_id + LEFT JOIN root_categories cp ON cp.id = c.root_categories_id + LEFT JOIN usenet_groups g ON g.id = r.groups_id + %s", + $whereSql + ); + $sql = sprintf('%s ORDER BY postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset); + $releases = Cache::get(md5($sql)); + if ($releases !== null) { + return $releases; + } + $releases = Release::fromQuery($sql); + if ($releases->isNotEmpty()) { + $releases[0]->_totalrows = $this->getPagerCount( + preg_replace('#LEFT(\s+OUTER)?\s+JOIN\s+(?!tv_episodes)\s+.*ON.*=.*\n#i', ' ', $baseSql) + ); + } + $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); + Cache::put(md5($sql), $releases, $expiresAt); + + return $releases; + } + + /** + * Search anime releases. + * + * @return Collection|mixed + */ + public function animeSearch($aniDbID, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, array $excludedCategories = []): mixed + { + $searchResult = []; + if (! empty($name)) { + // Try Elasticsearch first if enabled + if (config('nntmux.elasticsearch_enabled') === true) { + $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit); + } + + // Fall back to Manticore if Elasticsearch didn't return results + if (empty($searchResult)) { + $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']); + if (! empty($searchResult)) { + $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); + } + } + + // Fall back to MySQL if both Elasticsearch and Manticore failed + if (empty($searchResult)) { + $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit); + } + + if (count($searchResult) === 0) { + return collect(); + } + } + + $whereSql = sprintf( + 'WHERE r.passwordstatus %s + %s %s %s %s %s', + $this->showPasswords(), + ($aniDbID > -1 ? sprintf(' AND r.anidbid = %d ', $aniDbID) : ''), + (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''), + ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : '', + Category::getCategorySearch($cat), + ($maxAge > 0 ? sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge) : '') + ); + $baseSql = sprintf( + "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, + CONCAT(cp.title, ' > ', c.title) AS category_name, + g.name AS group_name, + rn.releases_id AS nfoid + FROM releases r + LEFT JOIN categories c ON c.id = r.categories_id + LEFT JOIN root_categories cp ON cp.id = c.root_categories_id + LEFT JOIN usenet_groups g ON g.id = r.groups_id + LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id + %s", + $whereSql + ); + $sql = sprintf( + '%s + ORDER BY postdate DESC + LIMIT %d OFFSET %d', + $baseSql, + $limit, + $offset + ); + $releases = Cache::get(md5($sql)); + if ($releases !== null) { + return $releases; + } + $releases = Release::fromQuery($sql); + if ($releases->isNotEmpty()) { + $releases[0]->_totalrows = $this->getPagerCount($baseSql); + } + $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); + Cache::put(md5($sql), $releases, $expiresAt); + + return $releases; + } + + /** + * Movies search through API and site. + * + * @return Collection|mixed + */ + 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 + { + // Early return if searching by name yields no results + $searchResult = []; + if (! empty($name)) { + // Try Elasticsearch first if enabled + if (config('nntmux.elasticsearch_enabled') === true) { + $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit); + } + + // Fall back to Manticore if Elasticsearch didn't return results + if (empty($searchResult)) { + $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']); + if (! empty($searchResult)) { + $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); + } + } + + // Fall back to MySQL if both Elasticsearch and Manticore failed + if (empty($searchResult)) { + $searchResult = $this->performMySQLSearch(['searchname' => $name], $limit); + } + + if (empty($searchResult)) { + return collect(); + } + } + + $conditions = [ + sprintf('r.categories_id BETWEEN %d AND %d', Category::MOVIE_ROOT, Category::MOVIE_OTHER), + sprintf('r.passwordstatus %s', $this->showPasswords()), + ]; + + if (! empty($searchResult)) { + $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))); + } + + // Optimize: Join on imdbid directly (both tables have indexed imdbid columns) + // This is more efficient than joining through movieinfo_id + // Always join movieinfo to get imdbid, tmdbid, and traktid fields consistently + + if ($imDbId !== -1 && $imDbId) { + // Filter on r.imdbid (uses index ix_releases_imdbid) + // The join on m.imdbid = r.imdbid will also use the index + $conditions[] = sprintf('r.imdbid = %d', $imDbId); + } + + if ($tmDbId !== -1 && $tmDbId) { + $conditions[] = sprintf('m.tmdbid = %d', $tmDbId); + } + + if ($traktId !== -1 && $traktId) { + $conditions[] = sprintf('m.traktid = %d', $traktId); + } + + if (! empty($excludedCategories)) { + $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories))); + } + + $catQuery = Category::getCategorySearch($cat, 'movies'); + $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery)); + if (! empty($catQuery) && $catQuery !== '1=1') { + $conditions[] = $catQuery; + } + if ($maxAge > 0) { + $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge); + } + if ($minSize > 0) { + $conditions[] = sprintf('r.size >= %d', $minSize); + } + + $whereSql = 'WHERE '.implode(' AND ', $conditions); + // Always join on imdbid directly (both tables have indexed imdbid) - more efficient than movieinfo_id + // Both ix_releases_imdbid and ix_movieinfo_imdbid are indexed, making this join very fast + // This ensures we always get imdbid, tmdbid, and traktid fields in results + $joinSql = 'INNER JOIN movieinfo m ON m.imdbid = r.imdbid'; + + // Select only fields required by XML/API transformers + $baseSql = sprintf( + "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, + %s + cp.title AS parent_category, c.title AS sub_category, + CONCAT(cp.title, ' > ', c.title) AS category_name, + g.name AS group_name + FROM releases r + INNER JOIN categories c ON c.id = r.categories_id + INNER JOIN root_categories cp ON cp.id = c.root_categories_id + %s + LEFT JOIN usenet_groups g ON g.id = r.groups_id + %s", + 'm.imdbid, m.tmdbid, m.traktid,', + $joinSql, + $whereSql + ); + + $sql = sprintf('%s ORDER BY r.postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset); + $cacheKey = md5($sql.serialize(func_get_args())); + if (($releases = Cache::get($cacheKey)) !== null) { + return $releases; + } + + $releases = Release::fromQuery($sql); + + if ($releases->isNotEmpty()) { + // Optimize: Execute count query using same WHERE clause (uses same indexes) + // The count query is lightweight and can use index-only scans when possible + // Use same join logic as main query (join on imdbid when needed) + $countSql = sprintf( + 'SELECT COUNT(*) as count FROM releases r %s %s', + $joinSql, + $whereSql + ); + $countResult = DB::selectOne($countSql); + $releases[0]->_totalrows = $countResult->count ?? 0; + } + + Cache::put($cacheKey, $releases, now()->addMinutes(config('nntmux.cache_expiry_medium'))); + + return $releases; + } + + public function searchSimilar($currentID, $name, array $excludedCats = []): bool|array + { + // Get the category for the parent of this release. + $ret = false; + $currRow = Release::getCatByRelId($currentID); + if ($currRow !== null) { + $catRow = Category::find($currRow['categories_id']); + $parentCat = $catRow !== null ? $catRow['root_categories_id'] : null; + + if ($parentCat === null) { + return $ret; + } + + $results = $this->search(['searchname' => getSimilarName($name)], -1, '', '', -1, -1, 0, config('nntmux.items_per_page'), '', -1, $excludedCats, 'basic', [$parentCat]); + if (! $results) { + return $ret; + } + + $ret = []; + foreach ($results as $res) { + if ($res['id'] !== $currentID && $res['categoryparentid'] === $parentCat) { + $ret[] = $res; + } + } + } + + return $ret; + } + + /** + * Perform index search using Elasticsearch or Manticore, with MySQL fallback */ private function performIndexSearch(array $searchArr, int $limit): array { + // Filter out -1 values and empty strings $searchFields = Arr::where($searchArr, static function ($value) { - return $value !== -1; + return $value !== -1 && $value !== '' && $value !== null; }); if (empty($searchFields)) { if (config('app.debug')) { - Log::debug('performIndexSearch: searchFields is empty'); + Log::debug('performIndexSearch: searchFields is empty after filtering', [ + 'original' => $searchArr, + ]); } return []; } @@ -552,22 +839,83 @@ class Releases extends Release Log::debug('performIndexSearch: starting search', [ 'elasticsearch_enabled' => $esEnabled, 'elasticsearch_enabled_type' => gettype($esEnabled), + 'searchFields' => $searchFields, 'phrases' => $phrases, 'limit' => $limit, ]); } + // Try Elasticsearch first if enabled if ($esEnabled === true) { $result = $this->elasticSearch->indexSearch($phrases, $limit); if (config('app.debug')) { Log::debug('performIndexSearch: Elasticsearch result count', ['count' => count($result)]); } - return $result; + // If Elasticsearch returned results, use them + if (!empty($result)) { + return $result; + } + // Otherwise fall through to Manticore + if (config('app.debug')) { + Log::debug('performIndexSearch: Elasticsearch returned empty, falling back to Manticore'); + } } + // Try Manticore search $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', '', [], $searchFields); - return ! empty($searchResult) ? Arr::wrap(Arr::get($searchResult, 'id')) : []; + if (config('app.debug')) { + Log::debug('performIndexSearch: Manticore result', [ + 'result' => $searchResult, + ]); + } + + if (!empty($searchResult) && !empty($searchResult['id'])) { + return Arr::wrap(Arr::get($searchResult, 'id')); + } + + // Fallback to MySQL LIKE search when both Elasticsearch and Manticore are unavailable + if (config('app.debug')) { + Log::debug('performIndexSearch: Falling back to MySQL search'); + } + + return $this->performMySQLSearch($searchFields, $limit); + } + + /** + * Fallback MySQL search when full-text search engines are unavailable + */ + private function performMySQLSearch(array $searchFields, int $limit): array + { + try { + $query = Release::query()->select('id'); + + foreach ($searchFields as $field => $value) { + if (!empty($value)) { + // Split search terms and search for each + $terms = preg_split('/\s+/', trim($value)); + foreach ($terms as $term) { + $term = trim($term); + if (strlen($term) >= 2) { + $query->where($field, 'LIKE', '%' . $term . '%'); + } + } + } + } + + $results = $query->limit($limit)->pluck('id')->toArray(); + + if (config('app.debug')) { + Log::debug('performMySQLSearch: MySQL fallback result count', ['count' => count($results)]); + } + + return $results; + } catch (\Throwable $e) { + Log::error('performMySQLSearch: MySQL fallback failed', [ + 'error' => $e->getMessage(), + ]); + return []; + } } /** @@ -723,302 +1071,35 @@ class Releases extends Release } /** - * Search function for API. - * - * @return Collection|mixed + * Get the passworded releases clause. */ - public function apiSearch($searchName, $groupName, int $offset = 0, int $limit = 1000, int $maxAge = -1, array $excludedCats = [], array $cat = [-1], int $minSize = 0): mixed + public function showPasswords(): string { - // Early return if searching with no results - $searchResult = []; - if ($searchName !== -1) { - if (config('nntmux.elasticsearch_enabled') === true) { - $searchResult = $this->elasticSearch->indexSearchApi($searchName, $limit); - } else { - $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $searchName, ['searchname']); - if (! empty($searchResult)) { - $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); - } - } + $show = (int) Settings::settingValue('showpasswordedrelease'); + $setting = $show ?? 0; - if (empty($searchResult)) { - return collect(); - } - } - - $conditions = [ - sprintf('r.passwordstatus %s', $this->showPasswords()), - ]; - - if ($maxAge > 0) { - $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge); - } - - if ((int) $groupName !== -1) { - $groupId = UsenetGroup::getIDByName($groupName); - if ($groupId) { - $conditions[] = sprintf('r.groups_id = %d', $groupId); - } - } - - $catQuery = Category::getCategorySearch($cat); - $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery)); - if (! empty($catQuery) && $catQuery !== '1=1') { - $conditions[] = $catQuery; - } - - if (! empty($excludedCats)) { - $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCats))); - } - - if (! empty($searchResult)) { - $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))); - } - - if ($minSize > 0) { - $conditions[] = sprintf('r.size >= %d', $minSize); - } - - $whereSql = 'WHERE '.implode(' AND ', $conditions); - - // Optimized query: remove unused columns/joins (haspreview, jpgstatus, *_id columns, nfo/video_data/failures) - $sql = sprintf( - "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, - cp.title AS parent_category, c.title AS sub_category, - CONCAT(cp.title, ' > ', c.title) AS category_name, - g.name AS group_name, - m.imdbid, m.tmdbid, m.traktid, - v.tvdb, v.trakt, v.tvrage, v.tvmaze, v.imdb, v.tmdb, - tve.firstaired, tve.title, tve.series, tve.episode - FROM releases r - INNER JOIN categories c ON c.id = r.categories_id - INNER JOIN root_categories cp ON cp.id = c.root_categories_id - LEFT JOIN usenet_groups g ON g.id = r.groups_id - LEFT JOIN videos v ON r.videos_id = v.id AND r.videos_id > 0 - LEFT JOIN tv_episodes tve ON r.tv_episodes_id = tve.id AND r.tv_episodes_id > 0 - LEFT JOIN movieinfo m ON m.id = r.movieinfo_id AND r.movieinfo_id > 0 - %s - ORDER BY r.postdate DESC - LIMIT %d OFFSET %d", - $whereSql, - $limit, - $offset - ); - - $cacheKey = md5($sql); - $cachedReleases = Cache::get($cacheKey); - if ($cachedReleases !== null) { - return $cachedReleases; - } - - $releases = $this->fromQuery($sql); - - if ($releases->isNotEmpty()) { - $countSql = sprintf('SELECT COUNT(*) as count FROM releases r %s', $whereSql); - $countResult = $this->fromQuery($countSql); - $releases[0]->_totalrows = $countResult[0]->count ?? 0; - } - - $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); - Cache::put($cacheKey, $releases, $expiresAt); - - return $releases; + return match ($setting) { + 1 => '<= '.self::PASSWD_RAR, + default => '= '.self::PASSWD_NONE, + }; } /** - * Search for TV shows via API. - * - * @return array|\Illuminate\Cache\|\Illuminate\Database\Eloquent\Collection|\Illuminate\Support\Collection|mixed + * Use to order releases on site. */ - 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 + public function getBrowseOrder(array|string $orderBy): array { - $shouldCache = ! (isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0); - $rawCacheKey = md5(serialize(func_get_args()).'tvSearch'); - $cacheKey = null; - if ($shouldCache) { - $cacheKey = md5($this->getCacheVersion().$rawCacheKey); - $cached = Cache::get($cacheKey); - if ($cached !== null) { - return $cached; - } - } + $orderArr = explode('_', ($orderBy === '' ? 'posted_desc' : $orderBy)); + $orderField = match ($orderArr[0]) { + 'cat' => 'categories_id', + 'name' => 'searchname', + 'size' => 'size', + 'files' => 'totalpart', + 'stats' => 'grabs', + default => 'postdate', + }; - $conditions = [ - sprintf('r.passwordstatus %s', $this->showPasswords()), - ]; - - $videoJoinCondition = ''; - $episodeJoinCondition = ''; - $needsEpisodeJoin = false; - - if (! empty($siteIdArr)) { - $siteConditions = []; - foreach ($siteIdArr as $column => $id) { - if ($id > 0) { - $siteConditions[] = sprintf('v.%s = %d', $column, (int) $id); - } - } - - if (! empty($siteConditions)) { - $siteUsesVideoIdOnly = count($siteConditions) === 1 && isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0; - - $seriesFilter = ($series !== '') ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''; - $episodeFilter = ($episode !== '') ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''; - $airDateFilter = ($airDate !== '') ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : ''; - - $lookupSql = sprintf( - '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', - implode(' OR ', $siteConditions), - $seriesFilter, - $episodeFilter, - $airDateFilter - ); - - $results = $this->fromQuery($lookupSql); - - if ($results->isEmpty()) { - return collect(); - } - - $videoIds = $results->pluck('video_id')->filter()->unique()->toArray(); - $episodeIds = $results->pluck('episode_id')->filter()->unique()->toArray(); - - if (! empty($videoIds)) { - $conditions[] = sprintf('r.videos_id IN (%s)', implode(',', array_map('intval', $videoIds))); - $videoJoinCondition = sprintf('AND v.id IN (%s)', implode(',', array_map('intval', $videoIds))); - } - - if (! empty($episodeIds) && ! $siteUsesVideoIdOnly) { - $conditions[] = sprintf('r.tv_episodes_id IN (%s)', implode(',', array_map('intval', $episodeIds))); - $episodeJoinCondition = sprintf('AND tve.id IN (%s)', implode(',', array_map('intval', $episodeIds))); - $needsEpisodeJoin = true; - } - - if ($siteUsesVideoIdOnly) { - $needsEpisodeJoin = false; - } - } - } - - $searchResult = []; - if (! empty($name)) { - $searchName = $name; - $hasValidSiteIds = false; - foreach ($siteIdArr as $column => $id) { - if ($id > 0) { - $hasValidSiteIds = true; - break; - } - } - - if (! $hasValidSiteIds) { - if (! empty($series) && (int) $series < 1900) { - $searchName .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT)); - $seriesNum = (int) preg_replace('/^s0*/i', '', $series); - $conditions[] = sprintf('tve.series = %d', $seriesNum); - $needsEpisodeJoin = true; - if (! empty($episode) && ! str_contains($episode, '/')) { - $searchName .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT)); - $episodeNum = (int) preg_replace('/^e0*/i', '', $episode); - $conditions[] = sprintf('tve.episode = %d', $episodeNum); - } - } elseif (! empty($airDate)) { - $searchName .= ' '.str_replace(['/', '-', '.', '_'], ' ', $airDate); - $conditions[] = sprintf('DATE(tve.firstaired) = %s', escapeString($airDate)); - $needsEpisodeJoin = true; - } - } - - if (config('nntmux.elasticsearch_enabled') === true) { - $searchResult = $this->elasticSearch->indexSearchTMA($searchName, $limit); - } else { - $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $searchName, ['searchname']); - if (! empty($searchResult)) { - $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); - } - } - - if (empty($searchResult)) { - return collect(); - } - - $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))); - } - - $catQuery = Category::getCategorySearch($cat, 'tv'); - $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery)); - if (! empty($catQuery) && $catQuery !== '1=1') { - $conditions[] = $catQuery; - } - - if ($maxAge > 0) { - $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge); - } - if ($minSize > 0) { - $conditions[] = sprintf('r.size >= %d', $minSize); - } - if (! empty($excludedCategories)) { - $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories))); - } - - $whereSql = 'WHERE '.implode(' AND ', $conditions); - - $joinType = $needsEpisodeJoin ? 'INNER' : 'LEFT'; - - // Optimized select list – only fields required by XML (extended) and transformers - $baseSql = sprintf( - "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.videos_id, r.tv_episodes_id, - v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage, - tve.series, tve.episode, tve.firstaired, - cp.title AS parent_category, c.title AS sub_category, - CONCAT(cp.title, ' > ', c.title) AS category_name, - g.name AS group_name - FROM releases r - INNER JOIN categories c ON c.id = r.categories_id - INNER JOIN root_categories cp ON cp.id = c.root_categories_id - LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0 %s - %s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s - LEFT JOIN usenet_groups g ON g.id = r.groups_id - %s", - $videoJoinCondition, - $joinType, - $episodeJoinCondition, - $whereSql - ); - - $limitClause = ''; - if ($limit > 0) { - $limitClause = sprintf(' LIMIT %d OFFSET %d', $limit, $offset); - } - - $sql = sprintf('%s ORDER BY r.postdate DESC%s', $baseSql, $limitClause); - $releases = $this->fromQuery($sql); - - if ($releases->isNotEmpty()) { - $countSql = sprintf( - 'SELECT COUNT(*) as count FROM releases r %s %s %s', - (! empty($videoJoinCondition) ? 'LEFT JOIN videos v ON r.videos_id = v.id AND v.type = 0' : ''), - ($needsEpisodeJoin ? sprintf('%s JOIN tv_episodes tve ON r.tv_episodes_id = tve.id %s', $joinType, $episodeJoinCondition) : ''), - $whereSql - ); - $countResult = $this->fromQuery($countSql); - $releases[0]->_totalrows = $countResult[0]->count ?? 0; - } - - if ($shouldCache && $cacheKey !== null) { - $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); - Cache::put($cacheKey, $releases, $expiresAt); - } - - return $releases; - } - - public static function bumpCacheVersion(): void - { - $current = Cache::get(self::CACHE_VERSION_KEY, 1); - Cache::forever(self::CACHE_VERSION_KEY, $current + 1); + return [$orderField, isset($orderArr[1]) && preg_match('/^(asc|desc)$/i', $orderArr[1]) ? $orderArr[1] : 'desc']; } private function getCacheVersion(): int @@ -1026,330 +1107,6 @@ class Releases extends Release return Cache::get(self::CACHE_VERSION_KEY, 1); } - /** - * Search TV Shows via APIv2. - * - * - * @return Collection|mixed - */ - 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 - { - $siteSQL = []; - $showSql = ''; - foreach ($siteIdArr as $column => $Id) { - if ($Id > 0) { - $siteSQL[] = sprintf('v.%s = %d', $column, $Id); - } - } - - if (\count($siteSQL) > 0) { - $showQry = sprintf( - "\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", - implode(' OR ', $siteSQL), - ($series !== '' ? sprintf('AND tve.series = %d', (int) preg_replace('/^s0*/i', '', $series)) : ''), - ($episode !== '' ? sprintf('AND tve.episode = %d', (int) preg_replace('/^e0*/i', '', $episode)) : ''), - ($airDate !== '' ? sprintf('AND DATE(tve.firstaired) = %s', escapeString($airDate)) : '') - ); - $show = $this->fromQuery($showQry); - if ($show->isNotEmpty()) { - if ((! empty($episode) && ! empty($series)) && $show[0]->episodes !== '') { - $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series; - } elseif (! empty($episode) && $show[0]->episodes !== '') { - $showSql = sprintf('AND r.tv_episodes_id IN (%s)', $show[0]->episodes); - } elseif (! empty($series) && empty($episode)) { - $showSql .= ' AND r.tv_episodes_id IN ('.$show[0]->episodes.') AND tve.series = '.$series; - } - if ($show[0]->video > 0) { - $showSql .= ' AND r.videos_id = '.$show[0]->video; - } - } else { - return []; - } - } - if (! empty($name) && $showSql === '') { - if (! empty($series) && (int) $series < 1900) { - $name .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT)); - if (! empty($episode) && ! str_contains($episode, '/')) { - $name .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT)); - } - if (empty($episode)) { - $name .= '*'; - } - } elseif (! empty($airDate)) { - $name .= sprintf(' %s', str_replace(['/', '-', '.', '_'], ' ', $airDate)); - } - } - if (! empty($name)) { - if (config('nntmux.elasticsearch_enabled') === true) { - $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit); - } else { - $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']); - if (! empty($searchResult)) { - $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); - } - } - - if (count($searchResult) === 0) { - return collect(); - } - } - $whereSql = sprintf( - 'WHERE r.passwordstatus %s %s %s %s %s %s %s', - $this->showPasswords(), - $showSql, - (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''), - Category::getCategorySearch($cat, 'tv'), - ($maxAge > 0 ? sprintf('AND r.postdate > NOW() - INTERVAL %d DAY', $maxAge) : ''), - ($minSize > 0 ? sprintf('AND r.size >= %d', $minSize) : ''), - ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : '' - ); - $baseSql = sprintf( - "SELECT r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, - r.tv_episodes_id, v.title, v.tvdb, v.trakt, v.imdb, v.tmdb, v.tvmaze, v.tvrage, - tve.series, tve.episode, tve.firstaired, cp.title AS parent_category, c.title AS sub_category, - CONCAT(cp.title, ' > ', c.title) AS category_name, g.name AS group_name - FROM releases r - LEFT OUTER JOIN videos v ON r.videos_id = v.id AND v.type = 0 - LEFT OUTER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id - LEFT JOIN categories c ON c.id = r.categories_id - LEFT JOIN root_categories cp ON cp.id = c.root_categories_id - LEFT JOIN usenet_groups g ON g.id = r.groups_id - %s", - $whereSql - ); - $sql = sprintf('%s ORDER BY postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset); - $releases = Cache::get(md5($sql)); - if ($releases !== null) { - return $releases; - } - $releases = $this->fromQuery($sql); - if ($releases->isNotEmpty()) { - $releases[0]->_totalrows = $this->getPagerCount( - preg_replace('#LEFT(\s+OUTER)?\s+JOIN\s+(?!tv_episodes)\s+.*ON.*=.*\n#i', ' ', $baseSql) - ); - } - $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); - Cache::put(md5($sql), $releases, $expiresAt); - - return $releases; - } - - /** - * Search anime releases. - * - * - * @return Collection|mixed - */ - public function animeSearch($aniDbID, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, array $excludedCategories = []): mixed - { - if (! empty($name)) { - if (config('nntmux.elasticsearch_enabled') === true) { - $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit); - } else { - $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']); - if (! empty($searchResult)) { - $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); - } - } - - if (count($searchResult) === 0) { - return collect(); - } - } - - $whereSql = sprintf( - 'WHERE r.passwordstatus %s - %s %s %s %s %s', - $this->showPasswords(), - ($aniDbID > -1 ? sprintf(' AND r.anidbid = %d ', $aniDbID) : ''), - (! empty($searchResult) ? 'AND r.id IN ('.implode(',', $searchResult).')' : ''), - ! empty($excludedCategories) ? sprintf('AND r.categories_id NOT IN('.implode(',', $excludedCategories).')') : '', - Category::getCategorySearch($cat), - ($maxAge > 0 ? sprintf(' AND r.postdate > NOW() - INTERVAL %d DAY ', $maxAge) : '') - ); - $baseSql = sprintf( - "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, - CONCAT(cp.title, ' > ', c.title) AS category_name, - g.name AS group_name, - rn.releases_id AS nfoid - FROM releases r - LEFT JOIN categories c ON c.id = r.categories_id - LEFT JOIN root_categories cp ON cp.id = c.root_categories_id - LEFT JOIN usenet_groups g ON g.id = r.groups_id - LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id - %s", - $whereSql - ); - $sql = sprintf( - '%s - ORDER BY postdate DESC - LIMIT %d OFFSET %d', - $baseSql, - $limit, - $offset - ); - $releases = Cache::get(md5($sql)); - if ($releases !== null) { - return $releases; - } - $releases = $this->fromQuery($sql); - if ($releases->isNotEmpty()) { - $releases[0]->_totalrows = $this->getPagerCount($baseSql); - } - $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); - Cache::put(md5($sql), $releases, $expiresAt); - - return $releases; - } - - /** - * Movies search through API and site. - * - * @return Collection|mixed - */ - 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 - { - // Early return if searching by name yields no results - $searchResult = []; - if (! empty($name)) { - if (config('nntmux.elasticsearch_enabled') === true) { - $searchResult = $this->elasticSearch->indexSearchTMA($name, $limit); - } else { - $searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']); - if (! empty($searchResult)) { - $searchResult = Arr::wrap(Arr::get($searchResult, 'id')); - } - } - - if (empty($searchResult)) { - return collect(); - } - } - - $conditions = [ - sprintf('r.categories_id BETWEEN %d AND %d', Category::MOVIE_ROOT, Category::MOVIE_OTHER), - sprintf('r.passwordstatus %s', $this->showPasswords()), - ]; - - if (! empty($searchResult)) { - $conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult))); - } - - // Optimize: Join on imdbid directly (both tables have indexed imdbid columns) - // This is more efficient than joining through movieinfo_id - // Always join movieinfo to get imdbid, tmdbid, and traktid fields consistently - - if ($imDbId !== -1 && $imDbId) { - // Filter on r.imdbid (uses index ix_releases_imdbid) - // The join on m.imdbid = r.imdbid will also use the index - $conditions[] = sprintf('r.imdbid = %d', $imDbId); - } - - if ($tmDbId !== -1 && $tmDbId) { - $conditions[] = sprintf('m.tmdbid = %d', $tmDbId); - } - - if ($traktId !== -1 && $traktId) { - $conditions[] = sprintf('m.traktid = %d', $traktId); - } - - if (! empty($excludedCategories)) { - $conditions[] = sprintf('r.categories_id NOT IN (%s)', implode(',', array_map('intval', $excludedCategories))); - } - - $catQuery = Category::getCategorySearch($cat, 'movies'); - $catQuery = preg_replace('/^(WHERE|AND)\s+/i', '', trim($catQuery)); - if (! empty($catQuery) && $catQuery !== '1=1') { - $conditions[] = $catQuery; - } - if ($maxAge > 0) { - $conditions[] = sprintf('r.postdate > (NOW() - INTERVAL %d DAY)', $maxAge); - } - if ($minSize > 0) { - $conditions[] = sprintf('r.size >= %d', $minSize); - } - - $whereSql = 'WHERE '.implode(' AND ', $conditions); - // Always join on imdbid directly (both tables have indexed imdbid) - more efficient than movieinfo_id - // Both ix_releases_imdbid and ix_movieinfo_imdbid are indexed, making this join very fast - // This ensures we always get imdbid, tmdbid, and traktid fields in results - $joinSql = 'INNER JOIN movieinfo m ON m.imdbid = r.imdbid'; - - // Select only fields required by XML/API transformers - $baseSql = sprintf( - "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, - %s - cp.title AS parent_category, c.title AS sub_category, - CONCAT(cp.title, ' > ', c.title) AS category_name, - g.name AS group_name - FROM releases r - INNER JOIN categories c ON c.id = r.categories_id - INNER JOIN root_categories cp ON cp.id = c.root_categories_id - %s - LEFT JOIN usenet_groups g ON g.id = r.groups_id - %s", - 'm.imdbid, m.tmdbid, m.traktid,', - $joinSql, - $whereSql - ); - - $sql = sprintf('%s ORDER BY r.postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset); - $cacheKey = md5($sql.serialize(func_get_args())); - if (($releases = Cache::get($cacheKey)) !== null) { - return $releases; - } - - $releases = $this->fromQuery($sql); - - if ($releases->isNotEmpty()) { - // Optimize: Execute count query using same WHERE clause (uses same indexes) - // The count query is lightweight and can use index-only scans when possible - // Use same join logic as main query (join on imdbid when needed) - $countSql = sprintf( - 'SELECT COUNT(*) as count FROM releases r %s %s', - $joinSql, - $whereSql - ); - $countResult = DB::selectOne($countSql); - $releases[0]->_totalrows = $countResult->count ?? 0; - } - - Cache::put($cacheKey, $releases, now()->addMinutes(config('nntmux.cache_expiry_medium'))); - - return $releases; - } - - public function searchSimilar($currentID, $name, array $excludedCats = []): bool|array - { - // Get the category for the parent of this release. - $ret = false; - $currRow = self::getCatByRelId($currentID); - if ($currRow !== null) { - $catRow = Category::find($currRow['categories_id']); - $parentCat = $catRow !== null ? $catRow['root_categories_id'] : null; - - if ($parentCat === null) { - return $ret; - } - - $results = $this->search(['searchname' => getSimilarName($name)], -1, '', '', -1, -1, 0, config('nntmux.items_per_page'), '', -1, $excludedCats, 'basic', [$parentCat]); - if (! $results) { - return $ret; - } - - $ret = []; - foreach ($results as $res) { - if ($res['id'] !== $currentID && $res['categoryparentid'] === $parentCat) { - $ret[] = $res; - } - } - } - - return $ret; - } - /** * Get the count of releases for pager. * @@ -1496,3 +1253,4 @@ class Releases extends Release } } } + diff --git a/app/Services/Search/ManticoreSearchService.php b/app/Services/Search/ManticoreSearchService.php index e599f9a14..01e06509c 100644 --- a/app/Services/Search/ManticoreSearchService.php +++ b/app/Services/Search/ManticoreSearchService.php @@ -402,6 +402,15 @@ class ManticoreSearchService implements SearchServiceInterface return []; } + if (config('app.debug')) { + Log::debug('ManticoreSearch::searchIndexes called', [ + 'rt_index' => $rt_index, + 'searchString' => $searchString, + 'column' => $column, + 'searchArray' => $searchArray, + ]); + } + // Create cache key for search results $cacheKey = md5(serialize([ 'index' => $rt_index, @@ -412,6 +421,12 @@ class ManticoreSearchService implements SearchServiceInterface $cached = Cache::get($cacheKey); if ($cached !== null) { + if (config('app.debug')) { + Log::debug('ManticoreSearch::searchIndexes returning cached result', [ + 'cacheKey' => $cacheKey, + 'cached_ids_count' => count($cached['id'] ?? []), + ]); + } return $cached; } @@ -430,11 +445,17 @@ class ManticoreSearchService implements SearchServiceInterface if (! empty($terms)) { $searchExpr = implode(' ', $terms); } else { + if (config('app.debug')) { + Log::debug('ManticoreSearch::searchIndexes no terms after escaping searchArray'); + } return []; } } elseif (! empty($searchString)) { $escapedSearch = self::escapeString($searchString); if (empty($escapedSearch)) { + if (config('app.debug')) { + Log::debug('ManticoreSearch::searchIndexes escapedSearch is empty'); + } return []; }