Update API and APIv2 handling

This commit is contained in:
DariusIII
2026-07-14 12:32:04 +02:00
parent 7a70462ba4
commit 92b1c39613
12 changed files with 590 additions and 375 deletions
+43 -167
View File
@@ -8,15 +8,17 @@ use App\Events\UserAccessedApi;
use App\Http\Controllers\BasePageController;
use App\Http\Controllers\GetNzbController;
use App\Models\Category;
use App\Models\Genre;
use App\Models\Release;
use App\Models\ReleaseNfo;
use App\Models\Settings;
use App\Models\UsenetGroup;
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\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\ReleaseSearchService;
use App\Support\FilenameSanitizer;
@@ -31,7 +33,6 @@ 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\HeaderUtils;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -46,15 +47,35 @@ class ApiController extends BasePageController
protected ApiReleaseRowCache $releaseRowCache;
private ?ApiQueryParameters $queryParameters = null;
private ?ApiUsageService $usageService = null;
private ApiUserResolver $userResolver;
private ApiCapabilitiesService $capabilitiesService;
private ApiV1Presenter $presenter;
public function __construct(
ReleaseSearchService $releaseSearchService,
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();
$this->releaseSearchService = $releaseSearchService;
$this->releaseBrowseService = $releaseBrowseService;
$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');
// Cache user lookup for 5 minutes to avoid repeated DB hits (same pattern as API v2)
$userCacheKey = 'api_user:'.md5((string) $apiKey);
$res = Cache::remember($userCacheKey, 300, function () use ($apiKey) {
return User::verifiedApiTokenQuery($apiKey)
->with('role')
->first();
});
$res = $this->userResolver->v1((string) $apiKey);
if ($res === null) {
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 = [])
{
$this->type = $type;
$options = [
'Parameters' => $params,
'Data' => $data,
'Server' => $this->getForMenu(),
'Offset' => $offset,
'Type' => $type,
];
$xmlResponse = new XML_Response($options);
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));
return $this->presenter->output($data, $params, $xml, $offset, $type, $headers);
}
/**
@@ -811,72 +800,7 @@ class ApiController extends BasePageController
{
$includeCats = $this->type === 'caps';
// Cache the server info blob (without categories) for 10 minutes
$serverInfo = Cache::remember('api_v1_server_menu', 600, function () {
$serverroot = url('/');
return [
'server' => [
'title' => config('app.name'),
'strapline' => Settings::settingValue('strapline'),
'email' => config('mail.from.address'),
'meta' => Settings::settingValue('metakeywords'),
'url' => $serverroot,
'image' => $serverroot.'/assets/images/tmux_logo.png',
],
'limits' => [
'max' => 100,
'default' => 100,
],
'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;
return $this->capabilitiesService->v1($includeCats);
}
/**
@@ -901,39 +825,11 @@ class ApiController extends BasePageController
/**
* Verify cat parameter.
*
* @return array<string, mixed>
* @return array<int, string|int>
*/
public function categoryID(Request $request): array
{
$categoryID = [-1];
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;
return $this->parameters()->categories($request);
}
/**
@@ -943,15 +839,7 @@ class ApiController extends BasePageController
*/
public function group(Request $request): string|int|bool
{
$groupName = -1;
if ($request->has('group')) {
$group = UsenetGroup::isValidGroup($request->input('group'));
if ($group !== false) {
$groupName = $group;
}
}
return $groupName;
return $this->parameters()->group($request);
}
/**
@@ -959,12 +847,7 @@ class ApiController extends BasePageController
*/
public function limit(Request $request): int
{
$limit = 100;
if ($request->has('limit') && is_numeric($request->input('limit'))) {
$limit = (int) $request->input('limit');
}
return $limit;
return $this->parameters()->limit($request);
}
/**
@@ -972,12 +855,7 @@ class ApiController extends BasePageController
*/
public function offset(Request $request): int
{
$offset = 0;
if ($request->has('offset') && is_numeric($request->input('offset'))) {
$offset = (int) $request->input('offset');
}
return $offset;
return $this->parameters()->offset($request);
}
/**
@@ -1042,19 +920,17 @@ class ApiController extends BasePageController
*/
public function getCachedUserStats(int $userId): object
{
$cacheKey = 'api_user_stats:'.$userId;
return $this->usage()->statistics($userId);
}
return Cache::remember($cacheKey, 60, function () use ($userId) {
$oneDayAgo = now()->subDay()->toDateTimeString();
private function parameters(): ApiQueryParameters
{
return $this->queryParameters ?? new ApiQueryParameters;
}
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]);
});
private function usage(): ApiUsageService
{
return $this->usageService ?? new ApiUsageService;
}
public function addCoverURL(mixed &$releases, callable $getCoverURL): void
+54 -144
View File
@@ -4,22 +4,18 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Data\Api\CategoryData;
use App\Data\Api\DetailsData;
use App\Data\Api\ReleaseData;
use App\Events\UserAccessedApi;
use App\Http\Controllers\BasePageController;
use App\Http\Controllers\GetNzbController;
use App\Models\Category;
use App\Models\Genre;
use App\Models\Release;
use App\Models\RootCategory;
use App\Models\Settings;
use App\Models\UsenetGroup;
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\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\ReleaseSearchService;
use Illuminate\Contracts\Foundation\Application;
@@ -31,36 +27,49 @@ 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
{
private const JSON_ENCODING_OPTIONS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
private ApiController $api;
private ReleaseSearchService $releaseSearchService;
private ReleaseBrowseService $releaseBrowseService;
private ApiReleaseRowCache $releaseRowCache;
private ApiQueryParameters $queryParameters;
private ApiUsageService $usageService;
private ApiUserResolver $userResolver;
private ApiV2Presenter $presenter;
private ApiCapabilitiesService $capabilitiesService;
/**
* @var array<int, object>
*/
private array $resolvedUserStats = [];
public function __construct(
ApiController $api,
ReleaseSearchService $releaseSearchService,
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->releaseBrowseService = $releaseBrowseService;
$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');
$userCacheKey = 'api_user:'.md5((string) $apiToken);
$user = Cache::remember($userCacheKey, 300, function () use ($apiToken) {
return User::query()
->whereApiToken((string) $apiToken)
->with('role')
->first();
});
$user = $this->userResolver->v2((string) $apiToken);
if (! $user || ! $user->hasVerifiedEmail()) {
return apiJsonError(100);
@@ -125,13 +127,12 @@ class ApiV2Controller extends BasePageController
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
{
UserRequest::addApiRequest($user->id, $request->getRequestUri());
event(new UserAccessedApi($user, $request->ip()));
$this->usageService->record($user, $request);
}
/**
@@ -139,7 +140,7 @@ class ApiV2Controller extends BasePageController
*/
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
{
$rowsArray = is_array($rows) ? $rows : iterator_to_array($rows, false);
$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],
));
return $this->presenter->search($rows, $user, $this->buildUserStatsResponse($user));
}
private function parseMaxAge(Request $request): int|JsonResponse
@@ -206,69 +193,7 @@ class ApiV2Controller extends BasePageController
public function capabilities(): JsonResponse
{
// Cache the full capabilities response for 10 minutes
$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);
return $this->jsonResponse($this->capabilitiesService->v2());
}
/**
@@ -292,9 +217,9 @@ class ApiV2Controller extends BasePageController
if ($searchName === '' && ! imdb_id_is_valid($imdbId) && $tmdbId <= 0 && $traktId <= 0) {
return $this->jsonResponse(['error' => 'Specify id (query), imdbid, tmdbid, or traktid'], 400);
}
$offset = $this->api->offset($request);
$limit = $this->api->limit($request);
$categoryID = $this->api->categoryID($request);
$offset = $this->queryParameters->offset($request);
$limit = $this->queryParameters->limit($request);
$categoryID = $this->queryParameters->categories($request);
$maxAge = $this->parseMaxAge($request);
if (! is_int($maxAge)) {
return $maxAge;
@@ -352,9 +277,9 @@ class ApiV2Controller extends BasePageController
return $this->jsonResponse(['error' => 'Incorrect parameter (id must not be empty)'], 400);
}
$offset = $this->api->offset($request);
$limit = $this->api->limit($request);
$categoryID = $this->api->categoryID($request);
$offset = $this->queryParameters->offset($request);
$limit = $this->queryParameters->limit($request);
$categoryID = $this->queryParameters->categories($request);
$maxAge = $this->parseMaxAge($request);
if (! is_int($maxAge)) {
return $maxAge;
@@ -366,7 +291,7 @@ class ApiV2Controller extends BasePageController
$minSize = max(0, (int) $request->input('minsize', 0));
$catExclusions = User::getCachedCategoryExclusionById($user->id);
$groupName = $this->api->group($request);
$groupName = $this->queryParameters->group($request);
$searchName = (string) $request->input('id', '');
if ($searchName === '') {
@@ -429,9 +354,9 @@ class ApiV2Controller extends BasePageController
return $this->jsonResponse(['error' => 'Incorrect parameter (id must not be empty)'], 400);
}
$offset = $this->api->offset($request);
$limit = $this->api->limit($request);
$categoryID = $this->api->categoryID($request);
$offset = $this->queryParameters->offset($request);
$limit = $this->queryParameters->limit($request);
$categoryID = $this->queryParameters->categories($request);
$maxAge = $this->parseMaxAge($request);
if (! is_int($maxAge)) {
return $maxAge;
@@ -443,7 +368,7 @@ class ApiV2Controller extends BasePageController
$minSize = max(0, (int) $request->input('minsize', 0));
$catExclusions = User::getCachedCategoryExclusionById($user->id);
$groupName = $this->api->group($request);
$groupName = $this->queryParameters->group($request);
$searchName = (string) $request->input('id', '');
if ($searchName === '') {
@@ -509,9 +434,9 @@ class ApiV2Controller extends BasePageController
return $this->jsonResponse(['error' => 'Specify id (query), anidbid, or anilistid'], 400);
}
$offset = $this->api->offset($request);
$limit = $this->api->limit($request);
$categoryID = $this->api->categoryID($request);
$offset = $this->queryParameters->offset($request);
$limit = $this->queryParameters->limit($request);
$categoryID = $this->queryParameters->categories($request);
$maxAge = $this->parseMaxAge($request);
if (! is_int($maxAge)) {
return $maxAge;
@@ -561,7 +486,7 @@ class ApiV2Controller extends BasePageController
$this->recordApiRequest($user, $request);
$offset = $this->api->offset($request);
$offset = $this->queryParameters->offset($request);
$catExclusions = User::getCachedCategoryExclusionById($user->id);
$minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0;
$maxAge = $this->parseMaxAge($request);
@@ -572,12 +497,12 @@ class ApiV2Controller extends BasePageController
if (! is_string($sort)) {
return $sort;
}
$groupName = $this->api->group($request);
$groupName = $this->queryParameters->group($request);
if (is_array($groupName)) {
$groupName = $groupName[0] ?? -1;
}
$categoryID = $this->api->categoryID($request);
$limit = $this->api->limit($request);
$categoryID = $this->queryParameters->categories($request);
$limit = $this->queryParameters->limit($request);
$searchName = $request->input('id');
$relData = $this->releaseRowCache->remember('v2', 'search', [
@@ -634,16 +559,6 @@ class ApiV2Controller extends BasePageController
$catExclusions = User::getCachedCategoryExclusionById($user->id);
$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)) {
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);
}
$offset = $this->api->offset($request);
$limit = $this->api->limit($request);
$categoryID = $this->api->categoryID($request);
$offset = $this->queryParameters->offset($request);
$limit = $this->queryParameters->limit($request);
$categoryID = $this->queryParameters->categories($request);
$airDate = $airDate ?? '';
$searchName = $request->input('id') ?? '';
@@ -751,12 +666,7 @@ class ApiV2Controller extends BasePageController
return $this->jsonResponse(['error' => 'No such item'], 404);
}
return $this->jsonResponse(DetailsData::toArrayFromRelease(
$relData,
$user,
url('/details').'/',
url('/getnzb')
));
return $this->presenter->details($relData, $user);
}
private function hasTvSearchParameters(Request $request): bool
+129
View File
@@ -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',
];
}
}
+96
View File
@@ -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;
}
}
+36
View File
@@ -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()));
}
}
+29
View File
@@ -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());
}
}
+56
View File
@@ -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));
}
}
+65
View File
@@ -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() : '',
];
}
}