Update API handling

This commit is contained in:
DariusIII
2026-07-11 10:00:01 +02:00
parent b0c781421c
commit ccb79f13b9
18 changed files with 768 additions and 240 deletions
+24 -21
View File
@@ -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'),
);
}
+28 -20
View File
@@ -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
+178 -67
View File
@@ -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');
+170 -75
View File
@@ -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);
+2 -2
View File
@@ -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 = [])
+1 -1
View File
@@ -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)
);
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
*/
+12
View File
@@ -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>
*/
+1
View File
@@ -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));
}
}
+137
View File
@@ -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(
+1 -1
View File
@@ -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;
});
+75
View File
@@ -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