mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update API and APIv2 handling
This commit is contained in:
@@ -8,15 +8,17 @@ use App\Events\UserAccessedApi;
|
|||||||
use App\Http\Controllers\BasePageController;
|
use App\Http\Controllers\BasePageController;
|
||||||
use App\Http\Controllers\GetNzbController;
|
use App\Http\Controllers\GetNzbController;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Genre;
|
|
||||||
use App\Models\Release;
|
use App\Models\Release;
|
||||||
use App\Models\ReleaseNfo;
|
use App\Models\ReleaseNfo;
|
||||||
use App\Models\Settings;
|
use App\Models\Settings;
|
||||||
use App\Models\UsenetGroup;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserRequest;
|
use App\Models\UserRequest;
|
||||||
|
use App\Services\Api\ApiCapabilitiesService;
|
||||||
|
use App\Services\Api\ApiQueryParameters;
|
||||||
use App\Services\Api\ApiReleaseRowCache;
|
use App\Services\Api\ApiReleaseRowCache;
|
||||||
use App\Services\RegistrationStatusService;
|
use App\Services\Api\ApiUsageService;
|
||||||
|
use App\Services\Api\ApiUserResolver;
|
||||||
|
use App\Services\Api\V1\ApiV1Presenter;
|
||||||
use App\Services\Releases\ReleaseBrowseService;
|
use App\Services\Releases\ReleaseBrowseService;
|
||||||
use App\Services\Releases\ReleaseSearchService;
|
use App\Services\Releases\ReleaseSearchService;
|
||||||
use App\Support\FilenameSanitizer;
|
use App\Support\FilenameSanitizer;
|
||||||
@@ -31,7 +33,6 @@ use Illuminate\Support\Facades\Cache;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
@@ -46,15 +47,35 @@ class ApiController extends BasePageController
|
|||||||
|
|
||||||
protected ApiReleaseRowCache $releaseRowCache;
|
protected ApiReleaseRowCache $releaseRowCache;
|
||||||
|
|
||||||
|
private ?ApiQueryParameters $queryParameters = null;
|
||||||
|
|
||||||
|
private ?ApiUsageService $usageService = null;
|
||||||
|
|
||||||
|
private ApiUserResolver $userResolver;
|
||||||
|
|
||||||
|
private ApiCapabilitiesService $capabilitiesService;
|
||||||
|
|
||||||
|
private ApiV1Presenter $presenter;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ReleaseSearchService $releaseSearchService,
|
ReleaseSearchService $releaseSearchService,
|
||||||
ReleaseBrowseService $releaseBrowseService,
|
ReleaseBrowseService $releaseBrowseService,
|
||||||
?ApiReleaseRowCache $releaseRowCache = null
|
?ApiReleaseRowCache $releaseRowCache = null,
|
||||||
|
?ApiQueryParameters $queryParameters = null,
|
||||||
|
?ApiUsageService $usageService = null,
|
||||||
|
?ApiUserResolver $userResolver = null,
|
||||||
|
?ApiCapabilitiesService $capabilitiesService = null,
|
||||||
|
?ApiV1Presenter $presenter = null,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->releaseSearchService = $releaseSearchService;
|
$this->releaseSearchService = $releaseSearchService;
|
||||||
$this->releaseBrowseService = $releaseBrowseService;
|
$this->releaseBrowseService = $releaseBrowseService;
|
||||||
$this->releaseRowCache = $releaseRowCache ?? app(ApiReleaseRowCache::class);
|
$this->releaseRowCache = $releaseRowCache ?? app(ApiReleaseRowCache::class);
|
||||||
|
$this->queryParameters = $queryParameters ?? app(ApiQueryParameters::class);
|
||||||
|
$this->usageService = $usageService ?? app(ApiUsageService::class);
|
||||||
|
$this->userResolver = $userResolver ?? app(ApiUserResolver::class);
|
||||||
|
$this->capabilitiesService = $capabilitiesService ?? app(ApiCapabilitiesService::class);
|
||||||
|
$this->presenter = $presenter ?? app(ApiV1Presenter::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,12 +153,7 @@ class ApiController extends BasePageController
|
|||||||
$apiKey = $request->input('apikey');
|
$apiKey = $request->input('apikey');
|
||||||
|
|
||||||
// Cache user lookup for 5 minutes to avoid repeated DB hits (same pattern as API v2)
|
// Cache user lookup for 5 minutes to avoid repeated DB hits (same pattern as API v2)
|
||||||
$userCacheKey = 'api_user:'.md5((string) $apiKey);
|
$res = $this->userResolver->v1((string) $apiKey);
|
||||||
$res = Cache::remember($userCacheKey, 300, function () use ($apiKey) {
|
|
||||||
return User::verifiedApiTokenQuery($apiKey)
|
|
||||||
->with('role')
|
|
||||||
->first();
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($res === null) {
|
if ($res === null) {
|
||||||
return showApiError(100, 'Incorrect user credentials (wrong API key)');
|
return showApiError(100, 'Incorrect user credentials (wrong API key)');
|
||||||
@@ -768,35 +784,8 @@ class ApiController extends BasePageController
|
|||||||
public function output(mixed $data, array $params, bool $xml, int $offset, string $type = '', array $headers = [])
|
public function output(mixed $data, array $params, bool $xml, int $offset, string $type = '', array $headers = [])
|
||||||
{
|
{
|
||||||
$this->type = $type;
|
$this->type = $type;
|
||||||
$options = [
|
|
||||||
'Parameters' => $params,
|
|
||||||
'Data' => $data,
|
|
||||||
'Server' => $this->getForMenu(),
|
|
||||||
'Offset' => $offset,
|
|
||||||
'Type' => $type,
|
|
||||||
];
|
|
||||||
|
|
||||||
$xmlResponse = new XML_Response($options);
|
return $this->presenter->output($data, $params, $xml, $offset, $type, $headers);
|
||||||
|
|
||||||
if ($xml) {
|
|
||||||
$response = $xmlResponse->returnXML();
|
|
||||||
$contentType = 'text/xml';
|
|
||||||
} else {
|
|
||||||
$arrayData = $xmlResponse->returnArray();
|
|
||||||
if ($arrayData === false) {
|
|
||||||
return showApiError(201);
|
|
||||||
}
|
|
||||||
$response = json_encode($arrayData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
|
||||||
$contentType = 'application/json';
|
|
||||||
}
|
|
||||||
if ($response === false) {
|
|
||||||
return showApiError(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response($response, 200, array_merge([
|
|
||||||
'Content-type' => $contentType,
|
|
||||||
'Content-Length' => (string) \strlen($response),
|
|
||||||
], $headers));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -811,72 +800,7 @@ class ApiController extends BasePageController
|
|||||||
{
|
{
|
||||||
$includeCats = $this->type === 'caps';
|
$includeCats = $this->type === 'caps';
|
||||||
|
|
||||||
// Cache the server info blob (without categories) for 10 minutes
|
return $this->capabilitiesService->v1($includeCats);
|
||||||
$serverInfo = Cache::remember('api_v1_server_menu', 600, function () {
|
|
||||||
$serverroot = url('/');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'server' => [
|
|
||||||
'title' => config('app.name'),
|
|
||||||
'strapline' => Settings::settingValue('strapline'),
|
|
||||||
'email' => config('mail.from.address'),
|
|
||||||
'meta' => Settings::settingValue('metakeywords'),
|
|
||||||
'url' => $serverroot,
|
|
||||||
'image' => $serverroot.'/assets/images/tmux_logo.png',
|
|
||||||
],
|
|
||||||
'limits' => [
|
|
||||||
'max' => 100,
|
|
||||||
'default' => 100,
|
|
||||||
],
|
|
||||||
'searching' => [
|
|
||||||
'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'],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$registrationStatus = app(RegistrationStatusService::class)->resolve();
|
|
||||||
$serverInfo['registration'] = [
|
|
||||||
'available' => $registrationStatus['available'] ? 'yes' : 'no',
|
|
||||||
'open' => $registrationStatus['is_open'] ? 'yes' : 'no',
|
|
||||||
];
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -901,39 +825,11 @@ class ApiController extends BasePageController
|
|||||||
/**
|
/**
|
||||||
* Verify cat parameter.
|
* Verify cat parameter.
|
||||||
*
|
*
|
||||||
* @return array<string, mixed>
|
* @return array<int, string|int>
|
||||||
*/
|
*/
|
||||||
public function categoryID(Request $request): array
|
public function categoryID(Request $request): array
|
||||||
{
|
{
|
||||||
$categoryID = [-1];
|
return $this->parameters()->categories($request);
|
||||||
if (! $request->has('cat')) {
|
|
||||||
return $categoryID;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rawCategoryIDs = $request->input('cat');
|
|
||||||
if (is_array($rawCategoryIDs)) {
|
|
||||||
$categoryIDs = implode(',', array_values(array_filter(array_map(
|
|
||||||
static fn (mixed $categoryId): string => urldecode(trim((string) $categoryId)),
|
|
||||||
$rawCategoryIDs
|
|
||||||
), static fn (string $categoryId): bool => $categoryId !== '')));
|
|
||||||
} elseif (is_scalar($rawCategoryIDs)) {
|
|
||||||
$categoryIDs = urldecode(trim((string) $rawCategoryIDs));
|
|
||||||
} else {
|
|
||||||
return $categoryID;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($categoryIDs === '') {
|
|
||||||
return $categoryID;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append Web-DL category ID if HD present for SickBeard / Sonarr compatibility.
|
|
||||||
if (str_contains($categoryIDs, (string) Category::TV_HD) && ! str_contains($categoryIDs, (string) Category::TV_WEBDL) && (int) Settings::settingValue('catwebdl') === 0) {
|
|
||||||
$categoryIDs .= (','.Category::TV_WEBDL);
|
|
||||||
}
|
|
||||||
|
|
||||||
$categoryID = array_values(array_filter(array_map('trim', explode(',', $categoryIDs)), static fn (string $categoryId): bool => $categoryId !== ''));
|
|
||||||
|
|
||||||
return $categoryID;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -943,15 +839,7 @@ class ApiController extends BasePageController
|
|||||||
*/
|
*/
|
||||||
public function group(Request $request): string|int|bool
|
public function group(Request $request): string|int|bool
|
||||||
{
|
{
|
||||||
$groupName = -1;
|
return $this->parameters()->group($request);
|
||||||
if ($request->has('group')) {
|
|
||||||
$group = UsenetGroup::isValidGroup($request->input('group'));
|
|
||||||
if ($group !== false) {
|
|
||||||
$groupName = $group;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $groupName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -959,12 +847,7 @@ class ApiController extends BasePageController
|
|||||||
*/
|
*/
|
||||||
public function limit(Request $request): int
|
public function limit(Request $request): int
|
||||||
{
|
{
|
||||||
$limit = 100;
|
return $this->parameters()->limit($request);
|
||||||
if ($request->has('limit') && is_numeric($request->input('limit'))) {
|
|
||||||
$limit = (int) $request->input('limit');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $limit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -972,12 +855,7 @@ class ApiController extends BasePageController
|
|||||||
*/
|
*/
|
||||||
public function offset(Request $request): int
|
public function offset(Request $request): int
|
||||||
{
|
{
|
||||||
$offset = 0;
|
return $this->parameters()->offset($request);
|
||||||
if ($request->has('offset') && is_numeric($request->input('offset'))) {
|
|
||||||
$offset = (int) $request->input('offset');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $offset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1042,19 +920,17 @@ class ApiController extends BasePageController
|
|||||||
*/
|
*/
|
||||||
public function getCachedUserStats(int $userId): object
|
public function getCachedUserStats(int $userId): object
|
||||||
{
|
{
|
||||||
$cacheKey = 'api_user_stats:'.$userId;
|
return $this->usage()->statistics($userId);
|
||||||
|
}
|
||||||
|
|
||||||
return Cache::remember($cacheKey, 60, function () use ($userId) {
|
private function parameters(): ApiQueryParameters
|
||||||
$oneDayAgo = now()->subDay()->toDateTimeString();
|
{
|
||||||
|
return $this->queryParameters ?? new ApiQueryParameters;
|
||||||
|
}
|
||||||
|
|
||||||
return DB::selectOne('
|
private function usage(): ApiUsageService
|
||||||
SELECT
|
{
|
||||||
(SELECT COUNT(*) FROM user_requests WHERE users_id = ? AND timestamp > ?) as api_count,
|
return $this->usageService ?? new ApiUsageService;
|
||||||
(SELECT COUNT(*) FROM user_downloads WHERE users_id = ? AND timestamp > ?) as grab_count,
|
|
||||||
(SELECT MIN(timestamp) FROM user_requests WHERE users_id = ? AND timestamp > ?) as api_time,
|
|
||||||
(SELECT MIN(timestamp) FROM user_downloads WHERE users_id = ? AND timestamp > ?) as grab_time
|
|
||||||
', [$userId, $oneDayAgo, $userId, $oneDayAgo, $userId, $oneDayAgo, $userId, $oneDayAgo]);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addCoverURL(mixed &$releases, callable $getCoverURL): void
|
public function addCoverURL(mixed &$releases, callable $getCoverURL): void
|
||||||
|
|||||||
@@ -4,22 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Data\Api\CategoryData;
|
|
||||||
use App\Data\Api\DetailsData;
|
|
||||||
use App\Data\Api\ReleaseData;
|
use App\Data\Api\ReleaseData;
|
||||||
use App\Events\UserAccessedApi;
|
|
||||||
use App\Http\Controllers\BasePageController;
|
use App\Http\Controllers\BasePageController;
|
||||||
use App\Http\Controllers\GetNzbController;
|
use App\Http\Controllers\GetNzbController;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Genre;
|
|
||||||
use App\Models\Release;
|
use App\Models\Release;
|
||||||
use App\Models\RootCategory;
|
|
||||||
use App\Models\Settings;
|
|
||||||
use App\Models\UsenetGroup;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserRequest;
|
use App\Services\Api\ApiCapabilitiesService;
|
||||||
|
use App\Services\Api\ApiQueryParameters;
|
||||||
use App\Services\Api\ApiReleaseRowCache;
|
use App\Services\Api\ApiReleaseRowCache;
|
||||||
use App\Services\RegistrationStatusService;
|
use App\Services\Api\ApiUsageService;
|
||||||
|
use App\Services\Api\ApiUserResolver;
|
||||||
|
use App\Services\Api\V2\ApiV2Presenter;
|
||||||
use App\Services\Releases\ReleaseBrowseService;
|
use App\Services\Releases\ReleaseBrowseService;
|
||||||
use App\Services\Releases\ReleaseSearchService;
|
use App\Services\Releases\ReleaseSearchService;
|
||||||
use Illuminate\Contracts\Foundation\Application;
|
use Illuminate\Contracts\Foundation\Application;
|
||||||
@@ -31,36 +27,49 @@ use Illuminate\Http\Response;
|
|||||||
use Illuminate\Routing\Redirector;
|
use Illuminate\Routing\Redirector;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class ApiV2Controller extends BasePageController
|
class ApiV2Controller extends BasePageController
|
||||||
{
|
{
|
||||||
private const JSON_ENCODING_OPTIONS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
|
|
||||||
|
|
||||||
private ApiController $api;
|
|
||||||
|
|
||||||
private ReleaseSearchService $releaseSearchService;
|
private ReleaseSearchService $releaseSearchService;
|
||||||
|
|
||||||
private ReleaseBrowseService $releaseBrowseService;
|
private ReleaseBrowseService $releaseBrowseService;
|
||||||
|
|
||||||
private ApiReleaseRowCache $releaseRowCache;
|
private ApiReleaseRowCache $releaseRowCache;
|
||||||
|
|
||||||
|
private ApiQueryParameters $queryParameters;
|
||||||
|
|
||||||
|
private ApiUsageService $usageService;
|
||||||
|
|
||||||
|
private ApiUserResolver $userResolver;
|
||||||
|
|
||||||
|
private ApiV2Presenter $presenter;
|
||||||
|
|
||||||
|
private ApiCapabilitiesService $capabilitiesService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, object>
|
* @var array<int, object>
|
||||||
*/
|
*/
|
||||||
private array $resolvedUserStats = [];
|
private array $resolvedUserStats = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ApiController $api,
|
|
||||||
ReleaseSearchService $releaseSearchService,
|
ReleaseSearchService $releaseSearchService,
|
||||||
ReleaseBrowseService $releaseBrowseService,
|
ReleaseBrowseService $releaseBrowseService,
|
||||||
?ApiReleaseRowCache $releaseRowCache = null
|
?ApiReleaseRowCache $releaseRowCache = null,
|
||||||
|
?ApiQueryParameters $queryParameters = null,
|
||||||
|
?ApiUsageService $usageService = null,
|
||||||
|
?ApiUserResolver $userResolver = null,
|
||||||
|
?ApiV2Presenter $presenter = null,
|
||||||
|
?ApiCapabilitiesService $capabilitiesService = null,
|
||||||
) {
|
) {
|
||||||
$this->api = $api;
|
|
||||||
$this->releaseSearchService = $releaseSearchService;
|
$this->releaseSearchService = $releaseSearchService;
|
||||||
$this->releaseBrowseService = $releaseBrowseService;
|
$this->releaseBrowseService = $releaseBrowseService;
|
||||||
$this->releaseRowCache = $releaseRowCache ?? app(ApiReleaseRowCache::class);
|
$this->releaseRowCache = $releaseRowCache ?? app(ApiReleaseRowCache::class);
|
||||||
|
$this->queryParameters = $queryParameters ?? app(ApiQueryParameters::class);
|
||||||
|
$this->usageService = $usageService ?? app(ApiUsageService::class);
|
||||||
|
$this->userResolver = $userResolver ?? app(ApiUserResolver::class);
|
||||||
|
$this->presenter = $presenter ?? app(ApiV2Presenter::class);
|
||||||
|
$this->capabilitiesService = $capabilitiesService ?? app(ApiCapabilitiesService::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,14 +83,7 @@ class ApiV2Controller extends BasePageController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$apiToken = $request->input('api_token');
|
$apiToken = $request->input('api_token');
|
||||||
$userCacheKey = 'api_user:'.md5((string) $apiToken);
|
$user = $this->userResolver->v2((string) $apiToken);
|
||||||
|
|
||||||
$user = Cache::remember($userCacheKey, 300, function () use ($apiToken) {
|
|
||||||
return User::query()
|
|
||||||
->whereApiToken((string) $apiToken)
|
|
||||||
->with('role')
|
|
||||||
->first();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (! $user || ! $user->hasVerifiedEmail()) {
|
if (! $user || ! $user->hasVerifiedEmail()) {
|
||||||
return apiJsonError(100);
|
return apiJsonError(100);
|
||||||
@@ -125,13 +127,12 @@ class ApiV2Controller extends BasePageController
|
|||||||
|
|
||||||
private function userStatsFor(User $user): object
|
private function userStatsFor(User $user): object
|
||||||
{
|
{
|
||||||
return $this->resolvedUserStats[$user->id] ??= $this->api->getCachedUserStats($user->id);
|
return $this->resolvedUserStats[$user->id] ??= $this->usageService->statistics($user->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recordApiRequest(User $user, Request $request): void
|
private function recordApiRequest(User $user, Request $request): void
|
||||||
{
|
{
|
||||||
UserRequest::addApiRequest($user->id, $request->getRequestUri());
|
$this->usageService->record($user, $request);
|
||||||
event(new UserAccessedApi($user, $request->ip()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,7 +140,7 @@ class ApiV2Controller extends BasePageController
|
|||||||
*/
|
*/
|
||||||
private function jsonResponse(array $data, int $status = 200): JsonResponse
|
private function jsonResponse(array $data, int $status = 200): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json($data, $status, [], self::JSON_ENCODING_OPTIONS);
|
return $this->presenter->json($data, $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,21 +156,7 @@ class ApiV2Controller extends BasePageController
|
|||||||
*/
|
*/
|
||||||
private function buildSearchResponse(iterable $rows, User $user): JsonResponse
|
private function buildSearchResponse(iterable $rows, User $user): JsonResponse
|
||||||
{
|
{
|
||||||
$rowsArray = is_array($rows) ? $rows : iterator_to_array($rows, false);
|
return $this->presenter->search($rows, $user, $this->buildUserStatsResponse($user));
|
||||||
$total = (int) ($rowsArray[0]->_totalrows ?? 0);
|
|
||||||
$detailsBaseUrl = url('/details').'/';
|
|
||||||
$getNzbBaseUrl = url('/getnzb');
|
|
||||||
|
|
||||||
$results = [];
|
|
||||||
foreach ($rowsArray as $row) {
|
|
||||||
$results[] = ReleaseData::toArrayFromRelease($row, $user, $detailsBaseUrl, $getNzbBaseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->jsonResponse(array_merge(
|
|
||||||
['Total' => $total],
|
|
||||||
$this->buildUserStatsResponse($user),
|
|
||||||
['results' => $results],
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseMaxAge(Request $request): int|JsonResponse
|
private function parseMaxAge(Request $request): int|JsonResponse
|
||||||
@@ -206,69 +193,7 @@ class ApiV2Controller extends BasePageController
|
|||||||
|
|
||||||
public function capabilities(): JsonResponse
|
public function capabilities(): JsonResponse
|
||||||
{
|
{
|
||||||
// Cache the full capabilities response for 10 minutes
|
return $this->jsonResponse($this->capabilitiesService->v2());
|
||||||
$capabilities = Cache::remember('api_v2_capabilities', 600, function () {
|
|
||||||
$category = Category::getForApi();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'server' => [
|
|
||||||
'title' => config('app.name'),
|
|
||||||
'strapline' => Settings::settingValue('strapline'),
|
|
||||||
'email' => config('mail.from.address'),
|
|
||||||
'url' => url('/'),
|
|
||||||
],
|
|
||||||
'limits' => [
|
|
||||||
'max' => 100,
|
|
||||||
'default' => 100,
|
|
||||||
],
|
|
||||||
'searching' => [
|
|
||||||
'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' => $category
|
|
||||||
->map(static fn (RootCategory $rootCategory): array => CategoryData::fromCategory($rootCategory)->toArray())
|
|
||||||
->values()
|
|
||||||
->all(),
|
|
||||||
'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()
|
|
||||||
->all()
|
|
||||||
: [],
|
|
||||||
'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()
|
|
||||||
->all()
|
|
||||||
: [],
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$registrationStatus = app(RegistrationStatusService::class)->resolve();
|
|
||||||
$capabilities['registration'] = [
|
|
||||||
'available' => $registrationStatus['available'] ? 'yes' : 'no',
|
|
||||||
'open' => $registrationStatus['is_open'] ? 'yes' : 'no',
|
|
||||||
];
|
|
||||||
|
|
||||||
return $this->jsonResponse($capabilities);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -292,9 +217,9 @@ class ApiV2Controller extends BasePageController
|
|||||||
if ($searchName === '' && ! imdb_id_is_valid($imdbId) && $tmdbId <= 0 && $traktId <= 0) {
|
if ($searchName === '' && ! imdb_id_is_valid($imdbId) && $tmdbId <= 0 && $traktId <= 0) {
|
||||||
return $this->jsonResponse(['error' => 'Specify id (query), imdbid, tmdbid, or traktid'], 400);
|
return $this->jsonResponse(['error' => 'Specify id (query), imdbid, tmdbid, or traktid'], 400);
|
||||||
}
|
}
|
||||||
$offset = $this->api->offset($request);
|
$offset = $this->queryParameters->offset($request);
|
||||||
$limit = $this->api->limit($request);
|
$limit = $this->queryParameters->limit($request);
|
||||||
$categoryID = $this->api->categoryID($request);
|
$categoryID = $this->queryParameters->categories($request);
|
||||||
$maxAge = $this->parseMaxAge($request);
|
$maxAge = $this->parseMaxAge($request);
|
||||||
if (! is_int($maxAge)) {
|
if (! is_int($maxAge)) {
|
||||||
return $maxAge;
|
return $maxAge;
|
||||||
@@ -352,9 +277,9 @@ class ApiV2Controller extends BasePageController
|
|||||||
return $this->jsonResponse(['error' => 'Incorrect parameter (id must not be empty)'], 400);
|
return $this->jsonResponse(['error' => 'Incorrect parameter (id must not be empty)'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$offset = $this->api->offset($request);
|
$offset = $this->queryParameters->offset($request);
|
||||||
$limit = $this->api->limit($request);
|
$limit = $this->queryParameters->limit($request);
|
||||||
$categoryID = $this->api->categoryID($request);
|
$categoryID = $this->queryParameters->categories($request);
|
||||||
$maxAge = $this->parseMaxAge($request);
|
$maxAge = $this->parseMaxAge($request);
|
||||||
if (! is_int($maxAge)) {
|
if (! is_int($maxAge)) {
|
||||||
return $maxAge;
|
return $maxAge;
|
||||||
@@ -366,7 +291,7 @@ class ApiV2Controller extends BasePageController
|
|||||||
|
|
||||||
$minSize = max(0, (int) $request->input('minsize', 0));
|
$minSize = max(0, (int) $request->input('minsize', 0));
|
||||||
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
||||||
$groupName = $this->api->group($request);
|
$groupName = $this->queryParameters->group($request);
|
||||||
$searchName = (string) $request->input('id', '');
|
$searchName = (string) $request->input('id', '');
|
||||||
|
|
||||||
if ($searchName === '') {
|
if ($searchName === '') {
|
||||||
@@ -429,9 +354,9 @@ class ApiV2Controller extends BasePageController
|
|||||||
return $this->jsonResponse(['error' => 'Incorrect parameter (id must not be empty)'], 400);
|
return $this->jsonResponse(['error' => 'Incorrect parameter (id must not be empty)'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$offset = $this->api->offset($request);
|
$offset = $this->queryParameters->offset($request);
|
||||||
$limit = $this->api->limit($request);
|
$limit = $this->queryParameters->limit($request);
|
||||||
$categoryID = $this->api->categoryID($request);
|
$categoryID = $this->queryParameters->categories($request);
|
||||||
$maxAge = $this->parseMaxAge($request);
|
$maxAge = $this->parseMaxAge($request);
|
||||||
if (! is_int($maxAge)) {
|
if (! is_int($maxAge)) {
|
||||||
return $maxAge;
|
return $maxAge;
|
||||||
@@ -443,7 +368,7 @@ class ApiV2Controller extends BasePageController
|
|||||||
|
|
||||||
$minSize = max(0, (int) $request->input('minsize', 0));
|
$minSize = max(0, (int) $request->input('minsize', 0));
|
||||||
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
||||||
$groupName = $this->api->group($request);
|
$groupName = $this->queryParameters->group($request);
|
||||||
$searchName = (string) $request->input('id', '');
|
$searchName = (string) $request->input('id', '');
|
||||||
|
|
||||||
if ($searchName === '') {
|
if ($searchName === '') {
|
||||||
@@ -509,9 +434,9 @@ class ApiV2Controller extends BasePageController
|
|||||||
return $this->jsonResponse(['error' => 'Specify id (query), anidbid, or anilistid'], 400);
|
return $this->jsonResponse(['error' => 'Specify id (query), anidbid, or anilistid'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$offset = $this->api->offset($request);
|
$offset = $this->queryParameters->offset($request);
|
||||||
$limit = $this->api->limit($request);
|
$limit = $this->queryParameters->limit($request);
|
||||||
$categoryID = $this->api->categoryID($request);
|
$categoryID = $this->queryParameters->categories($request);
|
||||||
$maxAge = $this->parseMaxAge($request);
|
$maxAge = $this->parseMaxAge($request);
|
||||||
if (! is_int($maxAge)) {
|
if (! is_int($maxAge)) {
|
||||||
return $maxAge;
|
return $maxAge;
|
||||||
@@ -561,7 +486,7 @@ class ApiV2Controller extends BasePageController
|
|||||||
|
|
||||||
$this->recordApiRequest($user, $request);
|
$this->recordApiRequest($user, $request);
|
||||||
|
|
||||||
$offset = $this->api->offset($request);
|
$offset = $this->queryParameters->offset($request);
|
||||||
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
||||||
$minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0;
|
$minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0;
|
||||||
$maxAge = $this->parseMaxAge($request);
|
$maxAge = $this->parseMaxAge($request);
|
||||||
@@ -572,12 +497,12 @@ class ApiV2Controller extends BasePageController
|
|||||||
if (! is_string($sort)) {
|
if (! is_string($sort)) {
|
||||||
return $sort;
|
return $sort;
|
||||||
}
|
}
|
||||||
$groupName = $this->api->group($request);
|
$groupName = $this->queryParameters->group($request);
|
||||||
if (is_array($groupName)) {
|
if (is_array($groupName)) {
|
||||||
$groupName = $groupName[0] ?? -1;
|
$groupName = $groupName[0] ?? -1;
|
||||||
}
|
}
|
||||||
$categoryID = $this->api->categoryID($request);
|
$categoryID = $this->queryParameters->categories($request);
|
||||||
$limit = $this->api->limit($request);
|
$limit = $this->queryParameters->limit($request);
|
||||||
|
|
||||||
$searchName = $request->input('id');
|
$searchName = $request->input('id');
|
||||||
$relData = $this->releaseRowCache->remember('v2', 'search', [
|
$relData = $this->releaseRowCache->remember('v2', 'search', [
|
||||||
@@ -634,16 +559,6 @@ class ApiV2Controller extends BasePageController
|
|||||||
|
|
||||||
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
||||||
$minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0;
|
$minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0;
|
||||||
$this->api->verifyEmptyParameter($request, 'id');
|
|
||||||
$this->api->verifyEmptyParameter($request, 'vid');
|
|
||||||
$this->api->verifyEmptyParameter($request, 'tvdbid');
|
|
||||||
$this->api->verifyEmptyParameter($request, 'traktid');
|
|
||||||
$this->api->verifyEmptyParameter($request, 'rid');
|
|
||||||
$this->api->verifyEmptyParameter($request, 'tvmazeid');
|
|
||||||
$this->api->verifyEmptyParameter($request, 'imdbid');
|
|
||||||
$this->api->verifyEmptyParameter($request, 'tmdbid');
|
|
||||||
$this->api->verifyEmptyParameter($request, 'season');
|
|
||||||
$this->api->verifyEmptyParameter($request, 'ep');
|
|
||||||
if (! $this->hasTvSearchParameters($request)) {
|
if (! $this->hasTvSearchParameters($request)) {
|
||||||
return $this->jsonResponse(['error' => 'Specify id (query), vid, tvdbid, traktid, rid, tvmazeid, imdbid, or tmdbid'], 400);
|
return $this->jsonResponse(['error' => 'Specify id (query), vid, tvdbid, traktid, rid, tvmazeid, imdbid, or tmdbid'], 400);
|
||||||
}
|
}
|
||||||
@@ -676,9 +591,9 @@ class ApiV2Controller extends BasePageController
|
|||||||
$airDate = str_replace('/', '-', $year[0].'-'.$episode);
|
$airDate = str_replace('/', '-', $year[0].'-'.$episode);
|
||||||
}
|
}
|
||||||
|
|
||||||
$offset = $this->api->offset($request);
|
$offset = $this->queryParameters->offset($request);
|
||||||
$limit = $this->api->limit($request);
|
$limit = $this->queryParameters->limit($request);
|
||||||
$categoryID = $this->api->categoryID($request);
|
$categoryID = $this->queryParameters->categories($request);
|
||||||
$airDate = $airDate ?? '';
|
$airDate = $airDate ?? '';
|
||||||
$searchName = $request->input('id') ?? '';
|
$searchName = $request->input('id') ?? '';
|
||||||
|
|
||||||
@@ -751,12 +666,7 @@ class ApiV2Controller extends BasePageController
|
|||||||
return $this->jsonResponse(['error' => 'No such item'], 404);
|
return $this->jsonResponse(['error' => 'No such item'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->jsonResponse(DetailsData::toArrayFromRelease(
|
return $this->presenter->details($relData, $user);
|
||||||
$relData,
|
|
||||||
$user,
|
|
||||||
url('/details').'/',
|
|
||||||
url('/getnzb')
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function hasTvSearchParameters(Request $request): bool
|
private function hasTvSearchParameters(Request $request): bool
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Api;
|
||||||
|
|
||||||
|
use App\Data\Api\CategoryData;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Genre;
|
||||||
|
use App\Models\RootCategory;
|
||||||
|
use App\Models\Settings;
|
||||||
|
use App\Models\UsenetGroup;
|
||||||
|
use App\Services\RegistrationStatusService;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
final readonly class ApiCapabilitiesService
|
||||||
|
{
|
||||||
|
public function __construct(private RegistrationStatusService $registrationStatus) {}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function v1(bool $includeCatalogs): array
|
||||||
|
{
|
||||||
|
$data = Cache::remember('api_v1_server_menu', 600, static fn (): array => [
|
||||||
|
'server' => [
|
||||||
|
'title' => config('app.name'),
|
||||||
|
'strapline' => Settings::settingValue('strapline'),
|
||||||
|
'email' => config('mail.from.address'),
|
||||||
|
'meta' => Settings::settingValue('metakeywords'),
|
||||||
|
'url' => url('/'),
|
||||||
|
'image' => url('/').'/assets/images/tmux_logo.png',
|
||||||
|
],
|
||||||
|
'limits' => ['max' => 100, 'default' => 100],
|
||||||
|
'searching' => [
|
||||||
|
'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'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = $this->registrationStatus->resolve();
|
||||||
|
$data['registration'] = $this->registration($status);
|
||||||
|
$data['categories'] = $includeCatalogs ? Category::getForMenu() : null;
|
||||||
|
$data['groups'] = $includeCatalogs ? $this->groups() : null;
|
||||||
|
$data['genres'] = $includeCatalogs ? $this->genres() : null;
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function v2(): array
|
||||||
|
{
|
||||||
|
$data = Cache::remember('api_v2_capabilities', 600, function (): array {
|
||||||
|
return [
|
||||||
|
'server' => [
|
||||||
|
'title' => config('app.name'),
|
||||||
|
'strapline' => Settings::settingValue('strapline'),
|
||||||
|
'email' => config('mail.from.address'),
|
||||||
|
'url' => url('/'),
|
||||||
|
],
|
||||||
|
'limits' => ['max' => 100, 'default' => 100],
|
||||||
|
'searching' => [
|
||||||
|
'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' => Category::getForApi()
|
||||||
|
->map(static fn (RootCategory $category): array => CategoryData::fromCategory($category)->toArray())
|
||||||
|
->values()->all(),
|
||||||
|
'groups' => $this->groups(),
|
||||||
|
'genres' => $this->genres(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$status = $this->registrationStatus->resolve();
|
||||||
|
$data['registration'] = $this->registration($status);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function groups(): array
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('usenet_groups')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function genres(): array
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('genres')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{available: bool, is_open: bool} $status
|
||||||
|
* @return array{available: string, open: string}
|
||||||
|
*/
|
||||||
|
private function registration(array $status): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'available' => $status['available'] ? 'yes' : 'no',
|
||||||
|
'open' => $status['is_open'] ? 'yes' : 'no',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Api;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Settings;
|
||||||
|
use App\Models\UsenetGroup;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class ApiQueryParameters
|
||||||
|
{
|
||||||
|
/** @return array<int, string|int> */
|
||||||
|
public function categories(Request $request): array
|
||||||
|
{
|
||||||
|
if (! $request->has('cat')) {
|
||||||
|
return [-1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = $request->input('cat');
|
||||||
|
if (is_array($raw)) {
|
||||||
|
$value = implode(',', array_values(array_filter(array_map(
|
||||||
|
static fn (mixed $id): string => urldecode(trim((string) $id)),
|
||||||
|
$raw
|
||||||
|
), static fn (string $id): bool => $id !== '')));
|
||||||
|
} elseif (is_scalar($raw)) {
|
||||||
|
$value = urldecode(trim((string) $raw));
|
||||||
|
} else {
|
||||||
|
return [-1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return [-1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($value, (string) Category::TV_HD)
|
||||||
|
&& ! str_contains($value, (string) Category::TV_WEBDL)
|
||||||
|
&& (int) Settings::settingValue('catwebdl') === 0) {
|
||||||
|
$value .= ','.Category::TV_WEBDL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
array_map('trim', explode(',', $value)),
|
||||||
|
static fn (string $id): bool => $id !== ''
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function group(Request $request): string|int|bool
|
||||||
|
{
|
||||||
|
if (! $request->has('group')) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$group = UsenetGroup::isValidGroup($request->input('group'));
|
||||||
|
|
||||||
|
return $group === false ? -1 : $group;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function limit(Request $request): int
|
||||||
|
{
|
||||||
|
return $request->has('limit') && is_numeric($request->input('limit'))
|
||||||
|
? (int) $request->input('limit')
|
||||||
|
: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function offset(Request $request): int
|
||||||
|
{
|
||||||
|
return $request->has('offset') && is_numeric($request->input('offset'))
|
||||||
|
? (int) $request->input('offset')
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function minimumSize(Request $request): int
|
||||||
|
{
|
||||||
|
return $request->has('minsize') && $request->input('minsize') > 0
|
||||||
|
? (int) $request->input('minsize')
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maximumAge(Request $request): int
|
||||||
|
{
|
||||||
|
return (int) $request->input('maxage', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sort(Request $request): string
|
||||||
|
{
|
||||||
|
return strtolower(trim((string) $request->input('sort', 'posted_desc')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasValidSort(Request $request): bool
|
||||||
|
{
|
||||||
|
return ! $request->has('sort')
|
||||||
|
|| preg_match('/^(cat|name|size|files|stats|posted)_(asc|desc)$/', $this->sort($request)) === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Api;
|
||||||
|
|
||||||
|
use App\Events\UserAccessedApi;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserRequest;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class ApiUsageService
|
||||||
|
{
|
||||||
|
public function statistics(int $userId): object
|
||||||
|
{
|
||||||
|
return Cache::remember('api_user_stats:'.$userId, 60, function () use ($userId): object {
|
||||||
|
$oneDayAgo = now()->subDay()->toDateTimeString();
|
||||||
|
|
||||||
|
return DB::selectOne('SELECT
|
||||||
|
(SELECT COUNT(*) FROM user_requests WHERE users_id = ? AND timestamp > ?) as api_count,
|
||||||
|
(SELECT COUNT(*) FROM user_downloads WHERE users_id = ? AND timestamp > ?) as grab_count,
|
||||||
|
(SELECT MIN(timestamp) FROM user_requests WHERE users_id = ? AND timestamp > ?) as api_time,
|
||||||
|
(SELECT MIN(timestamp) FROM user_downloads WHERE users_id = ? AND timestamp > ?) as grab_time',
|
||||||
|
[$userId, $oneDayAgo, $userId, $oneDayAgo, $userId, $oneDayAgo, $userId, $oneDayAgo]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function record(User $user, Request $request): void
|
||||||
|
{
|
||||||
|
UserRequest::addApiRequest($user->id, $request->getRequestUri());
|
||||||
|
event(new UserAccessedApi($user, $request->ip()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Api;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
final class ApiUserResolver
|
||||||
|
{
|
||||||
|
public function v1(string $token): ?User
|
||||||
|
{
|
||||||
|
return Cache::remember('api_user:'.md5($token), 300, static function () use ($token): ?User {
|
||||||
|
$user = User::findVerifiedByApiToken($token);
|
||||||
|
$user?->load('role');
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function v2(string $token): ?User
|
||||||
|
{
|
||||||
|
return Cache::remember('api_user:'.md5($token), 300, static fn (): ?User => User::query()
|
||||||
|
->whereApiToken($token)
|
||||||
|
->with('role')
|
||||||
|
->first());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\XML_Response;
|
||||||
|
use App\Services\Api\ApiCapabilitiesService;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
final readonly class ApiV1Presenter
|
||||||
|
{
|
||||||
|
public function __construct(private ApiCapabilitiesService $capabilities) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
* @param array<string, string> $headers
|
||||||
|
*/
|
||||||
|
public function output(
|
||||||
|
mixed $data,
|
||||||
|
array $parameters,
|
||||||
|
bool $xml,
|
||||||
|
int $offset,
|
||||||
|
string $type = '',
|
||||||
|
array $headers = []
|
||||||
|
): Response {
|
||||||
|
$response = new XML_Response([
|
||||||
|
'Parameters' => $parameters,
|
||||||
|
'Data' => $data,
|
||||||
|
'Server' => $this->capabilities->v1($type === 'caps'),
|
||||||
|
'Offset' => $offset,
|
||||||
|
'Type' => $type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($xml) {
|
||||||
|
$body = $response->returnXML();
|
||||||
|
$contentType = 'text/xml';
|
||||||
|
} else {
|
||||||
|
$array = $response->returnArray();
|
||||||
|
if ($array === false) {
|
||||||
|
return showApiError(201);
|
||||||
|
}
|
||||||
|
$body = json_encode($array, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||||
|
$contentType = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($body === false) {
|
||||||
|
return showApiError(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response($body, 200, array_merge([
|
||||||
|
'Content-type' => $contentType,
|
||||||
|
'Content-Length' => (string) strlen($body),
|
||||||
|
], $headers));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Api\V2;
|
||||||
|
|
||||||
|
use App\Data\Api\DetailsData;
|
||||||
|
use App\Data\Api\ReleaseData;
|
||||||
|
use App\Models\Release;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
final class ApiV2Presenter
|
||||||
|
{
|
||||||
|
private const JSON_ENCODING_OPTIONS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function json(array $data, int $status = 200): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($data, $status, [], self::JSON_ENCODING_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<int, Release|\stdClass> $rows
|
||||||
|
* @param array<string, mixed> $usage
|
||||||
|
*/
|
||||||
|
public function search(iterable $rows, User $user, array $usage): JsonResponse
|
||||||
|
{
|
||||||
|
$rows = is_array($rows) ? $rows : iterator_to_array($rows, false);
|
||||||
|
$results = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$results[] = ReleaseData::toArrayFromRelease($row, $user, url('/details').'/', url('/getnzb'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json(array_merge(
|
||||||
|
['Total' => (int) ($rows[0]->_totalrows ?? 0)],
|
||||||
|
$usage,
|
||||||
|
['results' => $results],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function details(Release|\stdClass $release, User $user): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->json(DetailsData::toArrayFromRelease(
|
||||||
|
$release,
|
||||||
|
$user,
|
||||||
|
url('/details').'/',
|
||||||
|
url('/getnzb')
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function usage(User $user, object $statistics): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'apiCurrent' => (int) ($statistics->api_count ?? 0),
|
||||||
|
'apiMax' => $user->role->apirequests,
|
||||||
|
'grabCurrent' => (int) ($statistics->grab_count ?? 0),
|
||||||
|
'grabMax' => $user->role->downloadrequests,
|
||||||
|
'apiOldestTime' => $statistics->api_time ? Carbon::parse($statistics->api_time)->toRfc2822String() : '',
|
||||||
|
'grabOldestTime' => $statistics->grab_time ? Carbon::parse($statistics->grab_time)->toRfc2822String() : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-23
@@ -65,32 +65,27 @@ JSON sorting response snippet (`sort=size_desc`):
|
|||||||
```http
|
```http
|
||||||
HTTP/1.1 200 OK
|
HTTP/1.1 200 OK
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
X-Total-Count: 2
|
{
|
||||||
X-Api-Current: 0
|
```
|
||||||
X-Api-Max: 100
|
|
||||||
X-Grab-Current: 0
|
Releases are ordered largest-to-smallest because `sort=size_desc`.
|
||||||
X-Grab-Max: 100
|
|
||||||
X-Api-Oldest-Time:
|
> The legacy `Results` (capital R) Fractal field has been replaced by the
|
||||||
```
|
> lower-case `results` field. Search responses retain pagination and quota
|
||||||
|
> metadata in the top-level JSON object. Movie/TV-only fields (`tvdbid`,
|
||||||
Releases are ordered largest-to-smallest because `sort=size_desc`.
|
> `imdbid`, `season`, …) are omitted when they do not apply to a release.
|
||||||
|
|
||||||
> **Breaking change (April 2026):** the legacy `Results` (capital R) JSON
|
## Endpoints
|
||||||
> envelope — previously produced by `spatie/laravel-fractal` — has been
|
|
||||||
|
## 1) Capabilities
|
||||||
|
|
||||||
- `GET /capabilities`
|
- `GET /capabilities`
|
||||||
- Auth: none
|
- Auth: none
|
||||||
|
|
||||||
> `X-Grab-Oldest-Time`). Movie/TV-only fields (`tvdbid`, `imdbid`, `season`, …)
|
Returns:
|
||||||
> are omitted from each release object when not applicable to its category,
|
|
||||||
> instead of being emitted as `null`. TypeScript definitions are auto-generated
|
- `server`
|
||||||
> to `resources/js/types/generated.d.ts`.
|
- `limits`
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
## 1) Capabilities
|
|
||||||
|
|
||||||
- `GET /capabilities`
|
|
||||||
- `searching`
|
- `searching`
|
||||||
- `registration`
|
- `registration`
|
||||||
- `categories`
|
- `categories`
|
||||||
@@ -213,7 +208,9 @@ Returns a single release object (not envelope). Download field name is `link` (n
|
|||||||
|
|
||||||
The following are intentionally not part of v2 JSON API:
|
The following are intentionally not part of v2 JSON API:
|
||||||
|
|
||||||
## Unsupported in v2
|
- `register`
|
||||||
|
- `user`
|
||||||
|
- `comments`
|
||||||
- `commentadd`
|
- `commentadd`
|
||||||
- `cartadd`
|
- `cartadd`
|
||||||
- `cartdel`
|
- `cartdel`
|
||||||
|
|||||||
@@ -276,42 +276,6 @@ parameters:
|
|||||||
count: 1
|
count: 1
|
||||||
path: app/Http/Controllers/Admin/DeletedUsersController.php
|
path: app/Http/Controllers/Admin/DeletedUsersController.php
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
|
|
||||||
identifier: property.notFound
|
|
||||||
count: 1
|
|
||||||
path: app/Http/Controllers/Api/ApiController.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$is_disabled\.$#'
|
|
||||||
identifier: property.notFound
|
|
||||||
count: 1
|
|
||||||
path: app/Http/Controllers/Api/ApiController.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$role\.$#'
|
|
||||||
identifier: property.notFound
|
|
||||||
count: 2
|
|
||||||
path: app/Http/Controllers/Api/ApiController.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:hasRole\(\)\.$#'
|
|
||||||
identifier: method.notFound
|
|
||||||
count: 1
|
|
||||||
path: app/Http/Controllers/Api/ApiController.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Method App\\Http\\Controllers\\Api\\ApiController\:\:categoryID\(\) should return array\<string, mixed\> but returns array\<int, int\>\.$#'
|
|
||||||
identifier: return.type
|
|
||||||
count: 3
|
|
||||||
path: app/Http/Controllers/Api/ApiController.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Method App\\Http\\Controllers\\Api\\ApiController\:\:categoryID\(\) should return array\<string, mixed\> but returns list\<string\>\.$#'
|
|
||||||
identifier: return.type
|
|
||||||
count: 1
|
|
||||||
path: app/Http/Controllers/Api/ApiController.php
|
|
||||||
|
|
||||||
-
|
-
|
||||||
message: '#^Call to function is_array\(\) with bool\|int\|string will always evaluate to false\.$#'
|
message: '#^Call to function is_array\(\) with bool\|int\|string will always evaluate to false\.$#'
|
||||||
identifier: function.impossibleType
|
identifier: function.impossibleType
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class ApiRequestMatrixTest extends TestCase
|
|||||||
$releaseBrowseService = Mockery::mock(ReleaseBrowseService::class);
|
$releaseBrowseService = Mockery::mock(ReleaseBrowseService::class);
|
||||||
$releaseBrowseService->shouldNotReceive('getBrowseRangeForApi');
|
$releaseBrowseService->shouldNotReceive('getBrowseRangeForApi');
|
||||||
|
|
||||||
$controller = new ApiV2Controller(app(ApiController::class), $releaseSearchService, $releaseBrowseService);
|
$controller = new ApiV2Controller($releaseSearchService, $releaseBrowseService);
|
||||||
|
|
||||||
$firstResponse = $controller->apiSearch($request);
|
$firstResponse = $controller->apiSearch($request);
|
||||||
$secondResponse = $controller->apiSearch($request);
|
$secondResponse = $controller->apiSearch($request);
|
||||||
@@ -178,7 +178,7 @@ class ApiRequestMatrixTest extends TestCase
|
|||||||
$releaseBrowseService = Mockery::mock(ReleaseBrowseService::class);
|
$releaseBrowseService = Mockery::mock(ReleaseBrowseService::class);
|
||||||
$releaseBrowseService->shouldNotReceive('getBrowseRangeForApi');
|
$releaseBrowseService->shouldNotReceive('getBrowseRangeForApi');
|
||||||
|
|
||||||
$controller = new ApiV2Controller(app(ApiController::class), $releaseSearchService, $releaseBrowseService);
|
$controller = new ApiV2Controller($releaseSearchService, $releaseBrowseService);
|
||||||
$response = $controller->apiSearch($request);
|
$response = $controller->apiSearch($request);
|
||||||
|
|
||||||
$content = $response->getContent();
|
$content = $response->getContent();
|
||||||
@@ -493,7 +493,7 @@ class ApiRequestMatrixTest extends TestCase
|
|||||||
)
|
)
|
||||||
->andReturn(collect());
|
->andReturn(collect());
|
||||||
|
|
||||||
$controller = new ApiV2Controller(app(ApiController::class), $releaseSearchService, $releaseBrowseService);
|
$controller = new ApiV2Controller($releaseSearchService, $releaseBrowseService);
|
||||||
|
|
||||||
$response = $controller->audio($request);
|
$response = $controller->audio($request);
|
||||||
|
|
||||||
@@ -527,7 +527,7 @@ class ApiRequestMatrixTest extends TestCase
|
|||||||
)
|
)
|
||||||
->andReturn(collect());
|
->andReturn(collect());
|
||||||
|
|
||||||
$controller = new ApiV2Controller(app(ApiController::class), $releaseSearchService, $releaseBrowseService);
|
$controller = new ApiV2Controller($releaseSearchService, $releaseBrowseService);
|
||||||
|
|
||||||
$response = $controller->books($request);
|
$response = $controller->books($request);
|
||||||
|
|
||||||
@@ -564,7 +564,7 @@ class ApiRequestMatrixTest extends TestCase
|
|||||||
)
|
)
|
||||||
->andReturn(collect());
|
->andReturn(collect());
|
||||||
|
|
||||||
$controller = new ApiV2Controller(app(ApiController::class), $releaseSearchService, $releaseBrowseService);
|
$controller = new ApiV2Controller($releaseSearchService, $releaseBrowseService);
|
||||||
$response = $controller->{$expectation['method']}($request);
|
$response = $controller->{$expectation['method']}($request);
|
||||||
|
|
||||||
$this->assertSame(200, $response->getStatusCode());
|
$this->assertSame(200, $response->getStatusCode());
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Services\Api\ApiQueryParameters;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ApiQueryParametersTest extends TestCase
|
||||||
|
{
|
||||||
|
private ApiQueryParameters $parameters;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->parameters = new ApiQueryParameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_common_defaults_match_both_api_versions(): void
|
||||||
|
{
|
||||||
|
$request = Request::create('/', 'GET');
|
||||||
|
|
||||||
|
self::assertSame([-1], $this->parameters->categories($request));
|
||||||
|
self::assertSame(100, $this->parameters->limit($request));
|
||||||
|
self::assertSame(0, $this->parameters->offset($request));
|
||||||
|
self::assertSame(0, $this->parameters->minimumSize($request));
|
||||||
|
self::assertSame(-1, $this->parameters->maximumAge($request));
|
||||||
|
self::assertSame('posted_desc', $this->parameters->sort($request));
|
||||||
|
self::assertTrue($this->parameters->hasValidSort($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_numeric_pagination_and_sort_are_normalized(): void
|
||||||
|
{
|
||||||
|
$request = Request::create('/', 'GET', [
|
||||||
|
'limit' => '25',
|
||||||
|
'offset' => '50',
|
||||||
|
'minsize' => '1024',
|
||||||
|
'maxage' => '7',
|
||||||
|
'sort' => ' NAME_ASC ',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(25, $this->parameters->limit($request));
|
||||||
|
self::assertSame(50, $this->parameters->offset($request));
|
||||||
|
self::assertSame(1024, $this->parameters->minimumSize($request));
|
||||||
|
self::assertSame(7, $this->parameters->maximumAge($request));
|
||||||
|
self::assertSame('name_asc', $this->parameters->sort($request));
|
||||||
|
self::assertTrue($this->parameters->hasValidSort($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_sort_is_reported_without_building_a_response(): void
|
||||||
|
{
|
||||||
|
$request = Request::create('/', 'GET', ['sort' => 'unexpected']);
|
||||||
|
|
||||||
|
self::assertFalse($this->parameters->hasValidSort($request));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user