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
- @endif
+ @endauth
Available Functions
@@ -72,16 +72,23 @@
@@ -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
- @endif
+ @endauth
Available Functions
@@ -68,16 +68,23 @@
@@ -101,16 +108,23 @@
@@ -131,16 +145,23 @@
@@ -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
- @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
-
+
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();