Update API spec and adjust the code accordingly

This commit is contained in:
DariusIII
2026-04-14 13:40:28 +02:00
parent c29f1137f7
commit b7fdf3dafd
10 changed files with 1090 additions and 950 deletions
+5
View File
@@ -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'],
};
+122 -21
View File
@@ -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(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<success id=\"0\" guid=\"\" categoryid=\"%s\" name=\"%s\" />\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.
*
+124 -22
View File
@@ -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) {
+33
View File
@@ -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();
}
/**
+54 -25
View File
@@ -144,7 +144,7 @@ class ReleaseSearchService
* @param array<string, mixed> $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<int|string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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);
File diff suppressed because it is too large Load Diff
+115 -190
View File
@@ -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://<host>/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://<host>/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=<token>&id=ubuntu&sort=posted_desc`
- `/api/v2/search?api_token=<token>&id=ubuntu&sort=name_asc`
- `/api/v2/tv?api_token=<token>&id=last+week+tonight&season=2025&ep=11/10&sort=posted_desc`
- `/api/v2/movies?api_token=<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": "<site 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=<api_token>&id=<guid>[&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=<api_token>&id=<guid>[&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/<guid>",
"url": "https://example.com/getnzb?id=<guid>.nzb&r=<api_token>",
"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/<guid>",
"link": "https://example.com/getnzb?id=<guid>.nzb&r=<api_token>",
"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`.
+46
View File
@@ -288,6 +288,52 @@
</tbody>
</table>
</div>
<h4 class="text-lg font-semibold mt-6 mb-3 text-gray-900 dark:text-gray-100 dark:text-white flex items-center">
<i class="fa fa-sort-amount-down mr-2 text-gray-600 dark:text-gray-400"></i>Sorting Results (v1)
</h4>
<p class="text-gray-700 dark:text-gray-300 mb-4 dark:text-gray-300">
Search-style endpoints support <code class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-xs text-red-600 dark:bg-gray-700 dark:text-red-400">sort=field_direction</code>.
Allowed fields: <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">cat</code>, <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">name</code>, <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">size</code>, <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">files</code>, <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">stats</code>, <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">posted</code>. Direction is <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">asc</code> or <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">desc</code>.
</p>
<div class="surface-panel-alt rounded-lg border shadow-sm mb-4">
<div class="p-6">
<h5 class="text-base font-semibold mb-2 text-gray-900 dark:text-gray-100 dark:text-white">Sort request examples</h5>
<div class="flex flex-col gap-2">
@auth
<a href="{{ url('/api/v1/api?t=search&q=ubuntu&sort=posted_desc&apikey=' . auth()->user()->api_token) }}" class="inline-flex items-center px-3 py-1.5 border border-primary-300 rounded text-xs font-medium text-primary-700 bg-white dark:bg-gray-800 hover:bg-primary-50 dark:text-primary-400 dark:border-primary-600 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:text-primary-400 dark:border-primary-600 dark:hover:bg-gray-600">
<i class="fa fa-external-link-alt mr-1"></i>
<code class="text-primary-700 dark:text-primary-400">?t=search&amp;q=ubuntu&amp;sort=posted_desc</code>
</a>
<a href="{{ url('/api/v1/api?t=search&q=ubuntu&sort=name_asc&apikey=' . auth()->user()->api_token) }}" class="inline-flex items-center px-3 py-1.5 border border-primary-300 rounded text-xs font-medium text-primary-700 bg-white dark:bg-gray-800 hover:bg-primary-50 dark:text-primary-400 dark:border-primary-600 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:text-primary-400 dark:border-primary-600 dark:hover:bg-gray-600">
<i class="fa fa-external-link-alt mr-1"></i>
<code class="text-primary-700 dark:text-primary-400">?t=search&amp;q=ubuntu&amp;sort=name_asc</code>
</a>
<a href="{{ url('/api/v1/api?t=search&q=ubuntu&sort=size_desc&limit=50&offset=0&apikey=' . auth()->user()->api_token) }}" class="inline-flex items-center px-3 py-1.5 border border-primary-300 rounded text-xs font-medium text-primary-700 bg-white dark:bg-gray-800 hover:bg-primary-50 dark:text-primary-400 dark:border-primary-600 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:text-primary-400 dark:border-primary-600 dark:hover:bg-gray-600">
<i class="fa fa-external-link-alt mr-1"></i>
<code class="text-primary-700 dark:text-primary-400">?t=search&amp;q=ubuntu&amp;sort=size_desc&amp;limit=50</code>
</a>
@else
<code class="block bg-gray-100 dark:bg-gray-800 p-2 rounded text-sm text-red-600 dark:bg-gray-700 dark:text-red-400">?t=search&amp;q=ubuntu&amp;sort=posted_desc</code>
<code class="block bg-gray-100 dark:bg-gray-800 p-2 rounded text-sm text-red-600 dark:bg-gray-700 dark:text-red-400">?t=search&amp;q=ubuntu&amp;sort=name_asc</code>
<code class="block bg-gray-100 dark:bg-gray-800 p-2 rounded text-sm text-red-600 dark:bg-gray-700 dark:text-red-400">?t=search&amp;q=ubuntu&amp;sort=size_desc&amp;limit=50&amp;offset=0</code>
@endauth
</div>
</div>
</div>
<div class="surface-panel-alt rounded-lg border shadow-sm mb-4">
<div class="p-6">
<h5 class="text-base font-semibold mb-2 text-gray-900 dark:text-gray-100 dark:text-white">XML sort response snippet (<code class="text-xs">sort=size_desc</code>)</h5>
<pre class="bg-gray-100 dark:bg-gray-800 p-3 rounded text-xs text-gray-800 dark:text-gray-200 overflow-x-auto"><code>&lt;item&gt;
&lt;title&gt;Ubuntu ISO x64&lt;/title&gt;
&lt;newznab:attr name="size" value="734003200"/&gt;
&lt;/item&gt;
&lt;item&gt;
&lt;title&gt;Ubuntu ISO x86&lt;/title&gt;
&lt;newznab:attr name="size" value="367001600"/&gt;
&lt;/item&gt;</code></pre>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2 mb-0">Larger release appears first because <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">size_desc</code> sorts descending.</p>
</div>
</div>
<h4 class="text-lg font-semibold mt-6 mb-3 text-gray-900 dark:text-gray-100 dark:text-white flex items-center">
<i class="fa fa-file-code mr-2 text-gray-600 dark:text-gray-400"></i>Output Format
</h4>
+46
View File
@@ -275,6 +275,52 @@
</table>
</div>
<h4 class="text-lg font-semibold mt-6 mb-3 text-gray-900 dark:text-gray-100 flex items-center">
<i class="fa fa-sort-amount-down mr-2 text-gray-600 dark:text-gray-400"></i>Sorting Results (v2)
</h4>
<p class="text-gray-700 dark:text-gray-300 mb-4 dark:text-gray-300">
Search endpoints support <code class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-xs text-red-600 dark:bg-gray-700 dark:text-red-400">sort=field_direction</code>.
Allowed fields: <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">cat</code>, <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">name</code>, <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">size</code>, <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">files</code>, <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">stats</code>, <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">posted</code>. Direction is <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">asc</code> or <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">desc</code>.
</p>
<div class="surface-panel-alt rounded-lg border shadow-sm mb-4">
<div class="p-6">
<h5 class="text-base font-semibold mb-2 text-gray-900 dark:text-gray-100">Sort request examples</h5>
<div class="flex flex-col gap-2">
@auth
<a href="{{ url('/api/v2/search?id=ubuntu&sort=posted_desc&api_token=' . auth()->user()->api_token) }}" class="inline-flex items-center px-3 py-1.5 border border-primary-300 rounded text-xs font-medium text-primary-700 bg-white dark:bg-gray-800 hover:bg-primary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:text-primary-400 dark:border-primary-600 dark:hover:bg-gray-600">
<i class="fa fa-external-link-alt mr-1"></i>
<code class="text-primary-700 dark:text-primary-400">search?id=ubuntu&amp;sort=posted_desc</code>
</a>
<a href="{{ url('/api/v2/search?id=ubuntu&sort=name_asc&api_token=' . auth()->user()->api_token) }}" class="inline-flex items-center px-3 py-1.5 border border-primary-300 rounded text-xs font-medium text-primary-700 bg-white dark:bg-gray-800 hover:bg-primary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:text-primary-400 dark:border-primary-600 dark:hover:bg-gray-600">
<i class="fa fa-external-link-alt mr-1"></i>
<code class="text-primary-700 dark:text-primary-400">search?id=ubuntu&amp;sort=name_asc</code>
</a>
<a href="{{ url('/api/v2/movies?imdbid=1418646&sort=size_desc&api_token=' . auth()->user()->api_token) }}" class="inline-flex items-center px-3 py-1.5 border border-primary-300 rounded text-xs font-medium text-primary-700 bg-white dark:bg-gray-800 hover:bg-primary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:text-primary-400 dark:border-primary-600 dark:hover:bg-gray-600">
<i class="fa fa-external-link-alt mr-1"></i>
<code class="text-primary-700 dark:text-primary-400">movies?imdbid=1418646&amp;sort=size_desc</code>
</a>
@else
<code class="block bg-gray-100 dark:bg-gray-800 p-2 rounded text-sm text-red-600 dark:bg-gray-700 dark:text-red-400">search?id=ubuntu&amp;sort=posted_desc</code>
<code class="block bg-gray-100 dark:bg-gray-800 p-2 rounded text-sm text-red-600 dark:bg-gray-700 dark:text-red-400">search?id=ubuntu&amp;sort=name_asc</code>
<code class="block bg-gray-100 dark:bg-gray-800 p-2 rounded text-sm text-red-600 dark:bg-gray-700 dark:text-red-400">movies?imdbid=1418646&amp;sort=size_desc</code>
@endauth
</div>
</div>
</div>
<div class="surface-panel-alt rounded-lg border shadow-sm mb-4">
<div class="p-6">
<h5 class="text-base font-semibold mb-2 text-gray-900 dark:text-gray-100">JSON sort response snippet (<code class="text-xs">sort=size_desc</code>)</h5>
<pre class="bg-gray-100 dark:bg-gray-800 p-3 rounded text-xs text-gray-800 dark:text-gray-200 overflow-x-auto"><code>{
"Total": 2,
"Results": [
{ "title": "Ubuntu ISO x64", "size": 734003200 },
{ "title": "Ubuntu ISO x86", "size": 367001600 }
]
}</code></pre>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2 mb-0"><code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">Results</code> are largest-to-smallest when <code class="px-1 bg-gray-100 dark:bg-gray-800 rounded text-xs">sort=size_desc</code>.</p>
</div>
</div>
<h4 class="text-lg font-semibold mt-6 mb-3 text-gray-900 dark:text-gray-100 flex items-center">
<i class="fa fa-file-code mr-2 text-gray-600 dark:text-gray-400"></i>Output Format
</h4>
+285
View File
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Http\Controllers\Api\ApiController;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use ReflectionClass;
use Tests\TestCase;
class ApiRequestMatrixTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => '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('<error code="201"', false);
$response->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('<error code="201"', false);
$response->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,
]);
}
}