mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-31 10:18:55 +00:00
Update API spec and adjust the code accordingly
This commit is contained in:
@@ -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'],
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
+260
-692
File diff suppressed because it is too large
Load Diff
+115
-190
@@ -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`.
|
||||
|
||||
@@ -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&q=ubuntu&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&q=ubuntu&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&q=ubuntu&sort=size_desc&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&q=ubuntu&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&q=ubuntu&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&q=ubuntu&sort=size_desc&limit=50&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><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></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>
|
||||
|
||||
@@ -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&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&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&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&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&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&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>
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user