From b7fdf3dafd60db88d9e5ef15f960ee4c8bf90198 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 14 Apr 2026 13:40:28 +0200 Subject: [PATCH] Update API spec and adjust the code accordingly --- app/Extensions/helper/helpers.php | 5 + app/Http/Controllers/Api/ApiController.php | 143 ++- app/Http/Controllers/Api/ApiV2Controller.php | 146 ++- app/Http/Controllers/Api/XML_Response.php | 33 + .../Releases/ReleaseSearchService.php | 79 +- docs/newznab_api_specification.txt | 952 +++++------------- docs/nntmux_api_v2.md | 305 +++--- resources/views/api/apidesc.blade.php | 46 + resources/views/api/apiv2desc.blade.php | 46 + tests/Feature/ApiRequestMatrixTest.php | 285 ++++++ 10 files changed, 1090 insertions(+), 950 deletions(-) create mode 100644 tests/Feature/ApiRequestMatrixTest.php diff --git a/app/Extensions/helper/helpers.php b/app/Extensions/helper/helpers.php index 96e3f2647..ccb364617 100644 --- a/app/Extensions/helper/helpers.php +++ b/app/Extensions/helper/helpers.php @@ -929,8 +929,13 @@ if (! function_exists('showApiError')) { 202 => ['No such function', 'HTTP 1.1 404 Not Found'], 203 => ['Function not available', 'HTTP 1.1 400 Bad Request'], 300 => ['No such item', 'HTTP 1.1 404 Not Found'], + 310 => ['Item already exists', 'HTTP 1.1 409 Conflict'], 500 => ['Request limit reached', 'HTTP 1.1 429 Too Many Requests'], 501 => ['Download limit reached', 'HTTP 1.1 429 Too Many Requests'], + 600 => ['Failed to load NZB', 'HTTP 1.1 400 Bad Request'], + 601 => ['NZB is duplicate', 'HTTP 1.1 409 Conflict'], + 602 => ['NZB is for a non-existent group', 'HTTP 1.1 400 Bad Request'], + 603 => ['NZB failed to write to disk', 'HTTP 1.1 500 Internal Server Error'], 910 => ['API disabled', 'HTTP 1.1 401 Unauthorized'], default => ['Unknown error', 'HTTP 1.1 400 Bad Request'], }; diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index ae9c96dbd..0dfb35bc7 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api; use App\Events\UserAccessedApi; use App\Http\Controllers\BasePageController; use App\Models\Category; +use App\Models\Genre; use App\Models\Release; use App\Models\ReleaseNfo; use App\Models\Settings; @@ -27,6 +28,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -181,6 +183,13 @@ class ApiController extends BasePageController case 's': $this->verifyEmptyParameter($request, 'q'); $maxAge = $this->maxAge($request); + if (! is_int($maxAge)) { + return $maxAge; + } + $sort = $this->sort($request); + if (! is_string($sort)) { + return $sort; + } $groupName = $this->group($request); UserRequest::addApiRequest($uid, $request->getRequestUri()); $categoryID = $this->categoryID($request); @@ -195,7 +204,8 @@ class ApiController extends BasePageController $maxAge, $catExclusions, $categoryID, - $minSize + $minSize, + $sort ); } else { $relData = $this->releaseBrowseService->getBrowseRangeForApi( @@ -203,7 +213,7 @@ class ApiController extends BasePageController $categoryID, $offset, $limit, - '', + $sort, $maxAge, $catExclusions, $groupName, @@ -225,6 +235,13 @@ class ApiController extends BasePageController $this->verifyEmptyParameter($request, 'season'); $this->verifyEmptyParameter($request, 'ep'); $maxAge = $this->maxAge($request); + if (! is_int($maxAge)) { + return $maxAge; + } + $sort = $this->sort($request); + if (! is_string($sort)) { + return $sort; + } UserRequest::addApiRequest($uid, $request->getRequestUri()); $siteIdArr = [ @@ -258,7 +275,8 @@ class ApiController extends BasePageController $this->categoryID($request), $maxAge, $minSize, - $catExclusions + $catExclusions, + $sort ); $this->output($relData, $params, $outputXML, $offset, 'api'); @@ -269,6 +287,13 @@ class ApiController extends BasePageController $this->verifyEmptyParameter($request, 'q'); $this->verifyEmptyParameter($request, 'imdbid'); $maxAge = $this->maxAge($request); + if (! is_int($maxAge)) { + return $maxAge; + } + $sort = $this->sort($request); + if (! is_string($sort)) { + return $sort; + } UserRequest::addApiRequest($uid, $request->getRequestUri()); $imdbId = $request->has('imdbid') && $request->filled('imdbid') @@ -287,7 +312,8 @@ class ApiController extends BasePageController $this->categoryID($request), $maxAge, $minSize, - $catExclusions + $catExclusions, + $sort ); $this->addCoverURL( @@ -308,6 +334,10 @@ class ApiController extends BasePageController if (! is_int($maxAge)) { return $maxAge; } + $sort = $this->sort($request); + if (! is_string($sort)) { + return $sort; + } $groupName = $this->group($request); UserRequest::addApiRequest($uid, $request->getRequestUri()); $relData = $this->releaseSearchService->apiMusicSearch( @@ -318,7 +348,8 @@ class ApiController extends BasePageController $maxAge, $catExclusions, $this->categoryID($request), - $minSize + $minSize, + $sort ); $this->output($relData, $params, $outputXML, $offset, 'api'); break; @@ -331,6 +362,10 @@ class ApiController extends BasePageController if (! is_int($maxAge)) { return $maxAge; } + $sort = $this->sort($request); + if (! is_string($sort)) { + return $sort; + } $groupName = $this->group($request); UserRequest::addApiRequest($uid, $request->getRequestUri()); $relData = $this->releaseSearchService->apiBookSearch( @@ -341,7 +376,8 @@ class ApiController extends BasePageController $maxAge, $catExclusions, $this->categoryID($request), - $minSize + $minSize, + $sort ); $this->output($relData, $params, $outputXML, $offset, 'api'); break; @@ -357,6 +393,10 @@ class ApiController extends BasePageController if (! is_int($maxAge)) { return $maxAge; } + $sort = $this->sort($request); + if (! is_string($sort)) { + return $sort; + } UserRequest::addApiRequest($uid, $request->getRequestUri()); $relData = $this->releaseSearchService->animeSearch( $anidb, @@ -366,7 +406,8 @@ class ApiController extends BasePageController $this->categoryID($request), $maxAge, $catExclusions, - $anilist + $anilist, + $sort ); $this->output($relData, $params, $outputXML, $offset, 'api'); break; @@ -426,18 +467,18 @@ class ApiController extends BasePageController // case 'nzbAdd': if (! User::canPost($uid)) { - return response('User does not have permission to post', 403); + return showApiError(102, 'Insufficient privileges/not authorized'); } if ($request->missing('file')) { - return response('Missing parameter (file is required for adding an NZB)', 400); + return showApiError(200, 'Missing parameter (file is required for adding an NZB)'); } if ($request->missing('apikey')) { - return response('Missing parameter (apikey is required for adding an NZB)', 400); + return showApiError(200, 'Missing parameter (apikey is required for adding an NZB)'); } if (! $request->hasFile('file')) { - return response('Missing parameter (file is required for adding an NZB)', 400); + return showApiError(600, 'Failed to load NZB'); } UserRequest::addApiRequest($uid, $request->getRequestUri()); @@ -448,11 +489,11 @@ class ApiController extends BasePageController if ($nzbFile !== null) { // We need to check if file is an actual nzb file. if ($nzbFile->getClientOriginalExtension() !== 'nzb') { - return response('File is not an NZB file', 400); + return showApiError(600, 'Failed to load NZB (file is not an NZB file)'); } // Check if the file is proper xml nzb file. if (! isValidNewznabNzb($nzbFile->getContent())) { - return response('File is not a valid Newznab NZB file', 400); + return showApiError(600, 'Failed to load NZB (invalid NZB payload)'); } if (! File::isDirectory(config('nntmux.nzb_upload_folder'))) { @File::makeDirectory(config('nntmux.nzb_upload_folder'), 0775, true); @@ -461,16 +502,24 @@ class ApiController extends BasePageController if (File::put(config('nntmux.nzb_upload_folder').$nzbFile->getClientOriginalName(), $nzbFile->getContent())) { Log::channel('nzb_upload')->info('NZB file uploaded by API: '.$nzbFile->getClientOriginalName()); - return response('NZB file uploaded successfully', 200); + $successXml = sprintf( + "\n\n", + (string) $request->input('cat', ''), + htmlspecialchars(pathinfo($nzbFile->getClientOriginalName(), PATHINFO_FILENAME), ENT_QUOTES, 'UTF-8') + ); + + return response($successXml, 200)->header('Content-type', 'text/xml'); } Log::channel('nzb_upload')->warning('NZB file uploaded by API failed: '.$nzbFile->getClientOriginalName()); } else { Log::channel('nzb_upload')->warning('NZB file uploaded by API failed: no file provided'); - return response('NZB file upload failed', 500); + return showApiError(603, 'NZB failed to write to disk'); } + return showApiError(603, 'NZB failed to write to disk'); + break; // Capabilities request. @@ -551,12 +600,12 @@ class ApiController extends BasePageController 'default' => 100, ], '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' => 'yes', 'supportedParams' => 'q,cat,minsize,maxage,group'], - 'book-search' => ['available' => 'yes', 'supportedParams' => 'q,cat,minsize,maxage,group'], - 'anime-search' => ['available' => 'yes', 'supportedParams' => 'q,anidbid,anilistid,cat,maxage'], + 'search' => ['available' => 'yes', 'supportedParams' => 'q,group,minsize,maxsize,maxage,cat,limit,offset,attrs,extended,del,sort'], + 'tv-search' => ['available' => 'yes', 'supportedParams' => 'q,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep,cat,minsize,maxsize,maxage,limit,offset,attrs,extended,del,sort'], + 'movie-search' => ['available' => 'yes', 'supportedParams' => 'q,imdbid,tmdbid,traktid,genre,cat,minsize,maxsize,maxage,limit,offset,attrs,extended,del,sort'], + 'audio-search' => ['available' => 'yes', 'supportedParams' => 'q,cat,minsize,maxsize,maxage,group,limit,offset,attrs,extended,del,sort'], + 'book-search' => ['available' => 'yes', 'supportedParams' => 'q,title,author,cat,minsize,maxsize,maxage,group,limit,offset,attrs,extended,del,sort'], + 'anime-search' => ['available' => 'yes', 'supportedParams' => 'q,anidbid,anilistid,cat,minsize,maxsize,maxage,limit,offset,attrs,extended,del,sort'], ], ]; }); @@ -569,6 +618,34 @@ class ApiController extends BasePageController // Only load categories for caps requests (also cached via Category::getForMenu) $serverInfo['categories'] = $includeCats ? Category::getForMenu() : null; + $serverInfo['groups'] = $includeCats + ? (Schema::hasTable('usenet_groups') + ? UsenetGroup::query() + ->where('active', 1) + ->orderBy('name') + ->get(['name', 'description', 'last_updated']) + ->map(static fn (UsenetGroup $group): array => [ + 'name' => $group->name, + 'description' => (string) ($group->description ?? ''), + 'lastupdate' => $group->last_updated ? Carbon::parse($group->last_updated)->toRfc2822String() : '', + ]) + ->all() + : []) + : null; + $serverInfo['genres'] = $includeCats + ? (Schema::hasTable('genres') + ? Genre::query() + ->enabled() + ->orderBy('title') + ->get(['id', 'title', 'type']) + ->map(static fn (Genre $genre): array => [ + 'id' => $genre->id, + 'name' => $genre->title, + 'categoryid' => (int) ($genre->type ?? 0), + ]) + ->all() + : []) + : null; return $serverInfo; } @@ -674,6 +751,30 @@ class ApiController extends BasePageController return $offset; } + /** + * Validate and normalize the API sort parameter. + * + * @return Application|ResponseFactory|\Illuminate\Foundation\Application|Response|string + */ + public function sort(Request $request) + { + $defaultSort = 'posted_desc'; + if (! $request->has('sort')) { + return $defaultSort; + } + + $sort = strtolower(trim((string) $request->input('sort'))); + if ($sort === '') { + return showApiError(201, 'Incorrect parameter (sort must not be empty)'); + } + + if (! preg_match('/^(cat|name|size|files|stats|posted)_(asc|desc)$/', $sort)) { + return showApiError(201, 'Incorrect parameter (sort must be one of: cat_asc/desc, name_asc/desc, size_asc/desc, files_asc/desc, stats_asc/desc, posted_asc/desc)'); + } + + return $sort; + } + /** * Check if a parameter is empty. * diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index f3be43093..81d2b87d5 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -7,8 +7,10 @@ namespace App\Http\Controllers\Api; use App\Events\UserAccessedApi; use App\Http\Controllers\BasePageController; use App\Models\Category; +use App\Models\Genre; use App\Models\Release; use App\Models\Settings; +use App\Models\UsenetGroup; use App\Models\User; use App\Models\UserRequest; use App\Services\RegistrationStatusService; @@ -17,11 +19,16 @@ use App\Services\Releases\ReleaseSearchService; use App\Transformers\ApiTransformer; use App\Transformers\CategoryTransformer; use App\Transformers\DetailsTransformer; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Contracts\Routing\ResponseFactory; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; 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\Schema; use Illuminate\Support\Str; class ApiV2Controller extends BasePageController @@ -82,6 +89,38 @@ class ApiV2Controller extends BasePageController ]; } + private function parseMaxAge(Request $request): int|JsonResponse + { + if (! $request->has('maxage')) { + return -1; + } + if ($request->isNotFilled('maxage')) { + return response()->json(['error' => 'Incorrect parameter (maxage must not be empty)'], 400); + } + if (! is_numeric($request->input('maxage'))) { + return response()->json(['error' => 'Incorrect parameter (maxage must be numeric)'], 400); + } + + return (int) $request->input('maxage'); + } + + private function parseSort(Request $request): string|JsonResponse + { + if (! $request->has('sort')) { + return 'posted_desc'; + } + + $sort = strtolower(trim((string) $request->input('sort'))); + if ($sort === '') { + return response()->json(['error' => 'Incorrect parameter (sort must not be empty)'], 400); + } + if (! preg_match('/^(cat|name|size|files|stats|posted)_(asc|desc)$/', $sort)) { + return response()->json(['error' => 'Incorrect parameter (sort must be one of: cat_asc/desc, name_asc/desc, size_asc/desc, files_asc/desc, stats_asc/desc, posted_asc/desc)'], 400); + } + + return $sort; + } + public function capabilities(): JsonResponse { // Cache the full capabilities response for 10 minutes @@ -100,14 +139,38 @@ class ApiV2Controller extends BasePageController 'default' => 100, ], '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' => 'yes', 'supportedParams' => 'id,cat,minsize,maxage,group'], - 'book-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxage,group'], - 'anime-search' => ['available' => 'yes', 'supportedParams' => 'id,anidbid,anilistid,cat,maxage'], + 'search' => ['available' => 'yes', 'supportedParams' => 'id,group,minsize,maxsize,maxage,cat,limit,offset,sort'], + 'tv-search' => ['available' => 'yes', 'supportedParams' => 'id,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep,cat,minsize,maxsize,maxage,limit,offset,sort'], + 'movie-search' => ['available' => 'yes', 'supportedParams' => 'id,imdbid,tmdbid,traktid,genre,cat,minsize,maxsize,maxage,limit,offset,sort'], + 'audio-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,sort'], + 'book-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,sort'], + 'anime-search' => ['available' => 'yes', 'supportedParams' => 'id,anidbid,anilistid,cat,minsize,maxsize,maxage,limit,offset,sort'], ], 'categories' => fractal($category, new CategoryTransformer), + 'groups' => Schema::hasTable('usenet_groups') + ? UsenetGroup::query() + ->where('active', 1) + ->orderBy('name') + ->get(['name', 'description', 'last_updated']) + ->map(static fn (UsenetGroup $group): array => [ + 'name' => $group->name, + 'description' => (string) ($group->description ?? ''), + 'lastupdate' => $group->last_updated ? Carbon::parse($group->last_updated)->toRfc2822String() : '', + ]) + ->values() + : collect(), + 'genres' => Schema::hasTable('genres') + ? Genre::query() + ->enabled() + ->orderBy('title') + ->get(['id', 'title', 'type']) + ->map(static fn (Genre $genre): array => [ + 'id' => $genre->id, + 'name' => $genre->title, + 'categoryid' => (int) ($genre->type ?? 0), + ]) + ->values() + : collect(), ]; }); @@ -142,18 +205,25 @@ class ApiV2Controller extends BasePageController $offset = $this->api->offset($request); $limit = $this->api->limit($request); $categoryID = $this->api->categoryID($request); - $maxAge = $this->api->maxAge($request); + $maxAge = $this->parseMaxAge($request); + if (! is_int($maxAge)) { + return $maxAge; + } + $sort = $this->parseSort($request); + if (! is_string($sort)) { + return $sort; + } $catExclusions = User::getCategoryExclusionById($user->id); // Create cache key for movie search results $searchCacheKey = 'api_movie_search:'.md5(serialize([ - $imdbId, $tmdbId, $traktId, $offset, $limit, $searchName, + $imdbId, $tmdbId, $traktId, $offset, $limit, $searchName, $sort, $categoryID, $maxAge, $minSize, $catExclusions, ])); // Cache search results for 10 minutes $relData = Cache::remember($searchCacheKey, 600, function () use ( - $imdbId, $tmdbId, $traktId, $offset, $limit, $searchName, + $imdbId, $tmdbId, $traktId, $offset, $limit, $searchName, $sort, $categoryID, $maxAge, $minSize, $catExclusions ) { return $this->releaseSearchService->moviesSearch( @@ -166,7 +236,8 @@ class ApiV2Controller extends BasePageController $categoryID, $maxAge, $minSize, - $catExclusions + $catExclusions, + $sort ); }); @@ -197,10 +268,14 @@ class ApiV2Controller extends BasePageController $offset = $this->api->offset($request); $limit = $this->api->limit($request); $categoryID = $this->api->categoryID($request); - $maxAge = $this->api->maxAge($request); + $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; } + $sort = $this->parseSort($request); + if (! is_string($sort)) { + return $sort; + } $minSize = max(0, (int) $request->input('minsize', 0)); $catExclusions = User::getCategoryExclusionById($user->id); @@ -214,7 +289,8 @@ class ApiV2Controller extends BasePageController $maxAge, $catExclusions, $categoryID, - $minSize + $minSize, + $sort ); $response = array_merge( @@ -244,10 +320,14 @@ class ApiV2Controller extends BasePageController $offset = $this->api->offset($request); $limit = $this->api->limit($request); $categoryID = $this->api->categoryID($request); - $maxAge = $this->api->maxAge($request); + $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; } + $sort = $this->parseSort($request); + if (! is_string($sort)) { + return $sort; + } $minSize = max(0, (int) $request->input('minsize', 0)); $catExclusions = User::getCategoryExclusionById($user->id); @@ -261,7 +341,8 @@ class ApiV2Controller extends BasePageController $maxAge, $catExclusions, $categoryID, - $minSize + $minSize, + $sort ); $response = array_merge( @@ -293,10 +374,14 @@ class ApiV2Controller extends BasePageController $offset = $this->api->offset($request); $limit = $this->api->limit($request); $categoryID = $this->api->categoryID($request); - $maxAge = $this->api->maxAge($request); + $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; } + $sort = $this->parseSort($request); + if (! is_string($sort)) { + return $sort; + } $catExclusions = User::getCategoryExclusionById($user->id); @@ -308,7 +393,8 @@ class ApiV2Controller extends BasePageController $categoryID, $maxAge, $catExclusions, - $anilist + $anilist, + $sort ); $response = array_merge( @@ -337,7 +423,14 @@ class ApiV2Controller extends BasePageController $offset = $this->api->offset($request); $catExclusions = User::getCategoryExclusionById($user->id); $minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0; - $maxAge = $this->api->maxAge($request); + $maxAge = $this->parseMaxAge($request); + if (! is_int($maxAge)) { + return $maxAge; + } + $sort = $this->parseSort($request); + if (! is_string($sort)) { + return $sort; + } $groupName = $this->api->group($request); if (is_array($groupName)) { $groupName = $groupName[0] ?? -1; @@ -354,7 +447,8 @@ class ApiV2Controller extends BasePageController $maxAge, $catExclusions, $categoryID, - $minSize + $minSize, + $sort ); } else { $relData = $this->releaseBrowseService->getBrowseRangeForApi( @@ -362,7 +456,7 @@ class ApiV2Controller extends BasePageController $categoryID, $offset, $limit, - '', + $sort, $maxAge, $catExclusions, $groupName, @@ -402,7 +496,14 @@ class ApiV2Controller extends BasePageController $this->api->verifyEmptyParameter($request, 'tmdbid'); $this->api->verifyEmptyParameter($request, 'season'); $this->api->verifyEmptyParameter($request, 'ep'); - $maxAge = $this->api->maxAge($request); + $maxAge = $this->parseMaxAge($request); + if (! is_int($maxAge)) { + return $maxAge; + } + $sort = $this->parseSort($request); + if (! is_string($sort)) { + return $sort; + } UserRequest::addApiRequest($user->id, $request->getRequestUri()); event(new UserAccessedApi($user, $request->ip())); @@ -436,7 +537,8 @@ class ApiV2Controller extends BasePageController $this->api->categoryID($request), $maxAge, $minSize, - $catExclusions + $catExclusions, + $sort ); $response = array_merge( @@ -448,7 +550,7 @@ class ApiV2Controller extends BasePageController return response()->json($response); } - public function getNzb(Request $request): Application|JsonResponse|Redirector|RedirectResponse|\Illuminate\Contracts\Foundation\Application + public function getNzb(Request $request): Application|ResponseFactory|JsonResponse|Redirector|RedirectResponse { $user = $this->resolveUser($request); if (! $user) { diff --git a/app/Http/Controllers/Api/XML_Response.php b/app/Http/Controllers/Api/XML_Response.php index d455fed34..28f3c2800 100644 --- a/app/Http/Controllers/Api/XML_Response.php +++ b/app/Http/Controllers/Api/XML_Response.php @@ -154,6 +154,8 @@ class XML_Response 'registration' => $this->server['registration'], 'searching' => $this->server['searching'], 'categories' => $this->server['categories'] ?? [], + 'groups' => $this->server['groups'] ?? [], + 'genres' => $this->server['genres'] ?? [], ]; } @@ -340,6 +342,8 @@ class XML_Response $this->addNode(['name' => 'registration', 'data' => $this->server['registration']]); $this->addNodes(['name' => 'searching', 'data' => $this->server['searching']]); $this->writeCategoryListing(); + $this->writeGroupListing(); + $this->writeGenreListing(); $this->xml->endElement(); $this->xml->endDocument(); @@ -461,6 +465,35 @@ class XML_Response } $this->xml->endElement(); } + $this->xml->endElement(); + } + + protected function writeGroupListing(): void + { + $this->xml->startElement('groups'); + foreach (($this->server['groups'] ?? []) as $group) { + $this->xml->startElement('group'); + $this->xml->writeAttribute('name', (string) ($group['name'] ?? '')); + $this->xml->writeAttribute('description', (string) ($group['description'] ?? '')); + if (! empty($group['lastupdate'])) { + $this->xml->writeAttribute('lastupdate', (string) $group['lastupdate']); + } + $this->xml->endElement(); + } + $this->xml->endElement(); + } + + protected function writeGenreListing(): void + { + $this->xml->startElement('genres'); + foreach (($this->server['genres'] ?? []) as $genre) { + $this->xml->startElement('genre'); + $this->xml->writeAttribute('id', (string) ($genre['id'] ?? '')); + $this->xml->writeAttribute('name', (string) ($genre['name'] ?? '')); + $this->xml->writeAttribute('categoryid', (string) ($genre['categoryid'] ?? '0')); + $this->xml->endElement(); + } + $this->xml->endElement(); } /** diff --git a/app/Services/Releases/ReleaseSearchService.php b/app/Services/Releases/ReleaseSearchService.php index fa22d836c..6a19d4b74 100644 --- a/app/Services/Releases/ReleaseSearchService.php +++ b/app/Services/Releases/ReleaseSearchService.php @@ -144,7 +144,7 @@ class ReleaseSearchService * @param array $excludedCats * @return Collection|mixed */ - public function apiSearch(mixed $searchName, mixed $groupName, int $offset = 0, int $limit = 1000, int $maxAge = -1, array $excludedCats = [], array $cat = [-1], int $minSize = 0): mixed + public function apiSearch(mixed $searchName, mixed $groupName, int $offset = 0, int $limit = 1000, int $maxAge = -1, array $excludedCats = [], array $cat = [-1], int $minSize = 0, string $orderBy = 'posted_desc'): mixed { if (config('app.debug')) { Log::debug('ReleaseSearchService::apiSearch called', [ @@ -156,6 +156,7 @@ class ReleaseSearchService } $hasText = $searchName !== -1 && $searchName !== '' && $searchName !== null; + [$orderField, $orderDir] = $this->getBrowseOrder($orderBy); if (Search::isAvailable()) { $groupId = null; @@ -182,15 +183,15 @@ class ReleaseSearchService 'max_age_days' => $maxAge, 'groups_id' => $groupId, 'password_allow_rar' => str_contains($this->showPasswords(), '<='), - 'sort_field' => 'postdate_ts', - 'sort_dir' => 'desc', + 'sort_field' => $this->browseOrderToIndexSortField($orderField), + 'sort_dir' => $orderDir, 'try_fuzzy' => true, ]; $filtered = Search::searchReleasesFiltered($criteria, $limit, $offset); if ($filtered['ids'] === [] && $hasText && config('nntmux.mysql_search_fallback', false) === true) { - return $this->apiSearchLegacyMysql($searchName, $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize); + return $this->apiSearchLegacyMysql($searchName, $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize, $orderBy); } if ($filtered['ids'] === []) { @@ -199,8 +200,6 @@ class ReleaseSearchService $ids = array_map(static fn (int|string $id): int => (int) $id, $filtered['ids']); $idList = implode(',', $ids); - $fieldOrder = implode(',', $ids); - $whereSql = 'WHERE r.id IN ('.$idList.')'; $sql = sprintf( @@ -219,9 +218,10 @@ class ReleaseSearchService 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 FIELD(r.id, %s)", + ORDER BY r.%s %s", $whereSql, - $fieldOrder + $orderField, + $orderDir ); $cacheKey = md5($this->getCacheVersion().$sql); @@ -242,7 +242,7 @@ class ReleaseSearchService return $releases; } - return $this->apiSearchLegacyMysql($searchName, $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize); + return $this->apiSearchLegacyMysql($searchName, $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize, $orderBy); } /** @@ -250,8 +250,9 @@ class ReleaseSearchService * * @param array $cat */ - private function apiSearchLegacyMysql(mixed $searchName, mixed $groupName, int $offset, int $limit, int $maxAge, array $excludedCats, array $cat, int $minSize): mixed + private function apiSearchLegacyMysql(mixed $searchName, mixed $groupName, int $offset, int $limit, int $maxAge, array $excludedCats, array $cat, int $minSize, string $orderBy = 'posted_desc'): mixed { + [$orderField, $orderDir] = $this->getBrowseOrder($orderBy); $searchLimit = $this->determineSearchCandidateLimit($offset, $limit); $searchResult = []; @@ -331,9 +332,11 @@ class ReleaseSearchService 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 + ORDER BY r.%s %s LIMIT %d OFFSET %d", $whereSql, + $orderField, + $orderDir, $limit, $offset ); @@ -372,7 +375,8 @@ class ReleaseSearchService int $maxAge, array $excludedCats, array $cat, - int $minSize + int $minSize, + string $orderBy = 'posted_desc' ): mixed { $q = trim($q); if ($q === '' || ! Search::isAvailable()) { @@ -381,7 +385,7 @@ class ReleaseSearchService $musicInfoIds = Search::searchSecondary(SecondarySearchIndex::Music, $q, 2000)['id']; - return $this->apiSearchByMetadataForeignKey($musicInfoIds, 'musicinfo_id', $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize); + return $this->apiSearchByMetadataForeignKey($musicInfoIds, 'musicinfo_id', $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize, $orderBy); } /** @@ -398,7 +402,8 @@ class ReleaseSearchService int $maxAge, array $excludedCats, array $cat, - int $minSize + int $minSize, + string $orderBy = 'posted_desc' ): mixed { $q = trim($q); if ($q === '' || ! Search::isAvailable()) { @@ -407,7 +412,7 @@ class ReleaseSearchService $bookIds = Search::searchSecondary(SecondarySearchIndex::Books, $q, 2000)['id']; - return $this->apiSearchByMetadataForeignKey($bookIds, 'bookinfo_id', $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize); + return $this->apiSearchByMetadataForeignKey($bookIds, 'bookinfo_id', $groupName, $offset, $limit, $maxAge, $excludedCats, $cat, $minSize, $orderBy); } /** @@ -424,8 +429,10 @@ class ReleaseSearchService int $maxAge, array $excludedCats, array $cat, - int $minSize + int $minSize, + string $orderBy = 'posted_desc' ): mixed { + [$orderField, $orderDir] = $this->getBrowseOrder($orderBy); if ($metadataIds === []) { return collect(); } @@ -482,9 +489,11 @@ class ReleaseSearchService 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 + ORDER BY r.%s %s LIMIT %d OFFSET %d", $whereSql, + $orderField, + $orderDir, $limit, $offset ); @@ -517,8 +526,9 @@ class ReleaseSearchService * @param array $siteIdArr * @return array|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 + 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 = [], string $orderBy = 'posted_desc'): mixed { + [$orderField, $orderDir] = $this->getBrowseOrder($orderBy); $shouldCache = ! (isset($siteIdArr['id']) && (int) $siteIdArr['id'] > 0); $rawCacheKey = md5(serialize(func_get_args()).'tvSearch'); $cacheKey = null; @@ -793,7 +803,7 @@ class ReleaseSearchService $limitClause = sprintf(' LIMIT %d OFFSET %d', $limit, $offset); } - $sql = sprintf('%s ORDER BY r.postdate DESC%s', $baseSql, $limitClause); + $sql = sprintf('%s ORDER BY r.%s %s%s', $baseSql, $orderField, $orderDir, $limitClause); $releases = Release::fromQuery($sql); if ($releases->isNotEmpty()) { @@ -823,8 +833,9 @@ class ReleaseSearchService * @param array $siteIdArr * @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 + 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 = [], string $orderBy = 'posted_desc'): mixed { + [$orderField, $orderDir] = $this->getBrowseOrder($orderBy); $searchLimit = $this->determineSearchCandidateLimit($offset, $limit); // OPTIMIZATION: Try to find releases using search index external IDs first @@ -968,7 +979,7 @@ class ReleaseSearchService %s", $whereSql ); - $sql = sprintf('%s ORDER BY postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset); + $sql = sprintf('%s ORDER BY r.%s %s LIMIT %d OFFSET %d', $baseSql, $orderField, $orderDir, $limit, $offset); $cacheKey = md5($this->getCacheVersion().$sql); $releases = Cache::get($cacheKey); if ($releases !== null) { @@ -993,8 +1004,9 @@ class ReleaseSearchService * @param array $excludedCategories * @return Collection|mixed */ - public function animeSearch(mixed $aniDbID, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, array $excludedCategories = [], int $anilistId = -1): mixed + public function animeSearch(mixed $aniDbID, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, array $excludedCategories = [], int $anilistId = -1, string $orderBy = 'posted_desc'): mixed { + [$orderField, $orderDir] = $this->getBrowseOrder($orderBy); if ($anilistId > 0) { $resolved = AnidbInfo::query()->where('anilist_id', $anilistId)->value('anidbid'); if ($resolved !== null) { @@ -1055,9 +1067,11 @@ class ReleaseSearchService ); $sql = sprintf( '%s - ORDER BY postdate DESC + ORDER BY %s %s LIMIT %d OFFSET %d', $baseSql, + $orderField, + $orderDir, $limit, $offset ); @@ -1083,8 +1097,9 @@ class ReleaseSearchService * @param array $excludedCategories * @return Collection|mixed */ - public function moviesSearch(string $imDbId = '', 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 + public function moviesSearch(string $imDbId = '', 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 = [], string $orderBy = 'posted_desc'): mixed { + [$orderField, $orderDir] = $this->getBrowseOrder($orderBy); $searchLimit = $this->determineSearchCandidateLimit($offset, $limit); $searchResult = []; @@ -1217,7 +1232,7 @@ class ReleaseSearchService $whereSql ); - $sql = sprintf('%s ORDER BY r.postdate DESC LIMIT %d OFFSET %d', $baseSql, $limit, $offset); + $sql = sprintf('%s ORDER BY r.%s %s LIMIT %d OFFSET %d', $baseSql, $orderField, $orderDir, $limit, $offset); $cacheKey = md5($sql.serialize(func_get_args())); if (($releases = Cache::get($cacheKey)) !== null) { return $releases; @@ -1667,6 +1682,20 @@ class ReleaseSearchService return [$orderField, isset($orderArr[1]) && preg_match('/^(asc|desc)$/i', $orderArr[1]) ? $orderArr[1] : 'desc']; // @phpstan-ignore return.type } + private function browseOrderToIndexSortField(string $orderField): string + { + return match ($orderField) { + 'postdate' => 'postdate_ts', + 'adddate' => 'adddate_ts', + 'categories_id' => 'categories_id', + 'searchname' => 'searchname', + 'size' => 'size', + 'totalpart' => 'totalpart', + 'grabs' => 'grabs', + default => 'postdate_ts', + }; + } + private function getCacheVersion(): int { return (int) Cache::get(self::CACHE_VERSION_KEY, 1); diff --git a/docs/newznab_api_specification.txt b/docs/newznab_api_specification.txt index 86e26863a..20e96aa91 100755 --- a/docs/newznab_api_specification.txt +++ b/docs/newznab_api_specification.txt @@ -1,7 +1,7 @@ newznab Usenet Searching Web API - v1.0-NNTmux - 2018-10-31 + v2.0-NNTmux + 2026-04-14 Authors: ensi ensisoft@gmail.com @@ -12,771 +12,339 @@ newznab-tmux https://github.com/NNTmux/newznab-tmux 1. Introduction 2. Functions 2.1 CAPS -2.2 REGISTER -2.3 SEARCH -2.4 TV-SEARCH -2.5 MOVIE-SEARCH -2.6 DETAILS -3. Predefined Categories -4. Predefined Attributes -4.1 List of Attributes -4.2 Attribute example -5. nZEDb Error Codes -5.1 Error code example -6 Changelog +2.2 SEARCH +2.3 TV-SEARCH +2.4 MOVIE-SEARCH +2.5 MUSIC-SEARCH +2.6 BOOK-SEARCH +2.7 ANIME-SEARCH (NNTmux extension) +2.8 DETAILS +2.9 GETNFO +2.10 GET +2.11 NZB-ADD +3. Unsupported upstream functions +4. Predefined Categories +5. Predefined Attributes +6. Error Codes +7. Changelog 1. Introduction - This document describes the newznab/nZEDb Usenet Searching Web API. The API is designed to be implemented - by Usenet indexing sites, i.e. sites that index Usenet newsgroups through some means, typically - by downloading and inspecting the NNTP headers. The API is aimed for NZB aware client applications - to allow them to perform Usenet searches against nZEDb servers and receive NZB information in order - to facilitate direct downloading from Usenet without having to download any NNTP headers. + This document describes the newznab-compatible Usenet Searching Web API as + implemented by NNTmux. It merges upstream newznab additions with + NNTmux-specific compatibility extensions. - This document does not describe the actual implementation of either the client or the server but just - describes the HTTP(S) interface and request/response sequences. - - Intended readers are server and client implementers. + All API endpoints return HTTP 200 on protocol success and report semantic + errors in the body (``), unless otherwise noted. 1.1 Notation - This document uses the following notations: - - Parameters: "t=c" denotes a required HTTP query parameter. [o=json | o=xml] denotes optional - parameters with possible values. - + Parameters: "t=c" denotes required HTTP query parameter. + [o=json|o=xml] denotes optional parameters. 2. Functions - All functions are executed as HTTP(S) requests over TCP. All parameters are to be passed - as query parameters unless otherwise indicated. All returned XML/JSON data is UTF-8 encoded - unless otherwise specified. All query parameters should be UTF-8 and URL encoded, i.e. - query-param = URL-ENCODE(UTF8-ENCODE(param=value)). + Endpoint: + GET /api/v1/api - The functions are divided into two categories. Functions specific to searching and retrieving of items - and the their information such as SEARCH and TV-SEARCH and functions that are for site/user account - management such as CAPS and REGISTER. + Optional aliases are supported for several t-values: + c/caps, s/search, tv/tvsearch, m/movie, b/book, d/details, g/get, + gn|n|nfo|info/getnfo. - Any conforming implementation should support the CAPS and SEARCH functions. Other functions are optional - and if not supported will return the "203 Function Not Available" when invoked. - - - 2.1 CAPS - - Description: - CAPS function is used to query the server for supported features and the protocol version and other - meta data relevant to the implementation. This function does not require the client to provide any - login information but can be executed out of "login session". - - Important fields of the returned data: - server/version The version of the protocol implemented by the server. All implementations should be backwards compatible. - limits The limit and defaults to the number of search results returned. - retention Server retention (how many days NZB information is stored before being purged). - category Defines a searchable category which might have any number of subcategories. - category/id Unique category ID, can be either one of the standard category IDs or a site specific ID. - category/name Any descriptive name for the category. Can be site/language specific. - category/description A description of the contents of the category. - category/subcat A subcategory. - category/subcat/id/ Unique category ID, can be either one of the standard category IDs or a site specific ID. - category/subcat/name Any descriptive name for the category. Can be site/language specific. - category/subcat/description A description of the contents of the category. - - HTTP Method: - GET - - HTTP Response: - 200 OK +2.1 CAPS Parameters: - t=caps Caps function, must always be "caps". + t=caps - Optional parameters: - o=xxx Output format, either "JSON" or "XML. Default is "XML". + Optional: + o=xml|json - Examples: - --> GET http://servername.com/api?t=caps - <-- 200 OK - - - - + Returns: + - server + - limits + - registration + - searching + - categories + - groups + - genres - - + searching.supportedParams (NNTmux): + - search: q,group,minsize,maxsize,maxage,cat,limit,offset,attrs,extended,del,sort + - tv-search: q,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep,cat,minsize,maxsize,maxage,limit,offset,attrs,extended,del,sort + - movie-search: q,imdbid,tmdbid,traktid,genre,cat,minsize,maxsize,maxage,limit,offset,attrs,extended,del,sort + - audio-search: q,cat,minsize,maxsize,maxage,group,limit,offset,attrs,extended,del,sort + - book-search: q,title,author,cat,minsize,maxsize,maxage,group,limit,offset,attrs,extended,del,sort + - anime-search: q,anidbid,anilistid,cat,minsize,maxsize,maxage,limit,offset,attrs,extended,del,sort - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 2.2 REGISTER - - Description: - - REGISTER function is used for automatically creating and registering user account. - This is an optional function and may or may not be available at a site. It is also possible - that function is available but currently registrations at the site are closed. - - The only prerequisite for registering an account is a valid email address and any server policies. - It is at the server administration discretion to allow or deny registrations based on - for example the validity of the email address or the the current client host address. - - On successful registration a valid username, password and api key are returned to the caller - On error an appropriate error code is returned. - - HTTP Method: - GET - - HTTP Response: - 200 OK +2.2 SEARCH Parameters: - t=register Register function, must always be "register" - email=xxx A valid email address to be used for registration. (URL/UTF-8 encoded). + t=search + apikey=xxxx - Examples: - --> GET HTTP://servername.com/api?t=register&email=john.joe%40acme.com - <-- 200 OK - - - + Optional: + q=xxxx + group=xxxx + limit=123 + cat=xxx + o=xml|json + attrs=xxx + extended=1 + del=1 + maxage=123 + minsize=0 + maxsize=1 (accepted for compatibility, currently not enforced) + offset=50 + sort=val_asc|val_desc - --> GET HTTP://servername.com/api?t=register&email=john.joe%40acme.com - <-- 200 OK - - + sort values: + cat, name, size, files, stats, posted + Example: sort=size_desc - --> GET HTTP://servername.com/api?t=register&email=john.joe%40acme.com - <-- 200 OK - - + Sorting request examples: + - /api?v=1&t=search&apikey=xxxx&q=ubuntu&sort=posted_desc + - /api?v=1&t=search&apikey=xxxx&q=ubuntu&sort=name_asc + - /api?v=1&t=search&apikey=xxxx&q=ubuntu&sort=size_desc&limit=50&offset=0 - --> GET HTTP://servername.com/api?t=register&email=john.joe%40acme.com - <-- 200 OK - - + XML sorting response snippet (size_desc): + + Ubuntu ISO x64 + + + + Ubuntu ISO x86 + + + (larger size appears first because sort=size_desc) + Response additions: + - newznab:response offset/total + - newznab:apilimits apicurrent/apimax/grabcurrent/grabmax/apioldesttime/graboldesttime - 2.3 SEARCH - - Description: - SEARCH function searches the index for items matching the search criteria. On successful - search the response contains a list of found items. Even if search matched nothing an empty - response set is created and returned. This function requires passing the user credentials. - - Searches that include categories that are not supported by the server are still executed - but the non-supported categories are simply skipped. This basically treats such a search - simply as a "no match" but allows the same query to be ran simultaneously against several - servers. - - The list of search categories specifies a logical OR condition. I.e. an item matching the - search input in any of the specified categories is considered a match and is returned. E.g. - a search searching for "linux" in "computer" and "ebook" categories searches for matching - items in "computer" and "ebook" but does not search for example the "movies" category. - Items found in either group are then combined into a single result set. If the input string - for search is empty all items (within the server/query limits) are returned for the - matching categories. - - When performing the query the categories to be searched are concatenated into a single - query parameter by , (comma). For example "cat=200,300,400", which is then URL encoded. - - The returned XML data stream is RSS 2.0 compliant and also contains additional information - in the extra namespace. - - Response-offset field identifies the current subset of all the matches that are being - transmitted in the response. In other words, if a search for "disco" finds more matches - than the server is capable of transmitting in a single response, the response needs to be - split into several responses. Then it is the clients responsibility to repeat the same - query with same parameters but specify an increased offset in order to return the next - set of results. - - If offset query parameter is not used response data contains items between 0 offset - limit. - If offset query parameter is out of bounds an empty result set is returned. - - Important fields of the returned data (RSS): - title Title of the found item. - guid A globally unique (GUID) item identifier. - pubdate The publishing date in RSS date object as specified by RFC822/2822. (not the Usenet date) - category The category the NZB belongs to. (This is human readable for RSS. More precise category is found in additional data) - enclosure The NZB url - - HTTP Method: - GET - - HTTP Response: - 200 OK +2.3 TV-SEARCH Parameters: - t=search Search function, must always be "search" - apikey=xxxx User's key as provided by the service provider. + t=tvsearch + apikey=xxxx - Optional parameters: - q=xxxx Search input (URL/UTF-8 encoded). Case insensitive. - limit=123 Upper limit for the number of items to be returned. - cat=xxx List of categories to search delimited by "," - o=xxx Output format, either "JSON" or "XML". Default is "XML". - extended=1 Return extended information in the search results. (See DETAILS). - del=1 Delete the item from a users cart on download. - maxage=123 Only return results which were posted to usenet in the last x days. - offset=50 The 0 based query offset defining which part of the response we want. + Optional: + q, vid, tvdbid, traktid, rid, tvmazeid, imdbid, tmdbid + season, ep + cat, limit, offset, maxage, minsize, maxsize, attrs, extended, del, sort - Examples: - --> GET http://servername.com/api?t=search&apikey=xxxxx&q=a%20tv%20show - <-- 200 OK - - + Notes: + - season YYYY + ep MM/DD is treated as daily date-style query. + - Example: + /api?v=1&t=tvsearch&apikey=xxxx&q=last+week+tonight&season=2025&ep=11/10&sort=posted_desc - - example.com</tile> - <description>example.com API results</description> - <!-- - More RSS content - --> - - <!-- offset is the current offset of the response total is the total number of items found by the query --> - <newznab:response offset="0" total="2344"/> - - <item> - <!-- Standard RSS 2.0 Data --> - <title>A.Tv.Show.S06E05.DVDRIP.XviD - http://servername.com/rss/viewnzb/e9c515e02346086e3a477a5436d7bc8c - http://servername.com/rss/nzb/e9c515e02346086e3a477a5436d7bc8c&i=1&r=18cf9f0a736041465e3bd521d00a90b9 - http://servername.com/rss/viewnzb/e9c515e02346086e3a477a5436d7bc8c#comments - Sun, 06 Jun 2010 17:29:23 +0100 - TV > XviD - Some TV show - - - - - - - - - - - - // no items matched the search criteria - --> GET http://servername.com/api?t=search&apikey=xxxxx&q=linux%20image - <-- 200 OK - - - - - - - - // Query could not be completed because user credentials are broken - --> GET http://servername.com/api?t=search&apikey=xxxxx&q=linux%20image - <-- 200 OK - - - - - // Query could not be completed because it was malformed - --> GET http://servername.com/api?t=search&apikey=xxxxx&q=linux%20image - <-- 200 OK - - - - 2.4 TV-SEARCH - - Description: - TV-SEARCH function searches the index in the TV category for items matching the search criteria. - The criteria includes query string and in addition information about season and episode. - On successful search the response contains a list of items that matched the query. Even if the - search matched nothing an empty but valid response is created and returned. This function - requires passing the user credentials. - - It is important to note, that all parameters are treated as AND operations execept for rid, tvdbid, - traktid, tvmazeid, imdbid, and tmdbid. Supplying any combination (some, none, all) of these values will - return results applicable for each value respective to the other AND parameters. - - The returned XML data stream is RSS 2.0 compliant and also contains additional information - in the extra namespace and optionally TV specific information - - HTTP Method: - GET - - HTTP Response: - 200 OK +2.4 MOVIE-SEARCH Parameters: - t=tvsearch TV-Search function, must always be "tvsearch". - apikey=xxx User's key as provided by the service provider. + t=movie + apikey=xxxx - Optional parameters: - limit=123 Upper limit for the number of items to be returned, e.g. 123. - rid=xxxx TVRage id of the item being queried. - tvdbid=xxxx TVDB id of the item being queried. - traktid=xxxx TraktTV id of the item being queried. - tvmazeid=xxxx TVMaze id of the item being queried. - imdbid=xxxx IMDB id of the item being queried. - tmdbid=xxxx TMDB id of the item being queried. - cat=xxx List of categories to search delimited by "," - season=xxxx Season string, e.g S13 or 13 for the item being queried. Can also be YYYY for Daily Show. - q=xxxx Search input (URL/UTF-8 encoded). Case insensitive. - ep=xxx Episode string, e.g E13 or 13 for the item being queried. Can also be MM/DD for Daily Show. - o=xml Output format, either "JSON" or "XML". Default is "XML". - extended=1 Return extended information in the search results - del=1 Delete the item from a users cart on download. - maxage=123 Only return results which were posted to usenet in the last x days. - offset=50 The 0 based query offset defining which part of the response we want. - - Examples: - --> GET http://servername.com/api?t=tvsearch&apikey=xxxq=lost&season=S03 - <-- 200 OK - - - - example.com - example.com API results - - - - - - - - Land.of.the.Lost.S03E02.Survival.Kit.iNTERNAL.DVDRip.XViD-SPRiNTER - http://servername.com/rss/viewnzb/e9c515e02346086e3a477a5436d7bc8c - http://servername.com/rss/nzb/e9c515e02346086e3a477a5436d7bc8c&i=1&r=18cf9f0a736041465e3bd521d00a90b9 - http://servername.com/rss/viewnzb/e9c515e02346086e3a477a5436d7bc8c#comments - Sun, 06 Jun 2010 17:29:23 +0100 - TV > XviD - Some TV show - - - - - - - - - - - - Lost.S03E01.720p.BluRay.DTS.x264.INTERNAL-hV - http://servername.com/rss/viewnzb/e9c515e02346086e3a477a5436d7bc8c - http://servername.com/rss/nzb/e9c515e02346086e3a477a5436d7bc8c&i=1&r=18cf9f0a736041465e3bd521d00a90b9 - http://servername.com/rss/viewnzb/e9c515e02346086e3a477a5436d7bc8c#comments - Sun, 06 Jun 2010 17:29:23 +0100 - TV > XviD - Some TV show - - - - - - - - - - - - - - - - 2.5 MOVIE-SEARCH - - Description: - MOVIE-SEARCH function searches the index for items matching an IMDB id or search query. - On successful search the response contains a list of items that matched the query. Even if the - search matched nothing an empty but valid response is created and returned. This function - requires passing the user credentials. - - The returned XML data stream is RSS 2.0 compliant and also contains additional information - in the extra namespace and optionally movie specific information. - - HTTP Method: - GET - - HTTP Response: - 200 OK - - Parameters: - t=movie Movie-Search function, must always be "movie". - apikey=xxx User's key as provided by the service provider. - - Optional parameters: - limit=123 Upper limit for the number of items to be returned, e.g. 123. - imdbid=xxxx IMDB id of the item being queried e.g. 0058935. - cat=xxx List of categories to search delimited by "," - q=xxxx Search input (URL/UTF-8 encoded). Case insensitive. - o=xml Output format, either "JSON" or "XML". Default is "XML". - extended=1 Return extended information in the search results - del=1 Delete the item from a users cart on download. - maxage=123 Only return results which were posted to usenet in the last x days. - offset=50 The 0 based query offset defining which part of the response we want. - - Examples: - --> GET http://servername.com/api?t=movie&apikey=xxx&imdbid=0058935 - <-- 200 OK - - - - example.com - example.com API results - - - - - - - - Movie.Name.720p.BluRay.DTS.x264 - http://servername.com/rss/viewnzb/e9c515e02346086e3a477a5436d7bc8c - http://servername.com/rss/nzb/e9c515e02346086e3a477a5436d7bc8c&i=1&r=18cf9f0a736041465e3bd521d00a90b9 - http://servername.com/rss/viewnzb/e9c515e02346086e3a477a5436d7bc8c#comments - Sun, 06 Jun 2010 17:29:23 +0100 - Movie > XviD - Some movie - - - - - - - - - - - - 2.6 DETAILS - - Description: - DETAILS function returns all information for a particular Usenet (NZB) item. The response - contains the generic RSS part + full extra information + full type/category specific information. - - HTTP Method: - GET - - HTTP Response: - 200 OK - - Parameters: - t=details Details function, must always be "details". - guid=xxxx The GUID of the item being queried. - apikey=xxxx User's key as provided by the service provider. - - Optional parameters: - o=xxx Output format, either "JSON" or "XML". Default is "XML". - del=1 Delete the item from a users cart on download. + Optional: + q, imdbid, tmdbid, traktid, genre + cat, limit, offset, maxage, minsize, maxsize, attrs, extended, del, sort Example: - --> GET http://servername.com/api?t=details&apikey=xxxxx&guid=xxxxxxxxx - <-- 200 OK - - - - - - A.Tv.Show.S06E05.DVDRIP.XviD - http://servername.com/rss/viewnzb/e9c515e02346086e3a477a5436d7bc8c - http://servername.com/rss/nzb/e9c515e02346086e3a477a5436d7bc8c&i=1&r=18cf9f0a736041465e3bd521d00a90b9 - http://servername.com/rss/viewnzb/e9c515e02346086e3a477a5436d7bc8c#comments - Sun, 06 Jun 2010 17:29:23 +0100 - TV > XviD - Some TV show - + /api?v=1&t=movie&apikey=xxxx&imdbid=tt0816692&sort=size_desc - - - - - - - - - - +2.5 MUSIC-SEARCH - - - + Parameters: + t=music (alias t=audio) + apikey=xxxx - // Query could not be completed because it was malformed - --> GET http://servername.com/api?t=details&apikey=xxxxx&guid=xxxxxxxxx - <-- 200 OK - - + Optional: + q + album, artist, label, track, year, genre + group, cat, limit, offset, maxage, minsize, maxsize, attrs, extended, del, sort - // Query could not be completed because no such item was available - --> GET http://servername.com/api?t=details&apikey=xxxxx&guid=xxxxxxxxx - <-- 200 OK - - +2.6 BOOK-SEARCH - // Query could not be completed because user credentials are broken - --> GET http://servername.com/api?t=details&apikey=xxxxx&guid=xxxxxxxxx - <-- 200 OK - - + Parameters: + t=book + apikey=xxxx + Optional: + q + title, author + group, cat, limit, offset, maxage, minsize, maxsize, attrs, extended, del, sort +2.7 ANIME-SEARCH (NNTmux extension) -3. Predefined Categories + Parameters: + t=anime + apikey=xxxx - In order to facilitate operation that does not rely on a particular natural language, e.g. english - a set of predefined category IDs have been defined. It is possible to define custom categories - in the custom category range. Each category is given a range for a set of subcategories. It is possible - for an item to belong to several categories at the same time. + Optional: + q, anidbid, anilistid + cat, limit, offset, maxage, minsize, maxsize, attrs, extended, del, sort - Category Range Category Name Comments - 0000-0999 Other - 1000-1999 Console - 2000-2999 Movies - 3000-3999 Audio - 4000-4999 PC - 5000-5999 TV - 6000-6999 XXX - 7000-7999 Books - 8000-99999 Reserved Reserved for future expansion - 100000- Custom Site specific category range. Defined in CAPS +2.8 DETAILS - Categories Category Name + Parameters: + t=details + id=guid + apikey=xxxx - 1 Other All of Other - 10 Other/Misc Anything that could not get categorized - 20 Other/Hashed Anything with a hashed name - 1000 Console All of console - 1010 Console/NDS Nintendo DS - 1020 Console/PSP Sony Playstation Portable - 1030 Console/Wii Nintendo Wii - 1040 Console/Xbox Microsoft XBox - 1050 Console/Xbox 360 Microsoft XBox 360 - 1060 Console/Wiiware VC Wii homebrew - 1070 Console/Xbox 360 DLC Microsoft XBox 360 Downloadable Content - 1080 Console/PS3 Playstation 3 - 1999 Console/Other Misc Console - 1110 Console/3DS Nintendo 3DS - 1120 Console/PS Vita Playstation Vita - 1130 Console/WiiU Nintento Wii U - 1140 Console/Xbox One Xbox One - 1180 Console/PS4 Playstation 4 - 2000 Movies All of movies - 2010 Movies/Foreign Non english movies - 2999 Movies/Other Misc movies - 2030 Movies/SD Standard definition movies - 2040 Movies/HD High definition movies (720p+) - 2050 Movies/3D 3D movies - 2060 Movies/BluRay Full BR movies - 2070 Movies/DVD Full DVD movies - 2080 Movies/WEBDL WEB-DL movies - 2090 Movies/X265 x265 encoded movies - 3000 Audio All of audio - 3010 Audio/MP3 Mp3 music - 3020 Audio/Video Music videos - 3030 Audio/Audiobook Books in audio format - 3040 Audio/Lossless Lossless music - 3999 Audio/Other Misc music - 3060 Audio/Foreign Non english music. - 4000 PC All of PC - 4010 PC/0day Apps and games not released in ISO. - 4020 PC/ISO CD-ROM images/DVD Images - 4030 PC/Mac OS X apps and games - 4040 PC/Phone-Other Misc mobile phone software - 4050 PC/Games PC Games - 4060 PC/Phone-IOS IOS apps - 4070 PC/Phone-Android Android apps - 5000 TV All of TV - 5010 TV/WEB-DL WEB-DL TV - 5020 TV/FOREIGN FOREIGN TV - 5030 TV/SD SD TV - 5040 TV/HD HD TV - 5999 TV/OTHER Other TV Content - 5060 TV/Sport Sports - 5070 TV/Anime Anime - 5080 TV/Documentary Documentaries - 5090 TV/X265 x265 encoded TV - 6000 XXX All of XXX - 6010 XXX/DVD Full DVD's - 6020 XXX/WMV WMV rips - 6030 XXX/XviD dvdrips - 6040 XXX/x264 HD Porn - 6041 XXX/HD Clips HD Clips Porn - 6042 XXX/SD Clips SD Clips Porn - 6050 XXX/Other Misc Porn - 6060 XXX/Imageset Sets of porn images - 6999 XXX/Packs Packs of multiple porn videos - 7000 Books All of Books - 7010 Books/Ebook Ebooks - 7020 Books/Comics Comics Ebooks - 7030 Books/Magazines Magazines - 7040 Books/Technical Technical books - 7060 Books/Foreign Non english books - 7999 Books/Other Misc books - 100000- Custom Specific to a site + Optional: + o=xml|json + del=1 +2.9 GETNFO -4. PREDEFINED ATTRIBUTES + Parameters: + t=getnfo (aliases: nfo, info, n, gn) + id=guid + apikey=xxxx - A set of known attributes for items in different categories has been defined. - Its possible that not all attributes are available at all times. Therefore a - client application should not rely on any particular attributes being in the - returned data but should take this list as an optional extra information. - However attributes marked with * are always available. + Optional: + raw=1 or o=file - Additionally, not all attributes are applicable to all items. The category - information can be used to check which attributes area available for which - category items. +2.10 GET - All attributes are defined using XML namespace syntax. - e.g. xmlns:newznab="http://www.newznab.com/DTD/2010/feeds/attributes/" + Parameters: + t=get + id=guid + apikey=xxxx - 4.1 List of Attributes + Optional: + del=1 - Attribute Category Description Example value + Behavior: + Redirects to /getnzb?r=&id=[&del=1] - size * ALL Size in bytes "252322" - category * ALL Item's category "5004" - files ALL Number of files "4" - poster ALL NNTP Poster "yenc@power-post" - group ALL NNTP Group(s) "a.b.warez, a.b.teevee" - team ALL Team doing the release "DiAMOND" - grabs ALL Number of times item downloaded "1" - password ALL Whether the archive is passworded "0" no, "1" rar pass, "2" contains inner archive - comments ALL Number of comments "2" - usenetdate ALL Date posted to usenet "Tue, 22 Jun 2010 06:54:22 +0100" - info ALL Info (.nfo) file URL "http://somesite/stuff/info.php?id=1234" - year ALL Release year "2009" - prematch ALL Has valid PreDB match "0" no "1" yes - season TV Numeric season "1" - episode TV Numeric episode within the season "1" - videos_id TV, Movies, Anime Local Video ID "1" - tv_episodes_id TV Local TV Episode ID "1" - tvdbid TV TVDB ID. (www.thetvdb.com) "153021" - traktid TV TraktTV ID. (www.trakt.tv) "1393" - rageid TV TVRage ID. (www.tvrage.com) "25056" - tvrageid TV TVRage ID. (www.tvrage.com) "25056" - tvmazeid TV TVMaze ID. (www.tvmaze.com) "73" - imdbid TV IMDB ID for show. (www.imdb.com) "tt1520211" - tmdbid TV TMDB ID for show. (www.themoviedb.com) "1402" - title TV TV Show Title. (www.tvrage.com) "Duck and Cover" - firstaired TV TV Show Air date. (www.tvrage.com) "Tue, 22 Jun 2010 06:54:22 +0100" - anidbid Anime AniDB.net ID. (www.anidb.net) "10445" - video TV, Movies Video codec "x264" - audio TV, Movies, Audio Audio codec "AC3 2.0 @ 384 kbs" - resolution TV, Movies Video resolution "1280x716 1.78:1" - framerate TV, Movies Video fps "23.976 fps" - language TV, Movies, Audio Natural languages "English" - subs TV, Movies Subtitles "English, Spanish" - imdb TV, Movies IMDb ID (www.imdb.com) "0104409" - genre TV, Movies Genre "Horror" +2.11 NZB-ADD + Parameters: + t=nzbadd + apikey=xxxx + file= + Optional: + cat, includemeta, dupecheck, nfo, medianfo - 4.2 Attribute Example + Error handling: + Uses XML error codes from section 6. - Example attribute declarations within element. +3. Unsupported upstream functions - - - + The following upstream newznab functions are intentionally not implemented + in NNTmux v1 API: + - REGISTER (t=register) + - USER (t=user) + - COMMENTS (t=comments) + - COMMENTS-ADD (t=commentadd) + - CART-ADD (t=cartadd) + - CART-DEL (t=cartdel) -5. nZEDb Error Codes + Requests to these endpoints return: + - Under normal circumstances i.e. when the HTTP request/response sequence is successfully completed - nZEDb implementations always respond with HTTP 200 OK. However this does not mean that the - query was semantically correct. It simply means that the HTTP part of the sequence was successful. - One then must check the actual response body/data to see if the request was completed - without errors. +4. Predefined Categories - In case of a nZEDb error the response contains an error code and an a description of the error. + Category ranges: + 0000-0999 Reserved + 1000-1999 Console + 2000-2999 Movies + 3000-3999 Audio + 4000-4999 PC + 5000-5999 TV + 6000-6999 XXX + 7000-7999 Books + 8000-8999 Other + 9000-99999 Reserved + 100000- Custom - The error codes have been defined into different ranges. 100-199 Account/user credentials specific - error codes, 200-299 API call specific error codes, 300-399 content specific error codes and finally - 900-999 Other error codes. + Important category IDs (union of upstream + NNTmux): + 7900 Category Not Determined + 8000 Other, 8010 Other/Misc + 1035 Console/Switch + 1090 Console/XBox One + 1100 Console/PS4 + 1110 Console/3DS (NNTmux) + 1120 Console/PS Vita (NNTmux) + 1130 Console/WiiU (NNTmux) + 1999 Console/Other (NNTmux) + 2045 Movies/UHD + 2060 Movies/BluRay (NNTmux) + 2070 Movies/DVD (NNTmux) + 2080 Movies/WEBDL (NNTmux) + 2090 Movies/X265 (NNTmux) + 3050 Audio/Podcast + 3060 Audio/Foreign (NNTmux) + 3999 Audio/Other (NNTmux) + 5010 TV/WEB-DL (NNTmux) + 5045 TV/UHD + 5090 TV/X265 (NNTmux) + 5999 TV/OTHER (NNTmux) + 6041 XXX/HD Clips (NNTmux) + 6042 XXX/SD Clips (NNTmux) + 6999 XXX/Packs (NNTmux) + 7040 Books/Technical (NNTmux) + 7060 Books/Foreign (NNTmux) + 7999 Books/Other (NNTmux) - Error code Description +5. Predefined Attributes - 100 Incorrect user credentials - 101 Account suspended - 102 Insufficient privileges/not authorised - 103 Registration denied - 104 Registrations are closed - 105 Invalid registration (Email Address Taken) - 106 Invalid registration (Email Address Bad Format) - 107 Registration Failed (Data error) + Standard: + size, category, guid, files, poster, group, grabs, comments, password, + usenetdate, info, year, genre, imdb, coverurl, review, etc. - 200 Missing parameter - 201 Incorrect parameter - 202 No such function. (Function not defined in this specification). - 203 Function not available. (Optional function is not implemented). + NNTmux extensions: + prematch, videos_id, tv_episodes_id, tvdbid, traktid, tvmazeid, imdbid, + tmdbid, title, firstaired, anidbid. - 300 No such item. +6. Error Codes - 500 Request limit reached - 501 Download limit reached + 100 Incorrect user credentials + 101 Account suspended + 102 Insufficient privileges/not authorized + 103 Registration denied + 104 Registrations are closed + 105 Invalid registration (Email Address Taken) + 106 Invalid registration (Email Address Bad Format) + 107 Registration Failed (Data error) - 900 Unknown error + 200 Missing parameter + 201 Incorrect parameter + 202 No such function + 203 Function not available - 5.1 Error code example + 300 No such item + 310 Item already exists - // Query could not be completed because user credentials are broken - --> GET http://servername.com/api?t=details&apikey=xxxxx&guid=xxxxxxxxx - <-- 200 OK - - + 500 Request limit reached (NNTmux) + 501 Download limit reached (NNTmux) -6 Changelog + 600 Failed to load NZB + 601 NZB is duplicate + 602 NZB is for a non-existent group + 603 NZB failed to write to disk + + 900 Unknown error + 910 API disabled + +7. Changelog + + 2026-04-14 + - Merged upstream newznab specification updates into NNTmux spec. + - Added MUSIC-SEARCH, BOOK-SEARCH, GETNFO, GET, NZB-ADD sections. + - Added sort, minsize/maxsize, attrs, and apilimits documentation. + - Added CAPS groups/genres documentation. + - Preserved NNTmux anime search, extended TV identifiers, categories, and attributes. + - Documented unsupported upstream functions (register/user/comments/cart). 2015-10-24 ruhllatio - Add new attribute returns. - Update supported tv-search methods. - Add audio-search capability and make it unavailable with no params. + - Add new attribute returns. + - Update supported tv-search methods. + - Add audio-search capability and make it unavailable with no params. + 2015-05-23 kevinlekiller - Fix spelling issues. - Fix indentation issues. - Add missing categories. - Add missing error codes. + - Fix spelling issues. + - Fix indentation issues. + - Add missing categories. + - Add missing error codes. diff --git a/docs/nntmux_api_v2.md b/docs/nntmux_api_v2.md index e0f91da39..e1036bfb1 100644 --- a/docs/nntmux_api_v2.md +++ b/docs/nntmux_api_v2.md @@ -1,6 +1,8 @@ # NNTmux API v2 Specification -This document is a code-first reference for the JSON API under `/api/v2`, based on: +Code-first reference for the JSON API under `/api/v2`. + +Primary sources: - `routes/api.php` - `app/Http/Controllers/Api/ApiV2Controller.php` @@ -17,9 +19,9 @@ https:///api/v2 ## Authentication and Rate Limits - `GET /capabilities` is public. -- All other v2 routes are behind `auth:api` and `throttle:rate_limit,1` middleware. -- Controller-level validation also requires `api_token` in request input/query. -- Invalid or missing token at controller level returns: +- All other v2 routes require `api_token`. +- Route-level middleware uses token-aware throttling (`apiRateLimit`). +- Controller-level auth errors return: ```json { @@ -29,163 +31,143 @@ https:///api/v2 with HTTP `403`. -> Note: If middleware rejects first, response shape may differ from controller responses depending on your auth guard configuration. - ## Common Query Parameters | Parameter | Type | Default | Notes | |---|---|---:|---| -| `api_token` | string | - | Required for all endpoints except `capabilities`. | -| `limit` | int | `100` | Read from request as numeric value; not hard-clamped in `ApiV2Controller`. | +| `api_token` | string | - | Required except `capabilities`. | +| `id` | string | `""` | Search text/fallback identifier on search endpoints. | +| `limit` | int | `100` | Max rows in page. | | `offset` | int | `0` | Zero-based pagination offset. | -| `cat` | csv string | `-1` | Comma-separated category IDs. If `TV_HD` is present and `catwebdl=0`, `TV_WEBDL` is auto-added. | -| `maxage` | int | `-1` | Max age in days (`-1` disables age filtering). | -| `minsize` | int | `0` | Minimum release size in bytes. | +| `cat` | csv string | `-1` | Category filter; `TV_WEBDL` auto-add can apply when `TV_HD` is requested. | +| `group` | string | `-1` | Usenet group filter (where supported). | +| `maxage` | int | `-1` | Max post age in days. Invalid values return JSON `400`. | +| `minsize` | int | `0` | Min release size in bytes. | +| `maxsize` | int | - | Accepted for compatibility; currently not enforced in query layer. | +| `sort` | string | `posted_desc` | `cat|name|size|files|stats|posted` + `_asc|_desc`. | -## Endpoints +Sorting examples: -### 1) Capabilities +- `/api/v2/search?api_token=&id=ubuntu&sort=posted_desc` +- `/api/v2/search?api_token=&id=ubuntu&sort=name_asc` +- `/api/v2/tv?api_token=&id=last+week+tonight&season=2025&ep=11/10&sort=posted_desc` +- `/api/v2/movies?api_token=&imdbid=tt0816692&sort=size_desc` -- `GET /capabilities` -- Auth: none -- Returns server metadata, declared limits, searching capabilities, registration flags, and category tree. - -Example response (abbreviated): +JSON sorting response snippet (`sort=size_desc`): ```json { - "server": { - "title": "NNTmux", - "strapline": "", - "email": "admin@example.com", - "url": "https://example.com" - }, - "limits": { - "max": 100, - "default": 100 - }, - "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": "" - } - }, - "registration": { - "available": "no", - "open": "yes" - }, - "categories": [ - { - "id": 2000, - "name": "Movies", - "subcategories": { - "2030": "SD", - "2040": "HD" - } - } + "Total": 2, + "Results": [ + { "title": "Ubuntu ISO x64", "size": 734003200 }, + { "title": "Ubuntu ISO x86", "size": 367001600 } ] } ``` -### 2) Search +`Results` are ordered largest-to-smallest because `sort=size_desc`. + +## Endpoints + +## 1) Capabilities + +- `GET /capabilities` +- Auth: none + +Returns: + +- `server` +- `limits` +- `searching` +- `registration` +- `categories` +- `groups` +- `genres` + +## 2) Search - `GET /search` - Auth: required -Parameters: +Behavior: -- `id` (optional): search term or GUID-like string -- `group` (optional): Usenet group name -- common parameters: `api_token`, `cat`, `offset`, `limit`, `maxage`, `minsize` +- If `id` is present: text search. +- If `id` is omitted: browse mode. +- Includes API usage counters in response (`apiCurrent`, `apiMax`, `grabCurrent`, `grabMax`, `apiOldestTime`, `grabOldestTime`). -If `id` is omitted, endpoint returns newest browse results scoped by filters. - -### 3) TV Search +## 3) TV Search - `GET /tv` - Auth: required -Parameters: +Identifiers: -- identifiers: `vid`, `tvdbid`, `traktid`, `rid`, `tvmazeid`, `imdbid`, `tmdbid` -- title fallback: `id` -- episode filters: `season`, `ep` -- common parameters +- `vid`, `tvdbid`, `traktid`, `rid`, `tvmazeid`, `imdbid`, `tmdbid` -Daily episode behavior: +Optional filters: -- if `season` is a 4-digit year and `ep` contains `/`, airdate is inferred as `YYYY-MM-DD`. +- `season`, `ep`, `cat`, `maxage`, `minsize`, `sort`, `offset`, `limit` -### 4) Movie Search +Daily parsing: + +- `season=YYYY` and `ep=MM/DD` infers an airdate query. + +## 4) Movie Search - `GET /movies` - Auth: required -Parameters: +Identifiers: -- identifiers: `imdbid`, `tmdbid`, `traktid` (default `-1` when not set) -- title fallback: `id` -- common parameters +- `imdbid`, `tmdbid`, `traktid` -Implementation note: +Optional filters: -- movie search result sets are cached for 10 minutes by filter signature. +- `id`, `cat`, `maxage`, `minsize`, `sort`, `offset`, `limit` -### 5) Get NZB +## 5) Audio Search + +- `GET /audio` +- Auth: required + +Required: + +- `id` (query string) + +## 6) Book Search + +- `GET /books` +- Auth: required + +Required: + +- `id` (query string) + +## 7) Anime Search + +- `GET /anime` +- Auth: required + +Selectors: + +- `id` and/or `anidbid` and/or `anilistid` + +## 8) Get NZB - `GET /getnzb` - Auth: required +- Valid GUID redirects to `/getnzb?r=&id=[&del=1]` +- Not found returns HTTP `404` JSON. -Parameters: - -- `id` (GUID, required for success) -- `del=1` (optional): forwards delete flag in downstream redirect - -Behavior: - -- valid GUID: HTTP `302` redirect to `/getnzb?r=&id=[&del=1]` -- missing/invalid GUID: HTTP `404` - -```json -{ - "data": "No such item (the guid you provided has no release in our database)" -} -``` - -### 6) Details +## 9) Details - `GET /details` - Auth: required - -Parameters: - -- `id` (GUID, required) - -Errors: - -- missing `id`: HTTP `400` - -```json -{ - "error": "Missing parameter (guid is required for single release details)" -} -``` +- Requires `id` (GUID) ## Response Models -### Search Envelope (`/search`, `/tv`, `/movies`) +### Search Envelope (`/search`, `/tv`, `/movies`, `/audio`, `/books`, `/anime`) ```json { @@ -196,93 +178,36 @@ Errors: "grabMax": 100, "apiOldestTime": "Wed, 20 Nov 2024 12:00:00 +0000", "grabOldestTime": "", - "Results": [ - { - "title": "Some.Release.2024.1080p", - "details": "https://example.com/details/", - "url": "https://example.com/getnzb?id=.nzb&r=", - "category": 2040, - "category_name": "Movies > HD", - "added": "Wed, 20 Nov 2024 12:00:00 +0000", - "size": 734003200, - "files": 55, - "grabs": null, - "comments": null, - "password": 0, - "usenetdate": "Wed, 20 Nov 2024 10:00:00 +0000" - } - ] + "Results": [] } ``` -### Additional Movie Fields in `Results` - -- `imdbid` -- `tmdbid` -- `traktid` - -All three are `null` when source value is zero. - -### Additional TV Fields in `Results` - -- `episode_title` -- `season` -- `episode` -- `tvairdate` -- `tvdbid` -- `traktid` -- `tvrageid` -- `tvmazeid` -- `imdbid` -- `tmdbid` - ### Details Object (`/details`) -Returns one release object (not envelope). Key difference from search results: download field is named `link` instead of `url`. +Returns a single release object (not envelope). Download field name is `link` (not `url`). -```json -{ - "title": "Some.Show.S01E01.720p", - "details": "https://example.com/details/", - "link": "https://example.com/getnzb?id=.nzb&r=", - "category": 5030, - "category_name": "TV > SD", - "added": "Wed, 20 Nov 2024 12:00:00 +0000", - "size": 450971565, - "files": 44, - "grabs": 3, - "comments": 0, - "password": 0, - "usenetdate": "Wed, 20 Nov 2024 10:00:00 +0000", - "tvairdate": "2024-11-19", - "tvdbid": 12345, - "traktid": 67890, - "tvrageid": null, - "tvmazeid": null, - "imdbid": 1234567, - "tmdbid": 98765 -} -``` +## Error Response Conventions -## Status Codes and Error Body Cheat Sheet +- Missing/invalid token: JSON `403` +- Invalid `maxage`: JSON `400` +- Invalid `sort`: JSON `400` +- Missing required endpoint parameter (`id`, etc.): JSON `400` +- Missing GUID in `/getnzb`: JSON `404` -| Endpoint(s) | HTTP | Body | -|---|---:|---| -| `/movies`, `/search`, `/tv`, `/getnzb`, `/details` (token failure in controller) | 403 | `{ "error": "Missing or invalid API key" }` | -| `/details` (missing `id`) | 400 | `{ "error": "Missing parameter (guid is required for single release details)" }` | -| `/getnzb` (GUID not found) | 404 | `{ "data": "No such item (the guid you provided has no release in our database)" }` | +## Unsupported in v2 -## Postman / Documenter Sync Source +The following are intentionally not part of v2 JSON API: -If you maintain the public Postman page, use this markdown as the source of truth and mirror: +- `register` +- `user` +- `comments` +- `commentadd` +- `cartadd` +- `cartdel` +- `nzbadd` -1. endpoint auth requirements -2. request parameter descriptions -3. response envelope vs details-object differences -4. exact error messages +NZB upload remains in v1 (`/api/v1/api?t=nzbadd`). -Starter collection (import into Postman, then publish with Documenter): +## Postman Collection - `docs/postman/nntmux_api_v2.postman_collection.json` - -This prevents drift between code and `https://documenter.getpostman.com/view/3059471/RW8FGS9E`. diff --git a/resources/views/api/apidesc.blade.php b/resources/views/api/apidesc.blade.php index 547ececa8..e5a3119f1 100644 --- a/resources/views/api/apidesc.blade.php +++ b/resources/views/api/apidesc.blade.php @@ -288,6 +288,52 @@ +

+ Sorting Results (v1) +

+

+ Search-style endpoints support sort=field_direction. + Allowed fields: cat, name, size, files, stats, posted. Direction is asc or desc. +

+
+
+
Sort request examples
+
+ @auth + + + ?t=search&q=ubuntu&sort=posted_desc + + + + ?t=search&q=ubuntu&sort=name_asc + + + + ?t=search&q=ubuntu&sort=size_desc&limit=50 + + @else + ?t=search&q=ubuntu&sort=posted_desc + ?t=search&q=ubuntu&sort=name_asc + ?t=search&q=ubuntu&sort=size_desc&limit=50&offset=0 + @endauth +
+
+
+
+
+
XML sort response snippet (sort=size_desc)
+
<item>
+  <title>Ubuntu ISO x64</title>
+  <newznab:attr name="size" value="734003200"/>
+</item>
+<item>
+  <title>Ubuntu ISO x86</title>
+  <newznab:attr name="size" value="367001600"/>
+</item>
+

Larger release appears first because size_desc sorts descending.

+
+

Output Format

diff --git a/resources/views/api/apiv2desc.blade.php b/resources/views/api/apiv2desc.blade.php index 8183744f5..afc5ca4b3 100644 --- a/resources/views/api/apiv2desc.blade.php +++ b/resources/views/api/apiv2desc.blade.php @@ -275,6 +275,52 @@ +

+ Sorting Results (v2) +

+

+ Search endpoints support sort=field_direction. + Allowed fields: cat, name, size, files, stats, posted. Direction is asc or desc. +

+
+
+
Sort request examples
+
+ @auth + + + search?id=ubuntu&sort=posted_desc + + + + search?id=ubuntu&sort=name_asc + + + + movies?imdbid=1418646&sort=size_desc + + @else + search?id=ubuntu&sort=posted_desc + search?id=ubuntu&sort=name_asc + movies?imdbid=1418646&sort=size_desc + @endauth +
+
+
+
+
+
JSON sort response snippet (sort=size_desc)
+
{
+  "Total": 2,
+  "Results": [
+    { "title": "Ubuntu ISO x64", "size": 734003200 },
+    { "title": "Ubuntu ISO x86", "size": 367001600 }
+  ]
+}
+

Results are largest-to-smallest when sort=size_desc.

+
+
+

Output Format

diff --git a/tests/Feature/ApiRequestMatrixTest.php b/tests/Feature/ApiRequestMatrixTest.php new file mode 100644 index 000000000..d4ee44934 --- /dev/null +++ b/tests/Feature/ApiRequestMatrixTest.php @@ -0,0 +1,285 @@ + 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + 'mail.from.address' => 'api-matrix@example.test', + 'app.key' => 'base64:'.base64_encode(random_bytes(32)), + ]); + + DB::purge(); + DB::reconnect(); + Cache::flush(); + + $this->createSchema(); + $this->seedData(); + } + + public function test_v1_invalid_sort_returns_xml_201_error(): void + { + $token = (string) DB::table('users')->value('api_token'); + + $response = $this->get('/api/v1/api?t=search&apikey='.$token.'&q=test&sort=bad_value'); + + $response->assertOk(); + $response->assertSee('assertSee('Incorrect parameter (sort', false); + } + + public function test_v1_invalid_maxage_returns_xml_201_error(): void + { + $token = (string) DB::table('users')->value('api_token'); + + $response = $this->get('/api/v1/api?t=search&apikey='.$token.'&q=test&maxage=abc'); + + $response->assertOk(); + $response->assertSee('assertSee('maxage must be numeric', false); + } + + public function test_v2_invalid_sort_returns_json_400_error(): void + { + $token = (string) DB::table('users')->value('api_token'); + + $this->getJson('/api/v2/search?api_token='.$token.'&id=test&sort=bad_value') + ->assertStatus(400) + ->assertJsonPath('error', 'Incorrect parameter (sort must be one of: cat_asc/desc, name_asc/desc, size_asc/desc, files_asc/desc, stats_asc/desc, posted_asc/desc)'); + } + + public function test_v2_invalid_maxage_returns_json_400_error(): void + { + $token = (string) DB::table('users')->value('api_token'); + + $this->getJson('/api/v2/search?api_token='.$token.'&id=test&maxage=abc') + ->assertStatus(400) + ->assertJsonPath('error', 'Incorrect parameter (maxage must be numeric)'); + } + + public function test_v1_caps_menu_data_includes_groups_and_genres(): void + { + $apiController = app(ApiController::class); + $reflection = new ReflectionClass($apiController); + $typeProperty = $reflection->getProperty('type'); + $typeProperty->setAccessible(true); + $typeProperty->setValue($apiController, 'caps'); + + $menu = $apiController->getForMenu(); + + $this->assertSame('alt.binaries.test', $menu['groups'][0]['name']); + $this->assertSame('Test Genre', $menu['genres'][0]['name']); + } + + public function test_v2_capabilities_includes_groups_and_genres(): void + { + $this->getJson('/api/v2/capabilities') + ->assertOk() + ->assertJsonPath('groups.0.name', 'alt.binaries.test') + ->assertJsonPath('genres.0.name', 'Test Genre'); + } + + private function createSchema(): void + { + Schema::create('roles', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name')->default('web'); + $table->integer('rate_limit')->default(60); + $table->integer('apirequests')->default(1000); + $table->integer('downloadrequests')->default(100); + $table->integer('addyears')->default(0); + $table->timestamps(); + }); + + Schema::create('users', function (Blueprint $table): void { + $table->increments('id'); + $table->string('username')->unique(); + $table->string('email')->unique(); + $table->string('password'); + $table->unsignedInteger('roles_id')->default(1); + $table->string('api_token')->nullable()->index(); + $table->string('host')->nullable(); + $table->timestamp('apiaccess')->nullable(); + $table->boolean('verified')->default(true); + $table->timestamp('email_verified_at')->nullable(); + $table->integer('rate_limit')->default(60); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('permissions', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->string('guard_name')->default('web'); + $table->timestamps(); + }); + + Schema::create('model_has_roles', function (Blueprint $table): void { + $table->unsignedInteger('role_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['role_id', 'model_id', 'model_type']); + }); + + Schema::create('model_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->string('model_type'); + $table->unsignedInteger('model_id'); + $table->primary(['permission_id', 'model_id', 'model_type']); + }); + + Schema::create('role_has_permissions', function (Blueprint $table): void { + $table->unsignedInteger('permission_id'); + $table->unsignedInteger('role_id'); + $table->primary(['permission_id', 'role_id']); + }); + + Schema::create('settings', function (Blueprint $table): void { + $table->string('name')->primary(); + $table->text('value')->nullable(); + }); + + Schema::create('root_categories', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->integer('status')->default(1); + }); + + Schema::create('categories', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->default(''); + $table->unsignedInteger('root_categories_id')->nullable(); + $table->integer('status')->default(1); + $table->text('description')->nullable(); + }); + + Schema::create('user_excluded_categories', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->unsignedInteger('categories_id'); + }); + + Schema::create('user_requests', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->text('request')->nullable(); + $table->timestamp('timestamp')->nullable(); + }); + + Schema::create('user_downloads', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('users_id'); + $table->timestamp('timestamp')->nullable(); + }); + + Schema::create('usenet_groups', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->boolean('active')->default(true); + $table->string('description')->nullable(); + $table->timestamp('last_updated')->nullable(); + }); + + Schema::create('genres', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title'); + $table->integer('type')->default(3000); + $table->boolean('disabled')->default(false); + }); + + Schema::create('registration_periods', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->dateTime('starts_at'); + $table->dateTime('ends_at'); + $table->boolean('is_enabled')->default(true); + $table->text('notes')->nullable(); + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->timestamps(); + }); + } + + private function seedData(): void + { + DB::table('settings')->insert([ + ['name' => 'strapline', 'value' => 'Test strapline'], + ['name' => 'metakeywords', 'value' => 'test,api'], + ['name' => 'registerstatus', 'value' => '0'], + ['name' => 'catwebdl', 'value' => '0'], + ['name' => 'title', 'value' => 'NNTmux Test'], + ['name' => 'home_link', 'value' => '/'], + ]); + + DB::table('roles')->insert([ + 'id' => 1, + 'name' => 'User', + 'guard_name' => 'web', + 'rate_limit' => 60, + 'apirequests' => 1000, + 'downloadrequests' => 100, + 'addyears' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('users')->insert([ + 'username' => 'matrix_user', + 'email' => 'matrix@example.test', + 'password' => bcrypt('secret'), + 'roles_id' => 1, + 'api_token' => Str::random(32), + 'verified' => 1, + 'email_verified_at' => now(), + 'rate_limit' => 60, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('root_categories')->insert([ + 'id' => 5000, + 'title' => 'TV', + 'status' => 1, + ]); + + DB::table('categories')->insert([ + 'id' => 5030, + 'title' => 'SD', + 'root_categories_id' => 5000, + 'status' => 1, + 'description' => 'TV SD', + ]); + + DB::table('usenet_groups')->insert([ + 'name' => 'alt.binaries.test', + 'active' => 1, + 'description' => 'Test usenet group', + 'last_updated' => now(), + ]); + + DB::table('genres')->insert([ + 'id' => 1, + 'title' => 'Test Genre', + 'type' => 3000, + 'disabled' => 0, + ]); + } +}