mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 00:01:21 +00:00
Update API handling
This commit is contained in:
@@ -42,41 +42,44 @@ final class DetailsData extends Data
|
||||
public Optional|int|string|null $tvmazeid = new Optional,
|
||||
) {}
|
||||
|
||||
public static function fromRelease(Release $release, User $user): self
|
||||
public static function fromRelease(Release|\stdClass $release, User $user): self
|
||||
{
|
||||
$categoriesId = (int) $release->categories_id;
|
||||
$get = static fn (string $key, mixed $default = null): mixed => $release->{$key} ?? $default;
|
||||
|
||||
$categoriesId = (int) $get('categories_id', 0);
|
||||
$guid = (string) $get('guid', '');
|
||||
$base = [
|
||||
'title' => (string) $release->searchname,
|
||||
'details' => url('/').'/details/'.$release->guid,
|
||||
'link' => url('/').'/getnzb?id='.$release->guid.'.nzb&r='.$user->api_token,
|
||||
'title' => (string) $get('searchname', ''),
|
||||
'details' => url('/').'/details/'.$guid,
|
||||
'link' => url('/').'/getnzb?id='.$guid.'.nzb&r='.$user->api_token,
|
||||
'category' => $categoriesId,
|
||||
'category_name' => $release->category_name ?? null,
|
||||
'added' => Carbon::parse($release->adddate)->toRssString(),
|
||||
'size' => $release->size,
|
||||
'files' => $release->totalpart,
|
||||
'grabs' => $release->grabs,
|
||||
'comments' => $release->comments,
|
||||
'password' => $release->passwordstatus,
|
||||
'usenetdate' => Carbon::parse($release->postdate)->toRssString(),
|
||||
'category_name' => $get('category_name'),
|
||||
'added' => Carbon::parse($get('adddate'))->toRssString(),
|
||||
'size' => $get('size'),
|
||||
'files' => $get('totalpart'),
|
||||
'grabs' => $get('grabs'),
|
||||
'comments' => $get('comments'),
|
||||
'password' => $get('passwordstatus'),
|
||||
'usenetdate' => Carbon::parse($get('postdate'))->toRssString(),
|
||||
];
|
||||
|
||||
if (in_array($categoriesId, Category::MOVIES_GROUP, true)) {
|
||||
return new self(
|
||||
...$base,
|
||||
imdbid: $release->imdbid,
|
||||
imdbid: $get('imdbid'),
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($categoriesId, Category::TV_GROUP, true)) {
|
||||
return new self(
|
||||
...$base,
|
||||
imdbid: $release->imdb, // @phpstan-ignore property.notFound
|
||||
tmdbid: $release->tmdb,
|
||||
traktid: $release->trakt,
|
||||
tvairdate: $release->firstaired,
|
||||
tvdbid: $release->tvdb,
|
||||
tvrageid: $release->tvrage,
|
||||
tvmazeid: $release->tvmaze,
|
||||
imdbid: $get('imdb'),
|
||||
tmdbid: $get('tmdb'),
|
||||
traktid: $get('trakt'),
|
||||
tvairdate: $get('firstaired'),
|
||||
tvdbid: $get('tvdb'),
|
||||
tvrageid: $get('tvrage'),
|
||||
tvmazeid: $get('tvmaze'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,16 @@ final class ReleaseData extends Data
|
||||
* Build a ReleaseData from an Eloquent {@see Release} or stdClass row.
|
||||
*/
|
||||
public static function fromRelease(Release|\stdClass $release, User $user): self
|
||||
{
|
||||
return new self(...self::toArrayFromRelease($release, $user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the API array directly for hot search response paths.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function toArrayFromRelease(Release|\stdClass $release, User $user): array
|
||||
{
|
||||
$get = static fn (string $key, mixed $default = null): mixed => $release->{$key} ?? $default;
|
||||
|
||||
@@ -77,31 +87,29 @@ final class ReleaseData extends Data
|
||||
];
|
||||
|
||||
if (in_array($categoriesId, Category::MOVIES_GROUP, true)) {
|
||||
return new self(
|
||||
...$base,
|
||||
imdbid: self::nullIfZero($get('imdbid')),
|
||||
tmdbid: self::nullIfZero($get('tmdbid')),
|
||||
traktid: self::nullIfZero($get('traktid')),
|
||||
);
|
||||
return $base + [
|
||||
'imdbid' => self::nullIfZero($get('imdbid')),
|
||||
'tmdbid' => self::nullIfZero($get('tmdbid')),
|
||||
'traktid' => self::nullIfZero($get('traktid')),
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array($categoriesId, Category::TV_GROUP, true)) {
|
||||
return new self(
|
||||
...$base,
|
||||
imdbid: self::nullIfZero($get('imdb')),
|
||||
tmdbid: self::nullIfZero($get('tmdb')),
|
||||
traktid: self::nullIfZero($get('trakt')),
|
||||
episode_title: $get('title'),
|
||||
season: $get('series'),
|
||||
episode: $get('episode'),
|
||||
tvairdate: $get('firstaired'),
|
||||
tvdbid: self::nullIfZero($get('tvdb')),
|
||||
tvrageid: self::nullIfZero($get('tvrage')),
|
||||
tvmazeid: self::nullIfZero($get('tvmaze')),
|
||||
);
|
||||
return $base + [
|
||||
'imdbid' => self::nullIfZero($get('imdb')),
|
||||
'tmdbid' => self::nullIfZero($get('tmdb')),
|
||||
'traktid' => self::nullIfZero($get('trakt')),
|
||||
'episode_title' => $get('title'),
|
||||
'season' => $get('series'),
|
||||
'episode' => $get('episode'),
|
||||
'tvairdate' => $get('firstaired'),
|
||||
'tvdbid' => self::nullIfZero($get('tvdb')),
|
||||
'tvrageid' => self::nullIfZero($get('tvrage')),
|
||||
'tvmazeid' => self::nullIfZero($get('tvmaze')),
|
||||
];
|
||||
}
|
||||
|
||||
return new self(...$base);
|
||||
return $base;
|
||||
}
|
||||
|
||||
private static function nullIfZero(mixed $value): mixed
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Models\Settings;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRequest;
|
||||
use App\Services\Api\ApiReleaseRowCache;
|
||||
use App\Services\RegistrationStatusService;
|
||||
use App\Services\Releases\ReleaseBrowseService;
|
||||
use App\Services\Releases\ReleaseSearchService;
|
||||
@@ -43,13 +44,17 @@ class ApiController extends BasePageController
|
||||
|
||||
protected ReleaseBrowseService $releaseBrowseService;
|
||||
|
||||
protected ApiReleaseRowCache $releaseRowCache;
|
||||
|
||||
public function __construct(
|
||||
ReleaseSearchService $releaseSearchService,
|
||||
ReleaseBrowseService $releaseBrowseService
|
||||
ReleaseBrowseService $releaseBrowseService,
|
||||
?ApiReleaseRowCache $releaseRowCache = null
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->releaseSearchService = $releaseSearchService;
|
||||
$this->releaseBrowseService = $releaseBrowseService;
|
||||
$this->releaseRowCache = $releaseRowCache ?? app(ApiReleaseRowCache::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +149,7 @@ class ApiController extends BasePageController
|
||||
|
||||
$uid = $res->id;
|
||||
// Use user ID directly instead of re-looking up by token
|
||||
$catExclusions = User::getCategoryExclusionById($uid);
|
||||
$catExclusions = User::getCachedCategoryExclusionById($uid);
|
||||
$maxRequests = $res->role->apirequests;
|
||||
$maxDownloads = $res->role->downloadrequests;
|
||||
|
||||
@@ -201,20 +206,33 @@ class ApiController extends BasePageController
|
||||
$categoryID = $this->categoryID($request);
|
||||
$limit = $this->limit($request);
|
||||
|
||||
if ($request->has('q')) {
|
||||
$relData = $this->releaseSearchService->apiSearch(
|
||||
$request->input('q'),
|
||||
$groupName,
|
||||
$offset,
|
||||
$limit,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$categoryID,
|
||||
$minSize,
|
||||
$sort
|
||||
);
|
||||
} else {
|
||||
$relData = $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
$searchName = $request->input('q');
|
||||
$relData = $this->releaseRowCache->remember('v1', 'search', [
|
||||
'q' => $searchName,
|
||||
'group' => $groupName,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'min_size' => $minSize,
|
||||
'excluded' => $catExclusions,
|
||||
], function () use ($request, $searchName, $groupName, $offset, $limit, $maxAge, $catExclusions, $categoryID, $minSize, $sort) {
|
||||
if ($request->has('q')) {
|
||||
return $this->releaseSearchService->apiSearch(
|
||||
$searchName,
|
||||
$groupName,
|
||||
$offset,
|
||||
$limit,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$categoryID,
|
||||
$minSize,
|
||||
$sort
|
||||
);
|
||||
}
|
||||
|
||||
return $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
1,
|
||||
$categoryID,
|
||||
$offset,
|
||||
@@ -225,7 +243,7 @@ class ApiController extends BasePageController
|
||||
$groupName,
|
||||
$minSize
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return $this->output($relData, $params, $outputXML, $offset, 'api');
|
||||
// Search tv releases.
|
||||
@@ -253,7 +271,16 @@ class ApiController extends BasePageController
|
||||
$categoryID = Category::TV_GROUP;
|
||||
}
|
||||
|
||||
$relData = $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
$relData = $this->releaseRowCache->remember('v1', 'tv', [
|
||||
'has_search' => false,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'min_size' => $minSize,
|
||||
'excluded' => $catExclusions,
|
||||
], fn () => $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
1,
|
||||
$categoryID,
|
||||
$offset,
|
||||
@@ -263,7 +290,7 @@ class ApiController extends BasePageController
|
||||
$catExclusions,
|
||||
-1,
|
||||
$minSize
|
||||
);
|
||||
));
|
||||
|
||||
return $this->output($relData, $params, $outputXML, $offset, 'api');
|
||||
}
|
||||
@@ -288,20 +315,36 @@ class ApiController extends BasePageController
|
||||
$airDate = str_replace('/', '-', $year[0].'-'.$episode);
|
||||
}
|
||||
|
||||
$relData = $this->releaseSearchService->tvSearch(
|
||||
$airDate = $airDate ?? '';
|
||||
$searchName = $request->input('q') ?? '';
|
||||
$relData = $this->releaseRowCache->remember('v1', 'tv', [
|
||||
'has_search' => true,
|
||||
'site_ids' => $siteIdArr,
|
||||
'season' => $series,
|
||||
'episode' => $episode,
|
||||
'air_date' => $airDate,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'q' => $searchName,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'min_size' => $minSize,
|
||||
'excluded' => $catExclusions,
|
||||
'sort' => $sort,
|
||||
], fn () => $this->releaseSearchService->tvSearch(
|
||||
$siteIdArr,
|
||||
$series,
|
||||
$episode,
|
||||
$airDate ?? '',
|
||||
$this->offset($request),
|
||||
$airDate,
|
||||
$offset,
|
||||
$limit,
|
||||
$request->input('q') ?? '',
|
||||
$searchName,
|
||||
$categoryID,
|
||||
$maxAge,
|
||||
$minSize,
|
||||
$catExclusions,
|
||||
$sort
|
||||
);
|
||||
));
|
||||
|
||||
return $this->output($relData, $params, $outputXML, $offset, 'api');
|
||||
|
||||
@@ -330,7 +373,16 @@ class ApiController extends BasePageController
|
||||
$categoryID = Category::MOVIES_GROUP;
|
||||
}
|
||||
|
||||
$relData = $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
$relData = $this->releaseRowCache->remember('v1', 'movie', [
|
||||
'has_search' => false,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'min_size' => $minSize,
|
||||
'excluded' => $catExclusions,
|
||||
], fn () => $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
1,
|
||||
$categoryID,
|
||||
$offset,
|
||||
@@ -340,7 +392,7 @@ class ApiController extends BasePageController
|
||||
$catExclusions,
|
||||
-1,
|
||||
$minSize
|
||||
);
|
||||
));
|
||||
|
||||
return $this->output($relData, $params, $outputXML, $offset, 'api');
|
||||
}
|
||||
@@ -351,19 +403,33 @@ class ApiController extends BasePageController
|
||||
$tmdbId = $request->has('tmdbid') && $request->filled('tmdbid') ? (int) $request->input('tmdbid') : -1;
|
||||
$traktId = $request->has('traktid') && $request->filled('traktid') ? (int) $request->input('traktid') : -1;
|
||||
|
||||
$relData = $this->releaseSearchService->moviesSearch(
|
||||
$searchName = $request->input('q') ?? '';
|
||||
$relData = $this->releaseRowCache->remember('v1', 'movie', [
|
||||
'has_search' => true,
|
||||
'imdbid' => $imdbId,
|
||||
'tmdbid' => $tmdbId,
|
||||
'traktid' => $traktId,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'q' => $searchName,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'min_size' => $minSize,
|
||||
'excluded' => $catExclusions,
|
||||
], fn () => $this->releaseSearchService->moviesSearch(
|
||||
$imdbId,
|
||||
$tmdbId,
|
||||
$traktId,
|
||||
$this->offset($request),
|
||||
$offset,
|
||||
$limit,
|
||||
$request->input('q') ?? '',
|
||||
$searchName,
|
||||
$categoryID,
|
||||
$maxAge,
|
||||
$minSize,
|
||||
$catExclusions,
|
||||
$sort
|
||||
);
|
||||
));
|
||||
|
||||
$this->addCoverURL(
|
||||
$relData,
|
||||
@@ -391,25 +457,40 @@ class ApiController extends BasePageController
|
||||
$categoryID = $this->categoryID($request);
|
||||
$limit = $this->limit($request);
|
||||
|
||||
if (! $request->filled('q')) {
|
||||
$searchName = (string) $request->input('q', '');
|
||||
if ($searchName === '') {
|
||||
if ($categoryID === [-1]) {
|
||||
$categoryID = [Category::MUSIC_ROOT];
|
||||
}
|
||||
}
|
||||
|
||||
$relData = $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
1,
|
||||
$categoryID,
|
||||
$offset,
|
||||
$limit,
|
||||
$sort,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$groupName,
|
||||
$minSize
|
||||
);
|
||||
} else {
|
||||
$relData = $this->releaseSearchService->apiMusicSearch(
|
||||
(string) $request->input('q'),
|
||||
$relData = $this->releaseRowCache->remember('v1', 'music', [
|
||||
'q' => $searchName,
|
||||
'group' => $groupName,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'min_size' => $minSize,
|
||||
'excluded' => $catExclusions,
|
||||
], function () use ($searchName, $groupName, $offset, $limit, $sort, $maxAge, $catExclusions, $categoryID, $minSize) {
|
||||
if ($searchName === '') {
|
||||
return $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
1,
|
||||
$categoryID,
|
||||
$offset,
|
||||
$limit,
|
||||
$sort,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$groupName,
|
||||
$minSize
|
||||
);
|
||||
}
|
||||
|
||||
return $this->releaseSearchService->apiMusicSearch(
|
||||
$searchName,
|
||||
$groupName,
|
||||
$offset,
|
||||
$limit,
|
||||
@@ -419,7 +500,7 @@ class ApiController extends BasePageController
|
||||
$minSize,
|
||||
$sort
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return $this->output($relData, $params, $outputXML, $offset, 'api');
|
||||
|
||||
@@ -440,25 +521,40 @@ class ApiController extends BasePageController
|
||||
$categoryID = $this->categoryID($request);
|
||||
$limit = $this->limit($request);
|
||||
|
||||
if (! $request->filled('q')) {
|
||||
$searchName = (string) $request->input('q', '');
|
||||
if ($searchName === '') {
|
||||
if ($categoryID === [-1]) {
|
||||
$categoryID = [Category::BOOKS_ROOT];
|
||||
}
|
||||
}
|
||||
|
||||
$relData = $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
1,
|
||||
$categoryID,
|
||||
$offset,
|
||||
$limit,
|
||||
$sort,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$groupName,
|
||||
$minSize
|
||||
);
|
||||
} else {
|
||||
$relData = $this->releaseSearchService->apiBookSearch(
|
||||
(string) $request->input('q'),
|
||||
$relData = $this->releaseRowCache->remember('v1', 'book', [
|
||||
'q' => $searchName,
|
||||
'group' => $groupName,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'min_size' => $minSize,
|
||||
'excluded' => $catExclusions,
|
||||
], function () use ($searchName, $groupName, $offset, $limit, $sort, $maxAge, $catExclusions, $categoryID, $minSize) {
|
||||
if ($searchName === '') {
|
||||
return $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
1,
|
||||
$categoryID,
|
||||
$offset,
|
||||
$limit,
|
||||
$sort,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$groupName,
|
||||
$minSize
|
||||
);
|
||||
}
|
||||
|
||||
return $this->releaseSearchService->apiBookSearch(
|
||||
$searchName,
|
||||
$groupName,
|
||||
$offset,
|
||||
$limit,
|
||||
@@ -468,7 +564,7 @@ class ApiController extends BasePageController
|
||||
$minSize,
|
||||
$sort
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return $this->output($relData, $params, $outputXML, $offset, 'api');
|
||||
|
||||
@@ -488,17 +584,29 @@ class ApiController extends BasePageController
|
||||
return $sort;
|
||||
}
|
||||
UserRequest::addApiRequest($uid, $request->getRequestUri());
|
||||
$relData = $this->releaseSearchService->animeSearch(
|
||||
$limit = $this->limit($request);
|
||||
$categoryID = $this->categoryID($request);
|
||||
$relData = $this->releaseRowCache->remember('v1', 'anime', [
|
||||
'q' => $q,
|
||||
'anidbid' => $anidb,
|
||||
'anilistid' => $anilist,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'excluded' => $catExclusions,
|
||||
], fn () => $this->releaseSearchService->animeSearch(
|
||||
$anidb,
|
||||
$offset,
|
||||
$this->limit($request),
|
||||
$limit,
|
||||
$q,
|
||||
$this->categoryID($request),
|
||||
$categoryID,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$anilist,
|
||||
$sort
|
||||
);
|
||||
));
|
||||
|
||||
return $this->output($relData, $params, $outputXML, $offset, 'api');
|
||||
|
||||
@@ -525,7 +633,10 @@ class ApiController extends BasePageController
|
||||
}
|
||||
|
||||
UserRequest::addApiRequest($uid, $request->getRequestUri());
|
||||
$data = Release::getByGuidForApi($request->input('id'));
|
||||
$guid = $request->input('id');
|
||||
$data = $this->releaseRowCache->remember('v1', 'details', [
|
||||
'guid' => $guid,
|
||||
], fn () => Release::getByGuidForApi($guid));
|
||||
|
||||
return $this->output($data, $params, $outputXML, $offset, 'api');
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ use App\Models\Settings;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRequest;
|
||||
use App\Services\Api\ApiReleaseRowCache;
|
||||
use App\Services\RegistrationStatusService;
|
||||
use App\Services\Releases\ReleaseBrowseService;
|
||||
use App\Services\Releases\ReleaseSearchService;
|
||||
@@ -41,14 +42,23 @@ class ApiV2Controller extends BasePageController
|
||||
|
||||
private ReleaseBrowseService $releaseBrowseService;
|
||||
|
||||
private ApiReleaseRowCache $releaseRowCache;
|
||||
|
||||
/**
|
||||
* @var array<int, object>
|
||||
*/
|
||||
private array $resolvedUserStats = [];
|
||||
|
||||
public function __construct(
|
||||
ApiController $api,
|
||||
ReleaseSearchService $releaseSearchService,
|
||||
ReleaseBrowseService $releaseBrowseService
|
||||
ReleaseBrowseService $releaseBrowseService,
|
||||
?ApiReleaseRowCache $releaseRowCache = null
|
||||
) {
|
||||
$this->api = $api;
|
||||
$this->releaseSearchService = $releaseSearchService;
|
||||
$this->releaseBrowseService = $releaseBrowseService;
|
||||
$this->releaseRowCache = $releaseRowCache ?? app(ApiReleaseRowCache::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +91,7 @@ class ApiV2Controller extends BasePageController
|
||||
|
||||
$user->loadMissing('role');
|
||||
|
||||
$userStats = $this->api->getCachedUserStats($user->id);
|
||||
$userStats = $this->userStatsFor($user);
|
||||
$thisRequests = (int) ($userStats->api_count ?? 0);
|
||||
$maxRequests = (int) $user->role->apirequests;
|
||||
if ($thisRequests > $maxRequests) {
|
||||
@@ -99,7 +109,7 @@ class ApiV2Controller extends BasePageController
|
||||
*/
|
||||
private function buildUserStatsResponse(User $user): array
|
||||
{
|
||||
$userStats = $this->api->getCachedUserStats($user->id);
|
||||
$userStats = $this->userStatsFor($user);
|
||||
|
||||
return [
|
||||
'apiCurrent' => (int) ($userStats->api_count ?? 0),
|
||||
@@ -111,6 +121,11 @@ class ApiV2Controller extends BasePageController
|
||||
];
|
||||
}
|
||||
|
||||
private function userStatsFor(User $user): object
|
||||
{
|
||||
return $this->resolvedUserStats[$user->id] ??= $this->api->getCachedUserStats($user->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the standard search-results JSON response.
|
||||
*
|
||||
@@ -128,7 +143,7 @@ class ApiV2Controller extends BasePageController
|
||||
$total = (int) ($rowsArray[0]->_totalrows ?? 0);
|
||||
|
||||
$results = array_map(
|
||||
static fn ($row): array => ReleaseData::fromRelease($row, $user)->toArray(),
|
||||
static fn ($row): array => ReleaseData::toArrayFromRelease($row, $user),
|
||||
$rowsArray,
|
||||
);
|
||||
|
||||
@@ -266,16 +281,21 @@ class ApiV2Controller extends BasePageController
|
||||
if (! is_string($sort)) {
|
||||
return $sort;
|
||||
}
|
||||
$catExclusions = User::getCategoryExclusionById($user->id);
|
||||
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
||||
|
||||
// Create cache key for movie search results
|
||||
$searchCacheKey = 'api_movie_search:'.md5(serialize([
|
||||
$imdbId, $tmdbId, $traktId, $offset, $limit, $searchName, $sort,
|
||||
$categoryID, $maxAge, $minSize, $catExclusions,
|
||||
]));
|
||||
|
||||
// Cache search results for 10 minutes
|
||||
$relData = Cache::remember($searchCacheKey, 600, function () use (
|
||||
$relData = $this->releaseRowCache->remember('v2', 'movie', [
|
||||
'imdbid' => $imdbId,
|
||||
'tmdbid' => $tmdbId,
|
||||
'traktid' => $traktId,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'id' => $searchName,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'min_size' => $minSize,
|
||||
'excluded' => $catExclusions,
|
||||
], function () use (
|
||||
$imdbId, $tmdbId, $traktId, $offset, $limit, $searchName, $sort,
|
||||
$categoryID, $maxAge, $minSize, $catExclusions
|
||||
) {
|
||||
@@ -324,28 +344,43 @@ class ApiV2Controller extends BasePageController
|
||||
}
|
||||
|
||||
$minSize = max(0, (int) $request->input('minsize', 0));
|
||||
$catExclusions = User::getCategoryExclusionById($user->id);
|
||||
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
||||
$groupName = $this->api->group($request);
|
||||
$searchName = (string) $request->input('id', '');
|
||||
|
||||
if (! $request->filled('id')) {
|
||||
if ($searchName === '') {
|
||||
if ($categoryID === [-1]) {
|
||||
$categoryID = [Category::MUSIC_ROOT];
|
||||
}
|
||||
}
|
||||
|
||||
$relData = $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
1,
|
||||
$categoryID,
|
||||
$offset,
|
||||
$limit,
|
||||
$sort,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$groupName,
|
||||
$minSize
|
||||
);
|
||||
} else {
|
||||
$relData = $this->releaseSearchService->apiMusicSearch(
|
||||
(string) $request->input('id'),
|
||||
$relData = $this->releaseRowCache->remember('v2', 'audio', [
|
||||
'id' => $searchName,
|
||||
'group' => $groupName,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'min_size' => $minSize,
|
||||
'excluded' => $catExclusions,
|
||||
], function () use ($searchName, $groupName, $offset, $limit, $sort, $maxAge, $catExclusions, $categoryID, $minSize) {
|
||||
if ($searchName === '') {
|
||||
return $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
1,
|
||||
$categoryID,
|
||||
$offset,
|
||||
$limit,
|
||||
$sort,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$groupName,
|
||||
$minSize
|
||||
);
|
||||
}
|
||||
|
||||
return $this->releaseSearchService->apiMusicSearch(
|
||||
$searchName,
|
||||
$groupName,
|
||||
$offset,
|
||||
$limit,
|
||||
@@ -355,7 +390,7 @@ class ApiV2Controller extends BasePageController
|
||||
$minSize,
|
||||
$sort
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return $this->buildSearchResponse($relData, $user);
|
||||
}
|
||||
@@ -387,28 +422,43 @@ class ApiV2Controller extends BasePageController
|
||||
}
|
||||
|
||||
$minSize = max(0, (int) $request->input('minsize', 0));
|
||||
$catExclusions = User::getCategoryExclusionById($user->id);
|
||||
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
||||
$groupName = $this->api->group($request);
|
||||
$searchName = (string) $request->input('id', '');
|
||||
|
||||
if (! $request->filled('id')) {
|
||||
if ($searchName === '') {
|
||||
if ($categoryID === [-1]) {
|
||||
$categoryID = [Category::BOOKS_ROOT];
|
||||
}
|
||||
}
|
||||
|
||||
$relData = $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
1,
|
||||
$categoryID,
|
||||
$offset,
|
||||
$limit,
|
||||
$sort,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$groupName,
|
||||
$minSize
|
||||
);
|
||||
} else {
|
||||
$relData = $this->releaseSearchService->apiBookSearch(
|
||||
(string) $request->input('id'),
|
||||
$relData = $this->releaseRowCache->remember('v2', 'books', [
|
||||
'id' => $searchName,
|
||||
'group' => $groupName,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'min_size' => $minSize,
|
||||
'excluded' => $catExclusions,
|
||||
], function () use ($searchName, $groupName, $offset, $limit, $sort, $maxAge, $catExclusions, $categoryID, $minSize) {
|
||||
if ($searchName === '') {
|
||||
return $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
1,
|
||||
$categoryID,
|
||||
$offset,
|
||||
$limit,
|
||||
$sort,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$groupName,
|
||||
$minSize
|
||||
);
|
||||
}
|
||||
|
||||
return $this->releaseSearchService->apiBookSearch(
|
||||
$searchName,
|
||||
$groupName,
|
||||
$offset,
|
||||
$limit,
|
||||
@@ -418,7 +468,7 @@ class ApiV2Controller extends BasePageController
|
||||
$minSize,
|
||||
$sort
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return $this->buildSearchResponse($relData, $user);
|
||||
}
|
||||
@@ -452,9 +502,19 @@ class ApiV2Controller extends BasePageController
|
||||
return $sort;
|
||||
}
|
||||
|
||||
$catExclusions = User::getCategoryExclusionById($user->id);
|
||||
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
||||
|
||||
$relData = $this->releaseSearchService->animeSearch(
|
||||
$relData = $this->releaseRowCache->remember('v2', 'anime', [
|
||||
'id' => $q,
|
||||
'anidbid' => $anidb,
|
||||
'anilistid' => $anilist,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'excluded' => $catExclusions,
|
||||
], fn () => $this->releaseSearchService->animeSearch(
|
||||
$anidb,
|
||||
$offset,
|
||||
$limit,
|
||||
@@ -464,7 +524,7 @@ class ApiV2Controller extends BasePageController
|
||||
$catExclusions,
|
||||
$anilist,
|
||||
$sort
|
||||
);
|
||||
));
|
||||
|
||||
return $this->buildSearchResponse($relData, $user);
|
||||
}
|
||||
@@ -484,7 +544,7 @@ class ApiV2Controller extends BasePageController
|
||||
event(new UserAccessedApi($user, $request->ip()));
|
||||
|
||||
$offset = $this->api->offset($request);
|
||||
$catExclusions = User::getCategoryExclusionById($user->id);
|
||||
$catExclusions = User::getCachedCategoryExclusionById($user->id);
|
||||
$minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0;
|
||||
$maxAge = $this->parseMaxAge($request);
|
||||
if (! is_int($maxAge)) {
|
||||
@@ -501,20 +561,33 @@ class ApiV2Controller extends BasePageController
|
||||
$categoryID = $this->api->categoryID($request);
|
||||
$limit = $this->api->limit($request);
|
||||
|
||||
if ($request->has('id')) {
|
||||
$relData = $this->releaseSearchService->apiSearch(
|
||||
$request->input('id'),
|
||||
$groupName,
|
||||
$offset,
|
||||
$limit,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$categoryID,
|
||||
$minSize,
|
||||
$sort
|
||||
);
|
||||
} else {
|
||||
$relData = $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
$searchName = $request->input('id');
|
||||
$relData = $this->releaseRowCache->remember('v2', 'search', [
|
||||
'id' => $searchName,
|
||||
'group' => $groupName,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'sort' => $sort,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'min_size' => $minSize,
|
||||
'excluded' => $catExclusions,
|
||||
], function () use ($request, $searchName, $groupName, $offset, $limit, $maxAge, $catExclusions, $categoryID, $minSize, $sort) {
|
||||
if ($request->has('id')) {
|
||||
return $this->releaseSearchService->apiSearch(
|
||||
$searchName,
|
||||
$groupName,
|
||||
$offset,
|
||||
$limit,
|
||||
$maxAge,
|
||||
$catExclusions,
|
||||
$categoryID,
|
||||
$minSize,
|
||||
$sort
|
||||
);
|
||||
}
|
||||
|
||||
return $this->releaseBrowseService->getBrowseRangeForApi(
|
||||
1,
|
||||
$categoryID,
|
||||
$offset,
|
||||
@@ -525,7 +598,7 @@ class ApiV2Controller extends BasePageController
|
||||
$groupName,
|
||||
$minSize
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return $this->buildSearchResponse($relData, $user);
|
||||
}
|
||||
@@ -541,7 +614,7 @@ class ApiV2Controller extends BasePageController
|
||||
return $user;
|
||||
}
|
||||
|
||||
$catExclusions = User::getCategoryExclusionById($user->id);
|
||||
$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');
|
||||
@@ -586,20 +659,39 @@ class ApiV2Controller extends BasePageController
|
||||
$airDate = str_replace('/', '-', $year[0].'-'.$episode);
|
||||
}
|
||||
|
||||
$relData = $this->releaseSearchService->apiTvSearch(
|
||||
$offset = $this->api->offset($request);
|
||||
$limit = $this->api->limit($request);
|
||||
$categoryID = $this->api->categoryID($request);
|
||||
$airDate = $airDate ?? '';
|
||||
$searchName = $request->input('id') ?? '';
|
||||
|
||||
$relData = $this->releaseRowCache->remember('v2', 'tv', [
|
||||
'site_ids' => $siteIdArr,
|
||||
'season' => $series,
|
||||
'episode' => $episode,
|
||||
'air_date' => $airDate,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
'id' => $searchName,
|
||||
'category' => $categoryID,
|
||||
'max_age' => $maxAge,
|
||||
'min_size' => $minSize,
|
||||
'excluded' => $catExclusions,
|
||||
'sort' => $sort,
|
||||
], fn () => $this->releaseSearchService->apiTvSearch(
|
||||
$siteIdArr,
|
||||
$series,
|
||||
$episode,
|
||||
$airDate ?? '',
|
||||
$this->api->offset($request),
|
||||
$this->api->limit($request),
|
||||
$request->input('id') ?? '',
|
||||
$this->api->categoryID($request),
|
||||
$airDate,
|
||||
$offset,
|
||||
$limit,
|
||||
$searchName,
|
||||
$categoryID,
|
||||
$maxAge,
|
||||
$minSize,
|
||||
$catExclusions,
|
||||
$sort
|
||||
);
|
||||
));
|
||||
|
||||
return $this->buildSearchResponse($relData, $user);
|
||||
}
|
||||
@@ -635,7 +727,10 @@ class ApiV2Controller extends BasePageController
|
||||
|
||||
UserRequest::addApiRequest($user->id, $request->getRequestUri());
|
||||
event(new UserAccessedApi($user, $request->ip()));
|
||||
$relData = Release::getByGuidForApi($request->input('id'));
|
||||
$guid = $request->input('id');
|
||||
$relData = $this->releaseRowCache->remember('v2', 'details', [
|
||||
'guid' => $guid,
|
||||
], fn () => Release::getByGuidForApi($guid));
|
||||
|
||||
if ($relData === null) {
|
||||
return response()->json(['error' => 'No such item'], 404);
|
||||
|
||||
@@ -200,7 +200,7 @@ class RSS extends ApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
*/
|
||||
public function getShowsRss(int $limit, int $userID = 0, array $excludedCats = [], int $airDate = -1): mixed
|
||||
{
|
||||
@@ -230,7 +230,7 @@ class RSS extends ApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
* @return Release[]|Collection|mixed
|
||||
*/
|
||||
public function getMyMoviesRss(int $limit, int $userID = 0, array $excludedCats = [])
|
||||
|
||||
@@ -88,7 +88,7 @@ class BasePageController extends Controller
|
||||
|
||||
// Cache category exclusions per user (5 minutes)
|
||||
$this->userdata->categoryexclusions = $this->rememberWithCacheFallback(
|
||||
'user_category_exclusions_'.$userId,
|
||||
User::categoryExclusionCacheKey($userId),
|
||||
300,
|
||||
fn () => User::getCategoryExclusionById($userId)
|
||||
);
|
||||
|
||||
@@ -228,7 +228,7 @@ class ProfileController extends BasePageController
|
||||
$this->viewData = array_merge($this->viewData, [
|
||||
'error' => $errorStr,
|
||||
'user' => $this->userdata,
|
||||
'userexccat' => User::getCategoryExclusionById($userid),
|
||||
'userexccat' => User::getCachedCategoryExclusionById($userid),
|
||||
'userExcludedCategories' => $this->userdata->excludedCategories()->pluck('categories_id')->toArray(),
|
||||
'success_2fa' => $success_2fa,
|
||||
'error_2fa' => $error_2fa,
|
||||
|
||||
@@ -40,7 +40,7 @@ class RssController extends BasePageController
|
||||
$outputXML = ! ($request->has('o') && $request->input('o') === 'json');
|
||||
$userNum = $request->has('num') && is_numeric($request->input('num')) ? abs((int) $request->input('num')) : 0;
|
||||
|
||||
$relData = $this->rss->getMyMoviesRss($userNum, $user['user_id'], User::getCategoryExclusionById($user['user_id']));
|
||||
$relData = $this->rss->getMyMoviesRss($userNum, $user['user_id'], User::getCachedCategoryExclusionById($user['user_id']));
|
||||
|
||||
return $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss');
|
||||
}
|
||||
@@ -61,7 +61,7 @@ class RssController extends BasePageController
|
||||
$userNum = $request->has('num') && is_numeric($request->input('num')) ? abs((int) $request->input('num')) : 0;
|
||||
$outputXML = ! ($request->has('o') && $request->input('o') === 'json');
|
||||
|
||||
$relData = $this->rss->getShowsRss($userNum, $user['user_id'], User::getCategoryExclusionById($user['user_id']), $userAirDate);
|
||||
$relData = $this->rss->getShowsRss($userNum, $user['user_id'], User::getCachedCategoryExclusionById($user['user_id']), $userAirDate);
|
||||
|
||||
return $this->rss->output($relData, $user['params'], $outputXML, 0, 'rss');
|
||||
}
|
||||
|
||||
+62
-25
@@ -550,35 +550,74 @@ class Release extends Model
|
||||
*/
|
||||
public static function getByGuidForApi(mixed $guid): mixed
|
||||
{
|
||||
$query = self::with([
|
||||
'group:id,name',
|
||||
'category:id,title,root_categories_id',
|
||||
'category.parent:id,title',
|
||||
'video:id,title,tvdb,trakt,tvrage,tvmaze',
|
||||
'episode:id,title,firstaired',
|
||||
]);
|
||||
$categoryNameExpression = DB::connection()->getDriverName() === 'sqlite'
|
||||
? "cp.title || ' > ' || c.title AS category_name"
|
||||
: "CONCAT(cp.title, ' > ', c.title) AS category_name";
|
||||
|
||||
$query = DB::table('releases')
|
||||
->select([
|
||||
'releases.id',
|
||||
'releases.searchname',
|
||||
'releases.guid',
|
||||
'releases.postdate',
|
||||
'releases.categories_id',
|
||||
'releases.size',
|
||||
'releases.totalpart',
|
||||
'releases.fromname',
|
||||
'releases.passwordstatus',
|
||||
'releases.grabs',
|
||||
'releases.comments',
|
||||
'releases.adddate',
|
||||
'releases.videos_id',
|
||||
'releases.tv_episodes_id',
|
||||
'releases.haspreview',
|
||||
'releases.nfostatus',
|
||||
'releases.movieinfo_id',
|
||||
'releases.musicinfo_id',
|
||||
'releases.consoleinfo_id',
|
||||
'c.root_categories_id as parentid',
|
||||
'cp.title as parent_category',
|
||||
'c.title as sub_category',
|
||||
DB::raw($categoryNameExpression),
|
||||
'g.name as group_name',
|
||||
'v.tvdb',
|
||||
'v.trakt',
|
||||
'v.tvrage',
|
||||
'v.tvmaze',
|
||||
'v.imdb',
|
||||
'v.tmdb',
|
||||
'm.imdbid',
|
||||
'm.tmdbid',
|
||||
'm.traktid',
|
||||
'tve.title',
|
||||
'tve.series',
|
||||
'tve.episode',
|
||||
'tve.firstaired',
|
||||
])
|
||||
->leftJoin('categories as c', 'c.id', '=', 'releases.categories_id')
|
||||
->leftJoin('root_categories as cp', 'cp.id', '=', 'c.root_categories_id')
|
||||
->leftJoin('usenet_groups as g', 'g.id', '=', 'releases.groups_id')
|
||||
->leftJoin('videos as v', function ($join): void {
|
||||
$join->on('releases.videos_id', '=', 'v.id')
|
||||
->where('releases.videos_id', '>', 0);
|
||||
})
|
||||
->leftJoin('tv_episodes as tve', function ($join): void {
|
||||
$join->on('releases.tv_episodes_id', '=', 'tve.id')
|
||||
->where('releases.tv_episodes_id', '>', 0);
|
||||
})
|
||||
->leftJoin('movieinfo as m', function ($join): void {
|
||||
$join->on('m.id', '=', 'releases.movieinfo_id')
|
||||
->where('releases.movieinfo_id', '>', 0);
|
||||
});
|
||||
|
||||
if (is_array($guid)) {
|
||||
$query->whereIn('guid', $guid);
|
||||
$query->whereIn('releases.guid', $guid);
|
||||
} else {
|
||||
$query->where('guid', $guid);
|
||||
$query->where('releases.guid', $guid);
|
||||
}
|
||||
|
||||
$releases = $query->get();
|
||||
|
||||
$releases->each(function ($release) {
|
||||
$release->group_name = $release->group->name ?? null;
|
||||
$release->tvdb = $release->video->tvdb ?? null;
|
||||
$release->trakt = $release->video->trakt ?? null;
|
||||
$release->tvrage = $release->video->tvrage ?? null;
|
||||
$release->tvmaze = $release->video->tvmaze ?? null;
|
||||
$release->title = $release->episode->title ?? null;
|
||||
$release->firstaired = $release->episode->firstaired ?? null;
|
||||
$release->parent_category = $release->category->parent->title ?? null;
|
||||
$release->sub_category = $release->category->title ?? null;
|
||||
$release->category_name = $release->parent_category.' > '.$release->sub_category;
|
||||
});
|
||||
|
||||
return is_array($guid) ? $releases : $releases->first();
|
||||
}
|
||||
|
||||
@@ -633,8 +672,6 @@ class Release extends Model
|
||||
|
||||
public static function checkGuidForApi(mixed $guid): bool
|
||||
{
|
||||
$check = self::whereGuid($guid)->first();
|
||||
|
||||
return $check !== null;
|
||||
return self::whereGuid($guid)->exists();
|
||||
}
|
||||
}
|
||||
|
||||
+32
-10
@@ -1679,10 +1679,27 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
|
||||
* This includes both permission-based exclusions (entire root categories)
|
||||
* and user-selected subcategory exclusions from user_excluded_categories table.
|
||||
*
|
||||
* @return array<int>
|
||||
* @return list<int>
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public static function getCachedCategoryExclusionById(int $userId): array
|
||||
{
|
||||
return Cache::remember(
|
||||
static::categoryExclusionCacheKey($userId),
|
||||
300,
|
||||
static fn (): array => static::getCategoryExclusionById($userId)
|
||||
);
|
||||
}
|
||||
|
||||
public static function categoryExclusionCacheKey(int $userId): string
|
||||
{
|
||||
return 'user_category_exclusions_'.$userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function getCategoryExclusionById(int $userId): array
|
||||
{
|
||||
$user = static::findOrFail($userId);
|
||||
@@ -1719,16 +1736,19 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
|
||||
->pluck('categories_id')
|
||||
->toArray();
|
||||
|
||||
// Filter out user exclusions that belong to already excluded root categories
|
||||
// to avoid duplicates
|
||||
$filteredUserExclusions = array_filter($userExclusions, function ($categoryId) use ($excludedRoots) {
|
||||
$category = Category::find($categoryId);
|
||||
|
||||
return $category && ! in_array($category->root_categories_id, $excludedRoots);
|
||||
});
|
||||
$filteredUserExclusions = $userExclusions === []
|
||||
? []
|
||||
: Category::query()
|
||||
->whereIn('id', $userExclusions)
|
||||
->when($excludedRoots !== [], fn (Builder $query): Builder => $query->whereNotIn('root_categories_id', $excludedRoots))
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
// Merge permission-based exclusions with user-selected subcategory exclusions
|
||||
return array_unique(array_merge($permissionExclusions, $filteredUserExclusions));
|
||||
return array_values(array_unique(array_map(
|
||||
static fn (mixed $categoryId): int => (int) $categoryId,
|
||||
array_merge($permissionExclusions, $filteredUserExclusions)
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1748,12 +1768,14 @@ final class User extends Authenticatable implements CanResetPasswordContract, Ha
|
||||
'categories_id' => (int) $categoryId,
|
||||
]);
|
||||
}
|
||||
|
||||
Cache::forget(static::categoryExclusionCacheKey($this->id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get excluded categories for API request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
* @return list<int>
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* App\Models\UserExcludedCategory.
|
||||
@@ -41,6 +42,17 @@ class UserExcludedCategory extends Model
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(static function (self $excludedCategory): void {
|
||||
Cache::forget(User::categoryExclusionCacheKey($excludedCategory->users_id));
|
||||
});
|
||||
|
||||
static::deleted(static function (self $excludedCategory): void {
|
||||
Cache::forget(User::categoryExclusionCacheKey($excludedCategory->users_id));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
|
||||
@@ -45,5 +45,6 @@ class UserApiCacheObserver
|
||||
Cache::forget('api_user:'.$tokenHash);
|
||||
Cache::forget('api_rate_limit_user:'.$tokenHash);
|
||||
Cache::forget('api_user_stats:'.$user->id);
|
||||
Cache::forget(User::categoryExclusionCacheKey($user->id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Api;
|
||||
|
||||
use App\Facades\Search;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ApiReleaseRowCache
|
||||
{
|
||||
private const CACHE_MISS = '__nntmux_api_release_row_cache_miss__';
|
||||
|
||||
private const NULL_SENTINEL = ['__nntmux_api_release_row_cache_null' => true];
|
||||
|
||||
private const BASE_TTL_SECONDS = 600;
|
||||
|
||||
private const MAX_TTL_JITTER_SECONDS = 60;
|
||||
|
||||
private const LOCK_TTL_SECONDS = 10;
|
||||
|
||||
private const WAIT_ATTEMPTS = 30;
|
||||
|
||||
private const WAIT_MICROSECONDS = 100_000;
|
||||
|
||||
/**
|
||||
* Cache release rows only. User-specific response fields should be built after this returns.
|
||||
*
|
||||
* @param array<string, mixed> $parameters
|
||||
* @param callable(): mixed $callback
|
||||
*/
|
||||
public function remember(string $apiVersion, string $scope, array $parameters, callable $callback): mixed
|
||||
{
|
||||
$cacheKey = $this->cacheKey($apiVersion, $scope, $parameters);
|
||||
|
||||
[$hit, $cached] = $this->getCachedValue($cacheKey);
|
||||
if ($hit) {
|
||||
$this->logCacheEvent('hit', $apiVersion, $scope);
|
||||
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$lockKey = $cacheKey.':lock';
|
||||
if (Cache::add($lockKey, true, self::LOCK_TTL_SECONDS)) {
|
||||
try {
|
||||
[$hit, $cached] = $this->getCachedValue($cacheKey);
|
||||
if ($hit) {
|
||||
$this->logCacheEvent('hit_after_lock', $apiVersion, $scope);
|
||||
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$this->logCacheEvent('miss_lock_owner', $apiVersion, $scope);
|
||||
$rows = $callback();
|
||||
$this->putCachedValue($cacheKey, $rows);
|
||||
|
||||
return $rows;
|
||||
} finally {
|
||||
Cache::forget($lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logCacheEvent('lock_wait', $apiVersion, $scope);
|
||||
for ($attempt = 0; $attempt < self::WAIT_ATTEMPTS; $attempt++) {
|
||||
usleep(self::WAIT_MICROSECONDS);
|
||||
|
||||
[$hit, $cached] = $this->getCachedValue($cacheKey);
|
||||
if ($hit) {
|
||||
$this->logCacheEvent('hit_after_wait', $apiVersion, $scope);
|
||||
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logCacheEvent('miss_after_wait', $apiVersion, $scope);
|
||||
$rows = $callback();
|
||||
$this->putCachedValue($cacheKey, $rows);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
private function cacheKey(string $apiVersion, string $scope, array $parameters): string
|
||||
{
|
||||
return 'api_release_rows:'.$apiVersion.':'.$scope.':'.md5(serialize([
|
||||
'driver' => Search::getCurrentDriver(),
|
||||
'release_version' => Cache::get('releases:cache_version', 1),
|
||||
'parameters' => $parameters,
|
||||
]));
|
||||
}
|
||||
|
||||
private function ttlSeconds(): int
|
||||
{
|
||||
return self::BASE_TTL_SECONDS + mt_rand(0, self::MAX_TTL_JITTER_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: bool, 1: mixed}
|
||||
*/
|
||||
private function getCachedValue(string $cacheKey): array
|
||||
{
|
||||
$cached = Cache::get($cacheKey, self::CACHE_MISS);
|
||||
if ($cached === self::CACHE_MISS) {
|
||||
return [false, null];
|
||||
}
|
||||
|
||||
if ($cached === self::NULL_SENTINEL) {
|
||||
return [true, null];
|
||||
}
|
||||
|
||||
return [true, $cached];
|
||||
}
|
||||
|
||||
private function putCachedValue(string $cacheKey, mixed $value): void
|
||||
{
|
||||
Cache::put(
|
||||
$cacheKey,
|
||||
$value === null ? self::NULL_SENTINEL : $value,
|
||||
$this->ttlSeconds()
|
||||
);
|
||||
}
|
||||
|
||||
private function logCacheEvent(string $event, string $apiVersion, string $scope): void
|
||||
{
|
||||
if (! config('app.debug')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::debug('API release row cache '.$event, [
|
||||
'version' => $apiVersion,
|
||||
'scope' => $scope,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class ReleaseBrowseService
|
||||
* Selects only columns needed by the browse/search Blade views.
|
||||
*
|
||||
* @param string|null $searchTerm Optional search term to filter by (uses search index)
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
* @return Collection|mixed
|
||||
*/
|
||||
public function getBrowseRange(mixed $page, mixed $cat, mixed $start, mixed $num, mixed $orderBy, int $maxAge = -1, array $excludedCats = [], int|string $groupName = -1, int $minSize = 0, ?string $searchTerm = null): mixed
|
||||
@@ -46,7 +46,7 @@ class ReleaseBrowseService
|
||||
* (movie IDs, TV episode info, external IDs).
|
||||
*
|
||||
* @param string|null $searchTerm Optional search term to filter by (uses search index)
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
* @return Collection|mixed
|
||||
*/
|
||||
public function getBrowseRangeForApi(mixed $page, mixed $cat, mixed $start, mixed $num, mixed $orderBy, int $maxAge = -1, array $excludedCats = [], int|string $groupName = -1, int $minSize = 0, ?string $searchTerm = null): mixed
|
||||
@@ -60,7 +60,7 @@ class ReleaseBrowseService
|
||||
*
|
||||
* @param string $purpose 'browse' for web views, 'api' for API responses
|
||||
* @param string|null $searchTerm Optional search term to filter by (uses search index)
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
* @return Collection|mixed
|
||||
*/
|
||||
private function executeBrowseQuery(string $purpose, mixed $page, mixed $cat, mixed $start, mixed $num, mixed $orderBy, int $maxAge = -1, array $excludedCats = [], int|string $groupName = -1, int $minSize = 0, ?string $searchTerm = null): mixed
|
||||
@@ -239,7 +239,7 @@ class ReleaseBrowseService
|
||||
* Uses sample-based counting and avoids JOINs whenever possible.
|
||||
*
|
||||
* @param array<string, mixed> $cat
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
*/
|
||||
public function getBrowseCount(array $cat, int $maxAge = -1, array $excludedCats = [], int|string $groupName = ''): int
|
||||
{
|
||||
@@ -331,7 +331,7 @@ class ReleaseBrowseService
|
||||
* API browse without text: filter and sort via search engine, hydrate rows from SQL.
|
||||
*
|
||||
* @param array<string, mixed> $cat
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
* @param array{0: string, 1: string} $orderBy
|
||||
*/
|
||||
private function executeApiBrowseViaSearchIndex(
|
||||
@@ -509,7 +509,7 @@ class ReleaseBrowseService
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
* @return Collection|mixed
|
||||
*/
|
||||
public function getShowsRange(mixed $userShows, mixed $offset, mixed $limit, mixed $orderBy, int $maxAge = -1, array $excludedCats = [])
|
||||
@@ -549,7 +549,7 @@ class ReleaseBrowseService
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
*/
|
||||
public function getShowsCount(mixed $userShows, int $maxAge = -1, array $excludedCats = []): int
|
||||
{
|
||||
|
||||
@@ -41,7 +41,7 @@ class ReleaseSearchService
|
||||
* Function for searching on the site (by subject, searchname or advanced).
|
||||
*
|
||||
* @param array<int|string, mixed> $cat Category IDs (list or associative)
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
* @param array<string, mixed> $orderBy
|
||||
* @param array<string, mixed> $searchArr
|
||||
* @return array|Collection|mixed
|
||||
@@ -211,7 +211,7 @@ class ReleaseSearchService
|
||||
* Search function for API.
|
||||
*
|
||||
* @param array<int|string, mixed> $cat Category IDs (list or associative)
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
* @return Collection|mixed
|
||||
*/
|
||||
public function apiSearch(mixed $searchName, mixed $groupName, int $offset = 0, int $limit = 1000, int $maxAge = -1, array $excludedCats = [], array $cat = [-1], int $minSize = 0, string $orderBy = 'posted_desc'): mixed
|
||||
@@ -592,7 +592,7 @@ class ReleaseSearchService
|
||||
* Search for TV shows via API.
|
||||
*
|
||||
* @param array<int|string, mixed> $cat Category IDs (list or associative)
|
||||
* @param array<string, mixed> $excludedCategories
|
||||
* @param array<int, int> $excludedCategories
|
||||
* @param array<string, mixed> $siteIdArr
|
||||
* @return array|Collection|\Illuminate\Support\Collection|mixed
|
||||
*/
|
||||
@@ -910,7 +910,7 @@ class ReleaseSearchService
|
||||
* Search TV Shows via APIv2.
|
||||
*
|
||||
* @param array<int|string, mixed> $cat Category IDs (list or associative)
|
||||
* @param array<string, mixed> $excludedCategories
|
||||
* @param array<int, int> $excludedCategories
|
||||
* @param array<string, mixed> $siteIdArr
|
||||
* @return Collection|mixed
|
||||
*/
|
||||
@@ -1082,7 +1082,7 @@ class ReleaseSearchService
|
||||
* Search anime releases.
|
||||
*
|
||||
* @param array<int|string, mixed> $cat Category IDs (list or associative)
|
||||
* @param array<string, mixed> $excludedCategories
|
||||
* @param array<int, int> $excludedCategories
|
||||
* @return Collection|mixed
|
||||
*/
|
||||
public function animeSearch(mixed $aniDbID, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, array $excludedCategories = [], int $anilistId = -1, string $orderBy = 'posted_desc'): mixed
|
||||
@@ -1175,7 +1175,7 @@ class ReleaseSearchService
|
||||
* Movies search through API and site.
|
||||
*
|
||||
* @param array<int|string, mixed> $cat Category IDs (list or associative)
|
||||
* @param array<string, mixed> $excludedCategories
|
||||
* @param array<int, int> $excludedCategories
|
||||
* @return Collection|mixed
|
||||
*/
|
||||
public function moviesSearch(string $imDbId = '', int $tmDbId = -1, int $traktId = -1, int $offset = 0, int $limit = 100, string $name = '', array $cat = [-1], int $maxAge = -1, int $minSize = 0, array $excludedCategories = [], string $orderBy = 'posted_desc'): mixed
|
||||
@@ -1343,7 +1343,7 @@ class ReleaseSearchService
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function searchSimilar(mixed $currentID, mixed $name, array $excludedCats = []): bool|array
|
||||
@@ -1702,7 +1702,7 @@ class ReleaseSearchService
|
||||
* Build WHERE clause for search query
|
||||
*
|
||||
* @param array<int|string, mixed> $cat Category IDs (list or associative)
|
||||
* @param array<string, mixed> $excludedCats
|
||||
* @param array<int, int> $excludedCats
|
||||
* @param array<string, mixed> $searchResult
|
||||
*/
|
||||
private function buildSearchWhereClause(
|
||||
|
||||
@@ -75,7 +75,7 @@ class GlobalDataComposer
|
||||
// User data with category exclusions (short cache per user)
|
||||
$userdata = $this->rememberWithCacheFallback('composer_user_'.$userId, self::CACHE_TTL, function () use ($userId) {
|
||||
$user = User::find($userId);
|
||||
$user->categoryexclusions = User::getCategoryExclusionById($userId);
|
||||
$user->categoryexclusions = User::getCachedCategoryExclusionById($userId);
|
||||
|
||||
return $user;
|
||||
});
|
||||
|
||||
@@ -118,6 +118,81 @@ class ApiRequestMatrixTest extends TestCase
|
||||
->assertJsonPath('error', 'Incorrect parameter (maxage must be numeric)');
|
||||
}
|
||||
|
||||
public function test_v2_search_reuses_cached_release_rows(): void
|
||||
{
|
||||
$token = (string) DB::table('users')->value('api_token');
|
||||
$request = Request::create('/api/v2/search', 'GET', [
|
||||
'api_token' => $token,
|
||||
'id' => 'ubuntu',
|
||||
]);
|
||||
|
||||
$releaseSearchService = Mockery::mock(ReleaseSearchService::class);
|
||||
$releaseSearchService->shouldReceive('apiSearch')
|
||||
->once()
|
||||
->with('ubuntu', -1, 0, 100, -1, [5030], [-1], 0, 'posted_desc')
|
||||
->andReturn(collect());
|
||||
|
||||
$releaseBrowseService = Mockery::mock(ReleaseBrowseService::class);
|
||||
$releaseBrowseService->shouldNotReceive('getBrowseRangeForApi');
|
||||
|
||||
$controller = new ApiV2Controller(app(ApiController::class), $releaseSearchService, $releaseBrowseService);
|
||||
|
||||
$firstResponse = $controller->apiSearch($request);
|
||||
$secondResponse = $controller->apiSearch($request);
|
||||
|
||||
$this->assertSame(200, $firstResponse->getStatusCode());
|
||||
$this->assertSame(200, $secondResponse->getStatusCode());
|
||||
$this->assertSame([], $firstResponse->getData(true)['results']);
|
||||
$this->assertSame([], $secondResponse->getData(true)['results']);
|
||||
}
|
||||
|
||||
public function test_v1_search_reuses_cached_release_rows(): void
|
||||
{
|
||||
$token = (string) DB::table('users')->value('api_token');
|
||||
$request = Request::create('/api/v1/api', 'GET', [
|
||||
't' => 'search',
|
||||
'apikey' => $token,
|
||||
'q' => 'ubuntu',
|
||||
]);
|
||||
|
||||
$releaseSearchService = Mockery::mock(ReleaseSearchService::class);
|
||||
$releaseSearchService->shouldReceive('apiSearch')
|
||||
->once()
|
||||
->with('ubuntu', -1, 0, 100, -1, [5030], [-1], 0, 'posted_desc')
|
||||
->andReturn(collect());
|
||||
|
||||
$releaseBrowseService = Mockery::mock(ReleaseBrowseService::class);
|
||||
$releaseBrowseService->shouldNotReceive('getBrowseRangeForApi');
|
||||
|
||||
$controller = new class($releaseSearchService, $releaseBrowseService) extends ApiController
|
||||
{
|
||||
/**
|
||||
* @var array{data:mixed,params:array<string,mixed>,xml:bool,offset:int,type:string}|null
|
||||
*/
|
||||
public ?array $capturedOutput = null;
|
||||
|
||||
public function output(mixed $data, array $params, bool $xml, int $offset, string $type = '', array $headers = [])
|
||||
{
|
||||
$this->capturedOutput = [
|
||||
'data' => $data,
|
||||
'params' => $params,
|
||||
'xml' => $xml,
|
||||
'offset' => $offset,
|
||||
'type' => $type,
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
$controller->api($request);
|
||||
$this->assertNotNull($controller->capturedOutput);
|
||||
$this->assertSame('api', $controller->capturedOutput['type']);
|
||||
$this->assertSame(1, DB::table('user_requests')->count());
|
||||
|
||||
$controller->api($request);
|
||||
$this->assertSame('api', $controller->capturedOutput['type']);
|
||||
$this->assertSame(2, DB::table('user_requests')->count());
|
||||
}
|
||||
|
||||
public function test_v1_movie_without_search_params_returns_recent_movie_feed(): void
|
||||
{
|
||||
$token = (string) DB::table('users')->value('api_token');
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Feature;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use App\Models\UserExcludedCategory;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
@@ -266,6 +267,32 @@ final class UserExcludedCategoryTest extends TestCase
|
||||
$this->assertContains(2070, $exclusions);
|
||||
}
|
||||
|
||||
public function test_cached_category_exclusions_are_invalidated_when_syncing(): void
|
||||
{
|
||||
$this->user->syncExcludedCategories([2070]);
|
||||
|
||||
$this->assertContains(2070, User::getCachedCategoryExclusionById($this->user->id));
|
||||
|
||||
$this->user->syncExcludedCategories([2040]);
|
||||
|
||||
$exclusions = User::getCachedCategoryExclusionById($this->user->id);
|
||||
|
||||
$this->assertNotContains(2070, $exclusions);
|
||||
$this->assertContains(2040, $exclusions);
|
||||
}
|
||||
|
||||
public function test_cached_category_exclusions_are_invalidated_when_rows_change_directly(): void
|
||||
{
|
||||
Cache::put(User::categoryExclusionCacheKey($this->user->id), [2070], 300);
|
||||
|
||||
UserExcludedCategory::create([
|
||||
'users_id' => $this->user->id,
|
||||
'categories_id' => 2040,
|
||||
]);
|
||||
|
||||
$this->assertNull(Cache::get(User::categoryExclusionCacheKey($this->user->id)));
|
||||
}
|
||||
|
||||
public function test_clearing_exclusions_works(): void
|
||||
{
|
||||
// First add exclusions
|
||||
|
||||
Reference in New Issue
Block a user