diff --git a/app/Extensions/helper/helpers.php b/app/Extensions/helper/helpers.php index a40777168..754d96df1 100644 --- a/app/Extensions/helper/helpers.php +++ b/app/Extensions/helper/helpers.php @@ -786,10 +786,15 @@ if (! function_exists('streamSslContextOptions')) { if (! function_exists('getCoverURL')) { /** + * Get cover URL for a release. Uses a short-lived in-memory cache to avoid + * repeated filesystem file_exists() calls for the same cover during a single request. + * * @param array $options */ function getCoverURL(array $options = []): string { + static $coverCache = []; + $defaults = [ 'id' => null, 'suffix' => '-cover.jpg', @@ -806,8 +811,15 @@ if (! function_exists('getCoverURL')) { ) ) { $fileSpec = sprintf($fileSpecTemplate, $options['type'], $options['id'], $options['suffix']); - $fileSpec = file_exists(storage_path('covers/').$fileSpec) ? $fileSpec : - sprintf($fileSpecTemplate, $options['type'], 'no', $options['suffix']); + $cacheKey = $options['type'].':'.$options['id']; + + if (! isset($coverCache[$cacheKey])) { + $coverCache[$cacheKey] = file_exists(storage_path('covers/').$fileSpec); + } + + if (! $coverCache[$cacheKey]) { + $fileSpec = sprintf($fileSpecTemplate, $options['type'], 'no', $options['suffix']); + } } return $fileSpec; diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index c7a95147a..42c36938a 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -10,7 +10,6 @@ use App\Models\ReleaseNfo; use App\Models\Settings; 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; @@ -20,6 +19,8 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Redirector; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -104,7 +105,16 @@ class ApiController extends BasePageController } $apiKey = $request->input('apikey'); - $res = User::getByRssToken($apiKey); + + // Cache user lookup for 5 minutes to avoid repeated DB hits (same pattern as API v2) + $userCacheKey = 'api_user:'.md5($apiKey); + $res = Cache::remember($userCacheKey, 300, function () use ($apiKey) { + return User::query() + ->where('api_token', $apiKey) + ->with('role') + ->first(); + }); + if ($res === null) { return showApiError(100, 'Incorrect user credentials (wrong API key)'); } @@ -114,20 +124,22 @@ class ApiController extends BasePageController } $uid = $res->id; - $catExclusions = User::getCategoryExclusionForApi($request); + // Use user ID directly instead of re-looking up by token + $catExclusions = User::getCategoryExclusionById($uid); $maxRequests = $res->role->apirequests; $maxDownloads = $res->role->downloadrequests; - $time = UserRequest::whereUsersId($uid)->min('timestamp'); - $thisOldestTime = $time !== null ? Carbon::createFromTimeString($time)->toRfc2822String() : ''; - $grabTime = UserDownload::whereUsersId($uid)->min('timestamp'); - $oldestGrabTime = $grabTime !== null ? Carbon::createFromTimeString($grabTime)->toRfc2822String() : ''; + + // Consolidated user stats: single query with 60s cache instead of 4 separate queries + $userStats = $this->getCachedUserStats($uid); + $thisOldestTime = $userStats->api_time ? Carbon::parse($userStats->api_time)->toRfc2822String() : ''; + $oldestGrabTime = $userStats->grab_time ? Carbon::parse($userStats->grab_time)->toRfc2822String() : ''; + $thisRequests = (int) ($userStats->api_count ?? 0); + $grabs = (int) ($userStats->grab_count ?? 0); } // Record user access to the api, if its been called by a user (i.e. capabilities request do not require a user to be logged in or key provided). if ($uid !== '') { event(new UserAccessedApi($res, $request->ip())); - $thisRequests = UserRequest::getApiRequests($uid); - $grabs = UserDownload::getDownloadRequests($uid); if ($thisRequests > $maxRequests) { return showApiError(500, 'Request limit reached ('.$thisRequests.'/'.$maxRequests.')'); } @@ -156,7 +168,7 @@ class ApiController extends BasePageController $this->verifyEmptyParameter($request, 'q'); $maxAge = $this->maxAge($request); $groupName = $this->group($request); - UserRequest::addApiRequest($apiKey, $request->getRequestUri()); + UserRequest::addApiRequest($uid, $request->getRequestUri()); $categoryID = $this->categoryID($request); $limit = $this->limit($request); @@ -199,7 +211,7 @@ class ApiController extends BasePageController $this->verifyEmptyParameter($request, 'season'); $this->verifyEmptyParameter($request, 'ep'); $maxAge = $this->maxAge($request); - UserRequest::addApiRequest($apiKey, $request->getRequestUri()); + UserRequest::addApiRequest($uid, $request->getRequestUri()); $siteIdArr = [ 'id' => $request->input('vid') ?? '0', @@ -243,7 +255,7 @@ class ApiController extends BasePageController $this->verifyEmptyParameter($request, 'q'); $this->verifyEmptyParameter($request, 'imdbid'); $maxAge = $this->maxAge($request); - UserRequest::addApiRequest($apiKey, $request->getRequestUri()); + UserRequest::addApiRequest($uid, $request->getRequestUri()); $imdbId = $request->has('imdbid') && $request->filled('imdbid') ? (int) $request->input('imdbid') : -1; $tmdbId = $request->has('tmdbid') && $request->filled('tmdbid') ? (int) $request->input('tmdbid') : -1; @@ -275,7 +287,7 @@ class ApiController extends BasePageController // Get NZB. case 'g': $this->verifyEmptyParameter($request, 'g'); - UserRequest::addApiRequest($apiKey, $request->getRequestUri()); + UserRequest::addApiRequest($uid, $request->getRequestUri()); $relData = Release::checkGuidForApi($request->input('id')); if ($relData) { return redirect(url('/getnzb?r='.$apiKey.'&id='.$request->input('id').(($request->has('del') && $request->input('del') === '1') ? '&del=1' : ''))); @@ -289,8 +301,8 @@ class ApiController extends BasePageController return showApiError(200, 'Missing parameter (guid is required for single release details)'); } - UserRequest::addApiRequest($apiKey, $request->getRequestUri()); - $data = Release::getByGuid($request->input('id')); + UserRequest::addApiRequest($uid, $request->getRequestUri()); + $data = Release::getByGuidForApi($request->input('id')); $this->output($data, $params, $outputXML, $offset, 'api'); break; @@ -301,7 +313,7 @@ class ApiController extends BasePageController return showApiError(200, 'Missing parameter (id is required for retrieving an NFO)'); } - UserRequest::addApiRequest($apiKey, $request->getRequestUri()); + UserRequest::addApiRequest($uid, $request->getRequestUri()); $rel = Release::query()->where('guid', $request->input('id'))->first(['id', 'searchname']); if ($rel) { @@ -341,7 +353,7 @@ class ApiController extends BasePageController return response('Missing parameter (file is required for adding an NZB)', 400); } - UserRequest::addApiRequest($apiKey, $request->getRequestUri()); + UserRequest::addApiRequest($uid, $request->getRequestUri()); $nzbFile = $request->file('file'); @@ -398,14 +410,19 @@ class ApiController extends BasePageController 'Type' => $type, ]; - // Generate the XML Response - $response = (new XML_Response($options))->returnXML(); + $xmlResponse = new XML_Response($options); if ($xml) { + // Generate XML response + $response = $xmlResponse->returnXML(); header('Content-type: text/xml'); } else { - // JSON encode the XMLWriter response - $response = json_encode(xml_to_array($response), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT + JSON_UNESCAPED_SLASHES); + // Build JSON directly from array (avoids expensive XML->xml_to_array->json_encode path) + $arrayData = $xmlResponse->returnArray(); + if ($arrayData === false) { + return showApiError(201); + } + $response = json_encode($arrayData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); header('Content-type: application/json'); } if ($response === false) { @@ -419,7 +436,7 @@ class ApiController extends BasePageController /** * Collect and return various capability information for usage in API. - * + * Cached for 10 minutes to avoid repeated Settings DB lookups on every API response. * * @return array * @@ -427,35 +444,42 @@ class ApiController extends BasePageController */ public function getForMenu(): array { - $serverroot = url('/'); + $includeCats = $this->type === 'caps'; - return [ - 'server' => [ - 'title' => config('app.name'), - 'strapline' => Settings::settingValue('strapline'), - 'email' => config('mail.from.address'), - 'meta' => Settings::settingValue('metakeywords'), - 'url' => $serverroot, - 'image' => $serverroot.'/assets/images/tmux_logo.png', - ], - 'limits' => [ - 'max' => 100, - 'default' => 100, - ], - 'registration' => [ - 'available' => 'yes', - 'open' => (int) Settings::settingValue('registerstatus') === 0 ? 'yes' : 'no', - ], - 'searching' => [ - 'search' => ['available' => 'yes', 'supportedParams' => 'q'], - 'tv-search' => ['available' => 'yes', 'supportedParams' => 'q,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep'], - 'movie-search' => ['available' => 'yes', 'supportedParams' => 'q,imdbid, tmdbid, traktid'], - 'audio-search' => ['available' => 'no', 'supportedParams' => ''], - ], - 'categories' => $this->type === 'caps' - ? Category::getForMenu() - : null, - ]; + // Cache the server info blob (without categories) for 10 minutes + $serverInfo = Cache::remember('api_v1_server_menu', 600, function () { + $serverroot = url('/'); + + return [ + 'server' => [ + 'title' => config('app.name'), + 'strapline' => Settings::settingValue('strapline'), + 'email' => config('mail.from.address'), + 'meta' => Settings::settingValue('metakeywords'), + 'url' => $serverroot, + 'image' => $serverroot.'/assets/images/tmux_logo.png', + ], + 'limits' => [ + 'max' => 100, + 'default' => 100, + ], + 'registration' => [ + 'available' => 'yes', + 'open' => (int) Settings::settingValue('registerstatus') === 0 ? 'yes' : 'no', + ], + 'searching' => [ + 'search' => ['available' => 'yes', 'supportedParams' => 'q'], + 'tv-search' => ['available' => 'yes', 'supportedParams' => 'q,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep'], + 'movie-search' => ['available' => 'yes', 'supportedParams' => 'q,imdbid, tmdbid, traktid'], + 'audio-search' => ['available' => 'no', 'supportedParams' => ''], + ], + ]; + }); + + // Only load categories for caps requests (also cached via Category::getForMenu) + $serverInfo['categories'] = $includeCats ? Category::getForMenu() : null; + + return $serverInfo; } /** @@ -556,6 +580,27 @@ class ApiController extends BasePageController } } + /** + * Get cached user stats (API requests + download counts/timestamps) in a single query. + * Cached for 60 seconds to reduce DB hits across rapid API calls. + */ + public function getCachedUserStats(int $userId): object + { + $cacheKey = 'api_user_stats:'.$userId; + + return Cache::remember($cacheKey, 60, function () use ($userId) { + $oneDayAgo = now()->subDay()->toDateTimeString(); + + return DB::selectOne(' + SELECT + (SELECT COUNT(*) FROM user_requests WHERE users_id = ? AND timestamp > ?) as api_count, + (SELECT COUNT(*) FROM user_downloads WHERE users_id = ? AND timestamp > ?) as grab_count, + (SELECT MIN(timestamp) FROM user_requests WHERE users_id = ? AND timestamp > ?) as api_time, + (SELECT MIN(timestamp) FROM user_downloads WHERE users_id = ? AND timestamp > ?) as grab_time + ', [$userId, $oneDayAgo, $userId, $oneDayAgo, $userId, $oneDayAgo, $userId, $oneDayAgo]); + }); + } + public function addCoverURL(mixed &$releases, callable $getCoverURL): void { if ($releases && \count($releases)) { diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index d5d9a9526..95a1bdacd 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -8,7 +8,6 @@ use App\Models\Category; use App\Models\Release; 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; @@ -20,7 +19,6 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; class ApiV2Controller extends BasePageController { @@ -40,33 +38,77 @@ class ApiV2Controller extends BasePageController $this->releaseBrowseService = $releaseBrowseService; } + /** + * Validate API token and return cached user, or null on failure. + * Caches user lookup for 5 minutes to reduce DB hits. + */ + private function resolveUser(Request $request): ?User + { + if ($request->missing('api_token') || $request->isNotFilled('api_token')) { + return null; + } + + $apiToken = $request->input('api_token'); + $userCacheKey = 'api_user:'.md5($apiToken); + + return Cache::remember($userCacheKey, 300, function () use ($apiToken) { + return User::query() + ->where('api_token', $apiToken) + ->with('role') + ->first(); + }); + } + + /** + * Build the standard user stats portion of an API response. + * Uses the consolidated single-query + 60s cache from ApiController. + * + * @return array + */ + private function buildUserStatsResponse(User $user): array + { + $userStats = $this->api->getCachedUserStats($user->id); + + return [ + 'apiCurrent' => (int) ($userStats->api_count ?? 0), + 'apiMax' => $user->role->apirequests, + 'grabCurrent' => (int) ($userStats->grab_count ?? 0), + 'grabMax' => $user->role->downloadrequests, + 'apiOldestTime' => $userStats->api_time ? Carbon::parse($userStats->api_time)->toRfc2822String() : '', + 'grabOldestTime' => $userStats->grab_time ? Carbon::parse($userStats->grab_time)->toRfc2822String() : '', + ]; + } + public function capabilities(): JsonResponse { - $category = Category::getForApi(); + // Cache the full capabilities response for 10 minutes + $capabilities = Cache::remember('api_v2_capabilities', 600, function () { + $category = Category::getForApi(); - $capabilities = [ - 'server' => [ - 'title' => config('app.name'), - 'strapline' => Settings::settingValue('strapline'), - 'email' => config('mail.from.address'), - 'url' => url('/'), - ], - 'limits' => [ - 'max' => 100, - 'default' => 100, - ], - 'registration' => [ - 'available' => 'no', - 'open' => (int) Settings::settingValue('registerstatus') === 0 ? 'yes' : 'no', - ], - 'searching' => [ - 'search' => ['available' => 'yes', 'supportedParams' => 'id'], - 'tv-search' => ['available' => 'yes', 'supportedParams' => 'id,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep'], - 'movie-search' => ['available' => 'yes', 'supportedParams' => 'id, imdbid, tmdbid, traktid'], - 'audio-search' => ['available' => 'no', 'supportedParams' => ''], - ], - 'categories' => fractal($category, new CategoryTransformer), - ]; + return [ + 'server' => [ + 'title' => config('app.name'), + 'strapline' => Settings::settingValue('strapline'), + 'email' => config('mail.from.address'), + 'url' => url('/'), + ], + 'limits' => [ + 'max' => 100, + 'default' => 100, + ], + 'registration' => [ + 'available' => 'no', + 'open' => (int) Settings::settingValue('registerstatus') === 0 ? 'yes' : 'no', + ], + 'searching' => [ + 'search' => ['available' => 'yes', 'supportedParams' => 'id'], + 'tv-search' => ['available' => 'yes', 'supportedParams' => 'id,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep'], + 'movie-search' => ['available' => 'yes', 'supportedParams' => 'id, imdbid, tmdbid, traktid'], + 'audio-search' => ['available' => 'no', 'supportedParams' => ''], + ], + 'categories' => fractal($category, new CategoryTransformer), + ]; + }); return response()->json($capabilities); } @@ -76,28 +118,12 @@ class ApiV2Controller extends BasePageController */ public function movie(Request $request): JsonResponse { - // Validate API token and get user in one query - if ($request->missing('api_token') || $request->isNotFilled('api_token')) { - return response()->json(['error' => 'Missing parameter (apikey)'], 403); - } - - $apiToken = $request->input('api_token'); - - // Cache user lookup for 5 minutes to reduce DB hits - $userCacheKey = 'api_user:'.md5($apiToken); - $user = Cache::remember($userCacheKey, 300, function () use ($apiToken) { - return User::query() - ->where('api_token', $apiToken) - ->with('role') - ->first(); - }); - + $user = $this->resolveUser($request); if (! $user) { - return response()->json(['error' => 'Invalid API key'], 403); + return response()->json(['error' => 'Missing or invalid API key'], 403); } - // Queue API request logging asynchronously (non-blocking) - UserRequest::addApiRequest($apiToken, $request->getRequestUri()); + UserRequest::addApiRequest($user->id, $request->getRequestUri()); event(new UserAccessedApi($user, $request->ip())); // Get request parameters efficiently @@ -110,7 +136,7 @@ class ApiV2Controller extends BasePageController $limit = $this->api->limit($request); $categoryID = $this->api->categoryID($request); $maxAge = $this->api->maxAge($request); - $catExclusions = User::getCategoryExclusionForApi($request); + $catExclusions = User::getCategoryExclusionById($user->id); // Create cache key for movie search results $searchCacheKey = 'api_movie_search:'.md5(serialize([ @@ -137,31 +163,11 @@ class ApiV2Controller extends BasePageController ); }); - // Get user stats with a single optimized raw SQL query - $userStatsCacheKey = 'api_user_stats:'.$user->id; - $userStats = Cache::remember($userStatsCacheKey, 60, function () use ($user) { - $oneDayAgo = now()->subDay()->toDateTimeString(); - - return DB::selectOne(' - SELECT - (SELECT COUNT(*) FROM user_requests WHERE users_id = ? AND timestamp > ?) as api_count, - (SELECT COUNT(*) FROM user_downloads WHERE users_id = ? AND timestamp > ?) as grab_count, - (SELECT MIN(timestamp) FROM user_requests WHERE users_id = ? AND timestamp > ?) as api_time, - (SELECT MIN(timestamp) FROM user_downloads WHERE users_id = ? AND timestamp > ?) as grab_time - ', [$user->id, $oneDayAgo, $user->id, $oneDayAgo, $user->id, $oneDayAgo, $user->id, $oneDayAgo]); - }); - - // Build response - $response = [ - 'Total' => $relData[0]->_totalrows ?? 0, - 'apiCurrent' => (int) ($userStats->api_count ?? 0), - 'apiMax' => $user->role->apirequests, - 'grabCurrent' => (int) ($userStats->grab_count ?? 0), - 'grabMax' => $user->role->downloadrequests, - 'apiOldestTime' => $userStats->api_time ? Carbon::parse($userStats->api_time)->toRfc2822String() : '', - 'grabOldestTime' => $userStats->grab_time ? Carbon::parse($userStats->grab_time)->toRfc2822String() : '', - 'Results' => fractal($relData, new ApiTransformer($user)), - ]; + $response = array_merge( + ['Total' => $relData[0]->_totalrows ?? 0], + $this->buildUserStatsResponse($user), + ['Results' => fractal($relData, new ApiTransformer($user))] + ); return response()->json($response); } @@ -172,17 +178,19 @@ class ApiV2Controller extends BasePageController */ public function apiSearch(Request $request): JsonResponse { - if ($request->missing('api_token') || $request->isNotFilled('api_token')) { - return response()->json(['error' => 'Missing parameter (api_token)'], 403); + $user = $this->resolveUser($request); + if (! $user) { + return response()->json(['error' => 'Missing or invalid API key'], 403); } - $user = User::query()->where('api_token', $request->input('api_token'))->first(); + + UserRequest::addApiRequest($user->id, $request->getRequestUri()); + event(new UserAccessedApi($user, $request->ip())); + $offset = $this->api->offset($request); - $catExclusions = User::getCategoryExclusionForApi($request); + $catExclusions = User::getCategoryExclusionById($user->id); $minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0; $maxAge = $this->api->maxAge($request); $groupName = $this->api->group($request); - UserRequest::addApiRequest($request->input('api_token'), $request->getRequestUri()); - event(new UserAccessedApi($user, $request->ip())); $categoryID = $this->api->categoryID($request); $limit = $this->api->limit($request); @@ -211,21 +219,11 @@ class ApiV2Controller extends BasePageController ); } - $time = UserRequest::whereUsersId($user->id)->min('timestamp'); - $apiOldestTime = $time !== null ? Carbon::createFromTimeString($time)->toRfc2822String() : ''; - $grabTime = UserDownload::whereUsersId($user->id)->min('timestamp'); - $oldestGrabTime = $grabTime !== null ? Carbon::createFromTimeString($grabTime)->toRfc2822String() : ''; - - $response = [ - 'Total' => $relData[0]->_totalrows ?? 0, - 'apiCurrent' => UserRequest::getApiRequests($user->id), - 'apiMax' => $user->role->apirequests, - 'grabCurrent' => UserDownload::getDownloadRequests($user->id), - 'grabMax' => $user->role->downloadrequests, - 'apiOldestTime' => $apiOldestTime, - 'grabOldestTime' => $oldestGrabTime, - 'Results' => fractal($relData, new ApiTransformer($user)), - ]; + $response = array_merge( + ['Total' => $relData[0]->_totalrows ?? 0], + $this->buildUserStatsResponse($user), + ['Results' => fractal($relData, new ApiTransformer($user))] + ); return response()->json($response); } @@ -236,14 +234,12 @@ class ApiV2Controller extends BasePageController */ public function tv(Request $request): JsonResponse { - if ($request->missing('api_token') || $request->isNotFilled('api_token')) { - return response()->json(['error' => 'Missing parameter (api_token)'], 403); + $user = $this->resolveUser($request); + if (! $user) { + return response()->json(['error' => 'Missing or invalid API key'], 403); } - $user = User::query()->where('api_token', $request->input('api_token'))->first(); - if ($user === null) { - return response()->json(['error' => 'Invalid API Token'], 403); - } - $catExclusions = User::getCategoryExclusionForApi($request); + + $catExclusions = User::getCategoryExclusionById($user->id); $minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0; $this->api->verifyEmptyParameter($request, 'id'); $this->api->verifyEmptyParameter($request, 'vid'); @@ -256,7 +252,7 @@ class ApiV2Controller extends BasePageController $this->api->verifyEmptyParameter($request, 'season'); $this->api->verifyEmptyParameter($request, 'ep'); $maxAge = $this->api->maxAge($request); - UserRequest::addApiRequest($request->input('api_token'), $request->getRequestUri()); + UserRequest::addApiRequest($user->id, $request->getRequestUri()); event(new UserAccessedApi($user, $request->ip())); $siteIdArr = [ @@ -292,36 +288,24 @@ class ApiV2Controller extends BasePageController $catExclusions ); - $time = UserRequest::whereUsersId($user->id)->min('timestamp'); - $apiOldestTime = $time !== null ? Carbon::createFromTimeString($time)->toRfc2822String() : ''; - $grabTime = UserDownload::whereUsersId($user->id)->min('timestamp'); - $oldestGrabTime = $grabTime !== null ? Carbon::createFromTimeString($grabTime)->toRfc2822String() : ''; - - $response = [ - 'Total' => $relData[0]->_totalrows ?? 0, - 'apiCurrent' => UserRequest::getApiRequests($user->id), - 'apiMax' => $user->role->apirequests, - 'grabCurrent' => UserDownload::getDownloadRequests($user->id), - 'grabMax' => $user->role->downloadrequests, - 'apiOldestTime' => $apiOldestTime, - 'grabOldestTime' => $oldestGrabTime, - 'Results' => fractal($relData, new ApiTransformer($user)), - ]; + $response = array_merge( + ['Total' => $relData[0]->_totalrows ?? 0], + $this->buildUserStatsResponse($user), + ['Results' => fractal($relData, new ApiTransformer($user))] + ); return response()->json($response); } public function getNzb(Request $request): \Illuminate\Foundation\Application|JsonResponse|\Illuminate\Routing\Redirector|RedirectResponse|\Illuminate\Contracts\Foundation\Application { - if ($request->missing('api_token') || $request->isNotFilled('api_token')) { - return response()->json(['error' => 'Missing parameter (api_token)'], 403); - } - $user = User::query()->where('api_token', $request->input('api_token'))->first(); - if ($user === null) { - return response()->json(['error' => 'Invalid API Token'], 403); + $user = $this->resolveUser($request); + if (! $user) { + return response()->json(['error' => 'Missing or invalid API key'], 403); } + event(new UserAccessedApi($user, $request->ip())); - UserRequest::addApiRequest($request->input('api_token'), $request->getRequestUri()); + UserRequest::addApiRequest($user->id, $request->getRequestUri()); $relData = Release::checkGuidForApi($request->input('id')); if ($relData) { return redirect('/getnzb?r='.$request->input('api_token').'&id='.$request->input('id').(($request->has('del') && $request->input('del') === '1') ? '&del=1' : '')); @@ -332,20 +316,17 @@ class ApiV2Controller extends BasePageController public function details(Request $request): JsonResponse { - if ($request->missing('api_token') || $request->isNotFilled('api_token')) { - return response()->json(['error' => 'Missing parameter (api_token)'], 403); + $user = $this->resolveUser($request); + if (! $user) { + return response()->json(['error' => 'Missing or invalid API key'], 403); } if ($request->missing('id')) { return response()->json(['error' => 'Missing parameter (guid is required for single release details)'], 400); } - UserRequest::addApiRequest($request->input('api_token'), $request->getRequestUri()); - $user = User::query()->where('api_token', $request->input('api_token'))->first(); - if ($user === null) { - return response()->json(['error' => 'Invalid API Token'], 403); - } + UserRequest::addApiRequest($user->id, $request->getRequestUri()); event(new UserAccessedApi($user, $request->ip())); - $relData = Release::getByGuid($request->input('id')); + $relData = Release::getByGuidForApi($request->input('id')); $relData = fractal($relData, new DetailsTransformer($user)); diff --git a/app/Http/Controllers/Api/XML_Response.php b/app/Http/Controllers/Api/XML_Response.php index 4a4312188..7eeb08892 100644 --- a/app/Http/Controllers/Api/XML_Response.php +++ b/app/Http/Controllers/Api/XML_Response.php @@ -98,7 +98,9 @@ class XML_Response $this->xml = new \XMLWriter; $this->xml->openMemory(); - $this->xml->setIndent(true); + // Disable indentation for API responses (smaller payload, faster generation). + // Clients (Sonarr, Radarr, etc.) don't need pretty-printed XML. + $this->xml->setIndent(false); } public function returnXML(): bool|string @@ -121,6 +123,207 @@ class XML_Response return false; } + /** + * Build the API response as a PHP array instead of XML. + * Used for JSON output to avoid the expensive XML->xml_to_array->json_encode path. + * + * @return array|false + */ + public function returnArray(): array|false + { + return match ($this->type) { + 'caps' => $this->buildCapsArray(), + 'api' => $this->buildApiArray(), + 'reg' => $this->buildRegArray(), + default => false, + }; + } + + /** + * Build capabilities response as array. + * + * @return array + */ + protected function buildCapsArray(): array + { + return [ + 'server' => $this->server['server'], + 'limits' => $this->server['limits'], + 'registration' => $this->server['registration'], + 'searching' => $this->server['searching'], + 'categories' => $this->server['categories'] ?? [], + ]; + } + + /** + * Build API response as array. + * + * @return array + */ + protected function buildApiArray(): array + { + $response = [ + 'offset' => $this->offset, + 'total' => $this->releases[0]->_totalrows ?? 0, + ]; + + $response['apilimits'] = [ + 'apicurrent' => $this->parameters['requests'], + 'apimax' => $this->parameters['apilimit'], + 'grabcurrent' => $this->parameters['grabs'], + 'grabmax' => $this->parameters['downloadlimit'], + ]; + if (! empty($this->parameters['oldestapi'])) { + $response['apilimits']['apioldesttime'] = $this->parameters['oldestapi']; + } + if (! empty($this->parameters['oldestgrab'])) { + $response['apilimits']['graboldesttime'] = $this->parameters['oldestgrab']; + } + + $response['item'] = []; + if (! empty($this->releases)) { + $releases = $this->releases instanceof Release ? [$this->releases] : $this->releases; + foreach ($releases as $release) { + $this->release = $release; + $item = $this->buildReleaseArray(); + $response['item'][] = $item; + } + } + + return $response; + } + + /** + * Build a single release as array. + * + * @return array + */ + protected function buildReleaseArray(): array + { + $serverUrl = $this->server['server']['url']; + $delParam = ((int) $this->parameters['del'] === 1 ? '&del=1' : ''); + + $item = [ + 'title' => $this->release->searchname, + 'guid' => $serverUrl.'/details/'.$this->release->guid, + 'link' => $serverUrl.'/getnzb?id='.$this->release->guid.'.nzb&r='.$this->parameters['token'].$delParam, + 'comments' => $serverUrl.'/details/'.$this->release->guid.'#comments', + 'pubDate' => date(DATE_RSS, strtotime($this->release->adddate)), + 'category' => $this->release->category_name, + 'description' => $this->release->searchname, + ]; + + if (! isset($this->parameters['dl']) || (int) $this->parameters['dl'] === 1) { + $item['enclosure'] = [ + 'url' => $serverUrl.'/getnzb?id='.$this->release->guid.'.nzb&r='.$this->parameters['token'].$delParam, + 'length' => $this->release->size, + 'type' => 'application/x-nzb', + ]; + } + + // Attributes + $attrs = [ + 'category' => $this->release->categories_id, + 'size' => $this->release->size, + ]; + + if (! empty($this->release->coverurl)) { + $attrs['coverurl'] = $serverUrl.'/covers/'.$this->release->coverurl; + } + + if ((int) $this->parameters['extended'] === 1) { + $attrs['files'] = $this->release->totalpart; + + if (($this->release->videos_id > 0 || $this->release->tv_episodes_id > 0)) { + $attrs = array_merge($attrs, $this->buildTvAttrArray()); + } + + if (isset($this->release->imdbid) && $this->release->imdbid > 0) { + $attrs['imdb'] = $this->release->imdbid; + } + if (isset($this->release->anidbid) && $this->release->anidbid > 0) { + $attrs['anidbid'] = $this->release->anidbid; + } + if (isset($this->release->predb_id) && $this->release->predb_id > 0) { + $attrs['prematch'] = '1'; + } + if (isset($this->release->nfostatus) && (int) $this->release->nfostatus === 1) { + $attrs['info'] = $serverUrl.'api?t=info&id='.$this->release->guid.'&r='.$this->parameters['token']; + } + + $attrs['grabs'] = $this->release->grabs; + $attrs['comments'] = $this->release->comments; + $attrs['password'] = $this->release->passwordstatus; + $attrs['usenetdate'] = Carbon::parse($this->release->postdate)->toRssString(); + if (! empty($this->release->group_name)) { + $attrs['group'] = $this->release->group_name; + } + } + + $item['attr'] = $attrs; + + return $item; + } + + /** + * Build TV attributes as array (scalar-safe). + * + * @return array + */ + protected function buildTvAttrArray(): array + { + $attrs = []; + + if (! empty($this->release->title)) { + $attrs['title'] = $this->release->title; + } + if (isset($this->release->series) && $this->release->series > 0) { + $attrs['season'] = $this->release->series; + } + $episodeNum = $this->getScalarOrRelationValue('episode', 'episode'); + if (! empty($episodeNum) && $episodeNum > 0) { + $attrs['episode'] = $episodeNum; + } + if (! empty($this->release->firstaired)) { + $attrs['tvairdate'] = $this->release->firstaired; + } + if (isset($this->release->tvdb) && $this->release->tvdb > 0) { + $attrs['tvdbid'] = $this->release->tvdb; + } + if (isset($this->release->trakt) && $this->release->trakt > 0) { + $attrs['traktid'] = $this->release->trakt; + } + if (isset($this->release->tvrage) && $this->release->tvrage > 0) { + $attrs['tvrageid'] = $this->release->tvrage; + $attrs['rageid'] = $this->release->tvrage; + } + if (isset($this->release->tvmaze) && $this->release->tvmaze > 0) { + $attrs['tvmazeid'] = $this->release->tvmaze; + } + if (isset($this->release->imdb) && $this->release->imdb > 0) { + $attrs['imdbid'] = $this->release->imdb; + } + if (isset($this->release->tmdb) && $this->release->tmdb > 0) { + $attrs['tmdbid'] = $this->release->tmdb; + } + + return $attrs; + } + + /** + * Build registration response as array. + * + * @return array + */ + protected function buildRegArray(): array + { + return [ + 'username' => $this->parameters['username'], + 'password' => $this->parameters['password'], + 'apikey' => $this->parameters['token'], + ]; + } + /** * XML writes and returns the API capabilities. * @@ -469,6 +672,8 @@ class XML_Response /** * Writes the TV Specific attributes. + * Uses scalar-safe access to avoid N+1 lazy loading when release data + * comes from raw SQL queries (stdClass with flat columns) vs Eloquent models. */ protected function setTvAttr(): void { @@ -478,8 +683,10 @@ class XML_Response if (isset($this->release->series) && $this->release->series > 0) { $this->writeZedAttr('season', $this->release->series); } - if (isset($this->release->episode->episode) && $this->release->episode->episode > 0) { - $this->writeZedAttr('episode', $this->release->episode->episode); + // episode can be a scalar (from raw SQL JOIN) or an Eloquent relation object + $episodeNum = $this->getScalarOrRelationValue('episode', 'episode'); + if (! empty($episodeNum) && $episodeNum > 0) { + $this->writeZedAttr('episode', $episodeNum); } if (! empty($this->release->firstaired)) { $this->writeZedAttr('tvairdate', $this->release->firstaired); @@ -505,6 +712,35 @@ class XML_Response } } + /** + * Safely get a value that may be a scalar (from raw SQL) or a property on a related object. + * Prevents N+1 lazy loading when accessing Eloquent relation properties in a loop. + * + * @param string $property The property name on the release (may be scalar or object) + * @param string $subProperty The sub-property to access if $property is an object + * @return mixed The scalar value, or null if not available + */ + protected function getScalarOrRelationValue(string $property, string $subProperty): mixed + { + $value = $this->release->$property ?? null; + + if ($value === null) { + return null; + } + + // If it's a scalar (from raw SQL JOIN), return directly + if (is_scalar($value)) { + return $value; + } + + // If it's an object (Eloquent relation), access the sub-property + if (is_object($value)) { + return $value->$subProperty ?? null; + } + + return null; + } + /** * Writes individual zed (newznab) type attributes. * diff --git a/app/Models/Release.php b/app/Models/Release.php index 1c5ae1a8c..dac42c061 100644 --- a/app/Models/Release.php +++ b/app/Models/Release.php @@ -426,6 +426,44 @@ class Release extends Model return is_array($guid) ? $releases : $releases->first(); } + /** + * Lighter version of getByGuid() optimized for API details responses. + * Skips video.tvInfo, releaseGroup, and fields not used by DetailsTransformer/XML_Response. + */ + public static function getByGuidForApi(mixed $guid): mixed + { + $query = self::with([ + 'group:id,name', + 'category:id,title,root_categories_id', + 'category.parent:id,title', + 'video:id,title,tvdb,trakt,tvrage,tvmaze', + 'episode:id,title,firstaired', + ]); + + if (is_array($guid)) { + $query->whereIn('guid', $guid); + } else { + $query->where('guid', $guid); + } + + $releases = $query->get(); + + $releases->each(function ($release) { + $release->group_name = $release->group->name ?? null; + $release->tvdb = $release->video->tvdb ?? null; + $release->trakt = $release->video->trakt ?? null; + $release->tvrage = $release->video->tvrage ?? null; + $release->tvmaze = $release->video->tvmaze ?? null; + $release->title = $release->episode->title ?? null; + $release->firstaired = $release->episode->firstaired ?? null; + $release->parent_category = $release->category->parent->title ?? null; + $release->sub_category = $release->category->title ?? null; + $release->category_name = $release->parent_category.' > '.$release->sub_category; + }); + + return is_array($guid) ? $releases : $releases->first(); + } + /** * Get a range of releases. used in admin manage list. */ diff --git a/app/Models/UserDownload.php b/app/Models/UserDownload.php index cd0e3aa2d..6953cde91 100644 --- a/app/Models/UserDownload.php +++ b/app/Models/UserDownload.php @@ -61,17 +61,16 @@ class UserDownload extends Model /** * Get the COUNT of how many NZB's the user has downloaded in the past day. - * + * Note: Old request cleanup is no longer done inline to avoid blocking API responses. + * Use a scheduled command to periodically clean old records instead. * * @throws \Exception */ public static function getDownloadRequests(int $userID): int { - // Clear old requests. - self::whereUsersId($userID)->where('timestamp', '<', now()->subDay())->delete(); $value = self::whereUsersId($userID)->where('timestamp', '>', now()->subDay())->count('id'); - return $value === false ? 0 : $value; + return $value ?: 0; } /** diff --git a/app/Models/UserRequest.php b/app/Models/UserRequest.php index 4e05c32ff..cbe72ffb5 100644 --- a/app/Models/UserRequest.php +++ b/app/Models/UserRequest.php @@ -66,18 +66,20 @@ class UserRequest extends Model /** * Get the quantity of API requests in the last day for the users_id. - * + * Note: Old request cleanup is no longer done inline to avoid blocking API responses. + * Use clearApiRequests() via a scheduled command or queue job instead. * * @throws \Exception * @throws \Throwable */ public static function getApiRequests(int $userID): int { - // Clear old requests. - self::clearApiRequests($userID); - $requests = self::query()->where('users_id', $userID)->count('id'); + $requests = self::query() + ->where('users_id', $userID) + ->where('timestamp', '>', now()->subDay()) + ->count('id'); - return ! $requests ? 0 : $requests; + return $requests ?: 0; } /** @@ -117,12 +119,16 @@ class UserRequest extends Model /** * If a user accesses the API, log it. * - * @param string $token API token of the user + * @param string|int $tokenOrUserId API token string or user ID integer * @param string $request The API request. */ - public static function addApiRequest(string $token, string $request): void + public static function addApiRequest(string|int $tokenOrUserId, string $request): void { - $userID = User::query()->select(['id'])->where('api_token', $token)->value('id'); + if (is_int($tokenOrUserId)) { + $userID = $tokenOrUserId; + } else { + $userID = User::query()->select(['id'])->where('api_token', $tokenOrUserId)->value('id'); + } self::query()->insert(['users_id' => $userID, 'request' => $request, 'timestamp' => now()]); } diff --git a/resources/views/api/apidesc.blade.php b/resources/views/api/apidesc.blade.php index 97694a163..b44bf0b04 100644 --- a/resources/views/api/apidesc.blade.php +++ b/resources/views/api/apidesc.blade.php @@ -11,19 +11,19 @@ Here lives the documentation for the API for accessing NZB and index data. API functions can be called by either logged in users, or by providing an API key.

- @if($loggedin ?? false) + @auth

Your API Credentials

- +
- @endif + @endauth

Available Functions

@@ -72,16 +72,23 @@
- @if($loggedin ?? false) - + @auth + ?t=search&q=linux - + ?t=search&cat={{ $catClass::GAME_ROOT }},{{ $catClass::MOVIE_ROOT }} - @endif + @else + + ?t=search&q=linux + + + ?t=search&cat={{ $catClass::GAME_ROOT }},{{ $catClass::MOVIE_ROOT }} + + @endauth
@@ -104,12 +111,16 @@ - @if($loggedin ?? false) - + @auth + ?t=tvsearch&q=law and order&season=7&ep=12 - @endif + @else + + ?t=tvsearch&q=law and order&season=7&ep=12 + + @endauth @@ -126,24 +137,32 @@ - @if($loggedin ?? false) - + @auth + ?t=movie&imdbid=1418646 - @endif + @else + + ?t=movie&imdbid=1418646 + + @endauth Details Returns detailed information about an NZB. - @if($loggedin ?? false) - + @auth + ?t=details&id=9ca52909ba9b9e5e6758d815fef4ecda - @endif + @else + + ?t=details&id=<guid> + + @endauth @@ -154,24 +173,32 @@ - @if($loggedin ?? false) - + @auth + ?t=info&id=9ca52909ba9b9e5e6758d815fef4ecda - @endif + @else + + ?t=info&id=<guid> + + @endauth Get Downloads the NZB file associated with an ID. - @if($loggedin ?? false) - + @auth + ?t=get&id=9ca52909ba9b9e5e6758d815fef4ecda - @endif + @else + + ?t=get&id=<guid> + + @endauth diff --git a/resources/views/api/apiv2desc.blade.php b/resources/views/api/apiv2desc.blade.php index 2a2b8a19e..a1c4feb29 100644 --- a/resources/views/api/apiv2desc.blade.php +++ b/resources/views/api/apiv2desc.blade.php @@ -12,19 +12,19 @@ Here lives the documentation for the API v2 for accessing NZB and index data. API functions can be called by providing an API token.

- @if($loggedin ?? false) + @auth

Your API Credentials

- +
- @endif + @endauth

Available Functions @@ -68,16 +68,23 @@
- @if($loggedin ?? false) - + @auth + search?id=linux - + search?cat={{ $catClass::GAME_ROOT }},{{ $catClass::MOVIE_ROOT }} - @endif + @else + + search?id=linux + + + search?cat={{ $catClass::GAME_ROOT }},{{ $catClass::MOVIE_ROOT }} + + @endauth
@@ -101,16 +108,23 @@
- @if($loggedin ?? false) - + @auth + tv?id=law and order&season=7&ep=12 - + tv?rid=2204&cat={{ $catClass::GAME_ROOT }},{{ $catClass::MOVIE_ROOT }} - @endif + @else + + tv?id=law and order&season=7&ep=12 + + + tv?rid=2204&cat={{ $catClass::GAME_ROOT }},{{ $catClass::MOVIE_ROOT }} + + @endauth
@@ -131,16 +145,23 @@
- @if($loggedin ?? false) - + @auth + movies?imdbid=1418646 - + movies?imdbid=1418646&cat={{ $catClass::MOVIE_SD }},{{ $catClass::MOVIE_HD }} - @endif + @else + + movies?imdbid=1418646 + + + movies?imdbid=1418646&cat={{ $catClass::MOVIE_SD }},{{ $catClass::MOVIE_HD }} + + @endauth
@@ -148,24 +169,32 @@ Details Returns detailed information about an NZB. - @if($loggedin ?? false) - + @auth + details?id=9ca52909ba9b9e5e6758d815fef4ecda - @endif + @else + + details?id=<guid> + + @endauth Get NZB Downloads the NZB file associated with an ID. - @if($loggedin ?? false) - + @auth + getnzb?id=9ca52909ba9b9e5e6758d815fef4ecda - @endif + @else + + getnzb?id=<guid> + + @endauth @@ -194,4 +223,3 @@ @endsection - diff --git a/resources/views/rss/rssdesc.blade.php b/resources/views/rss/rssdesc.blade.php index 752f29885..30051b893 100644 --- a/resources/views/rss/rssdesc.blade.php +++ b/resources/views/rss/rssdesc.blade.php @@ -13,19 +13,19 @@ direct NZB downloads based on your preferences.

- @if($loggedin ?? false) + @auth

Your API Token

- +
- @endif + @endauth

RSS Configuration Options @@ -43,7 +43,7 @@ api_token Add this to your feed URL to allow NZB downloads without logging in - &api_token={{ $userdata->api_token ?? 'YOUR_TOKEN' }} + &api_token={{ auth()->user()->api_token ?? 'YOUR_TOKEN' }} del=1 @@ -99,12 +99,12 @@ Full Site Feed - + Open Feed
- + @@ -117,12 +117,12 @@ My Cart Feed - + Open Feed
- + @@ -134,12 +134,12 @@ My Shows Feed - + Open Feed
- + @@ -151,12 +151,12 @@ My Movies Feed - + Open Feed
- + @@ -179,12 +179,12 @@ Trending Movies - + Open Feed
- + @@ -195,12 +195,12 @@ Trending TV Shows - + Open Feed
- + @@ -224,12 +224,12 @@ {{ $category['title'] }} - + Open Feed
- + @@ -258,12 +258,12 @@ {{ $category['title'] }} - + Open Feed
- + diff --git a/routes/console.php b/routes/console.php index 0b3168c3c..f9bee2e86 100644 --- a/routes/console.php +++ b/routes/console.php @@ -49,5 +49,10 @@ if (config('nntmux.purge_inactive_users') === true) { Schedule::job(new RemoveInactiveAccounts)->daily(); Schedule::job(new PurgeDeletedAccounts)->daily(); } +// Cleanup old API requests and download logs (older than 1 day) - deferred from inline API calls +Schedule::call(function () { + \App\Models\UserRequest::clearApiRequests(false); + \App\Models\UserDownload::where('timestamp', '<', now()->subDay())->delete(); +})->name('cleanup-api-request-logs')->hourly()->withoutOverlapping(); // Check tmux health and auto-restart if monitor pane is dead Schedule::command('tmux:health-check --auto-restart')->everyThirtyMinutes()->withoutOverlapping();