From ccb79f13b935d4d2ff88cc62766d5a8b4037c081 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Sat, 11 Jul 2026 10:00:01 +0200 Subject: [PATCH] Update API handling --- app/Data/Api/DetailsData.php | 45 ++-- app/Data/Api/ReleaseData.php | 48 ++-- app/Http/Controllers/Api/ApiController.php | 245 +++++++++++++----- app/Http/Controllers/Api/ApiV2Controller.php | 245 ++++++++++++------ app/Http/Controllers/Api/RSS.php | 4 +- app/Http/Controllers/BasePageController.php | 2 +- app/Http/Controllers/ProfileController.php | 2 +- app/Http/Controllers/RssController.php | 4 +- app/Models/Release.php | 87 +++++-- app/Models/User.php | 42 ++- app/Models/UserExcludedCategory.php | 12 + app/Observers/UserApiCacheObserver.php | 1 + app/Services/Api/ApiReleaseRowCache.php | 137 ++++++++++ .../Releases/ReleaseBrowseService.php | 14 +- .../Releases/ReleaseSearchService.php | 16 +- app/View/Composers/GlobalDataComposer.php | 2 +- tests/Feature/ApiRequestMatrixTest.php | 75 ++++++ tests/Feature/UserExcludedCategoryTest.php | 27 ++ 18 files changed, 768 insertions(+), 240 deletions(-) create mode 100644 app/Services/Api/ApiReleaseRowCache.php diff --git a/app/Data/Api/DetailsData.php b/app/Data/Api/DetailsData.php index 1f183f1a1..accd003fe 100644 --- a/app/Data/Api/DetailsData.php +++ b/app/Data/Api/DetailsData.php @@ -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'), ); } diff --git a/app/Data/Api/ReleaseData.php b/app/Data/Api/ReleaseData.php index 9dacb4caf..03c3350b7 100644 --- a/app/Data/Api/ReleaseData.php +++ b/app/Data/Api/ReleaseData.php @@ -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 + */ + 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 diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 1d8b4ff6a..7c55b0675 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -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'); diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index e00db7ee2..d7ede74cb 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -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 + */ + 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); diff --git a/app/Http/Controllers/Api/RSS.php b/app/Http/Controllers/Api/RSS.php index b123c05fa..af719af95 100644 --- a/app/Http/Controllers/Api/RSS.php +++ b/app/Http/Controllers/Api/RSS.php @@ -200,7 +200,7 @@ class RSS extends ApiController } /** - * @param array $excludedCats + * @param array $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 $excludedCats + * @param array $excludedCats * @return Release[]|Collection|mixed */ public function getMyMoviesRss(int $limit, int $userID = 0, array $excludedCats = []) diff --git a/app/Http/Controllers/BasePageController.php b/app/Http/Controllers/BasePageController.php index 9ad7a4989..c8ecbca48 100644 --- a/app/Http/Controllers/BasePageController.php +++ b/app/Http/Controllers/BasePageController.php @@ -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) ); diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index d16fdfe33..c121f66a3 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -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, diff --git a/app/Http/Controllers/RssController.php b/app/Http/Controllers/RssController.php index b6fd306f3..49d44552a 100644 --- a/app/Http/Controllers/RssController.php +++ b/app/Http/Controllers/RssController.php @@ -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'); } diff --git a/app/Models/Release.php b/app/Models/Release.php index 7b2d9bfe2..7fe3eeaed 100644 --- a/app/Models/Release.php +++ b/app/Models/Release.php @@ -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(); } } diff --git a/app/Models/User.php b/app/Models/User.php index dd6510417..97827aadd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 + * @return list * * @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 + */ 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 + * @return list * * @throws \Exception */ diff --git a/app/Models/UserExcludedCategory.php b/app/Models/UserExcludedCategory.php index 311f814aa..42b384b6c 100644 --- a/app/Models/UserExcludedCategory.php +++ b/app/Models/UserExcludedCategory.php @@ -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 */ diff --git a/app/Observers/UserApiCacheObserver.php b/app/Observers/UserApiCacheObserver.php index 403a66295..0cfe4dfbe 100644 --- a/app/Observers/UserApiCacheObserver.php +++ b/app/Observers/UserApiCacheObserver.php @@ -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)); } } diff --git a/app/Services/Api/ApiReleaseRowCache.php b/app/Services/Api/ApiReleaseRowCache.php new file mode 100644 index 000000000..70ce7cc61 --- /dev/null +++ b/app/Services/Api/ApiReleaseRowCache.php @@ -0,0 +1,137 @@ + 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 $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 $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, + ]); + } +} diff --git a/app/Services/Releases/ReleaseBrowseService.php b/app/Services/Releases/ReleaseBrowseService.php index 6c36b382d..e3cb3aef4 100644 --- a/app/Services/Releases/ReleaseBrowseService.php +++ b/app/Services/Releases/ReleaseBrowseService.php @@ -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 $excludedCats + * @param array $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 $excludedCats + * @param array $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 $excludedCats + * @param array $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 $cat - * @param array $excludedCats + * @param array $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 $cat - * @param array $excludedCats + * @param array $excludedCats * @param array{0: string, 1: string} $orderBy */ private function executeApiBrowseViaSearchIndex( @@ -509,7 +509,7 @@ class ReleaseBrowseService } /** - * @param array $excludedCats + * @param array $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 $excludedCats + * @param array $excludedCats */ public function getShowsCount(mixed $userShows, int $maxAge = -1, array $excludedCats = []): int { diff --git a/app/Services/Releases/ReleaseSearchService.php b/app/Services/Releases/ReleaseSearchService.php index a5d40af00..8efe640b6 100644 --- a/app/Services/Releases/ReleaseSearchService.php +++ b/app/Services/Releases/ReleaseSearchService.php @@ -41,7 +41,7 @@ class ReleaseSearchService * Function for searching on the site (by subject, searchname or advanced). * * @param array $cat Category IDs (list or associative) - * @param array $excludedCats + * @param array $excludedCats * @param array $orderBy * @param array $searchArr * @return array|Collection|mixed @@ -211,7 +211,7 @@ class ReleaseSearchService * Search function for API. * * @param array $cat Category IDs (list or associative) - * @param array $excludedCats + * @param array $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 $cat Category IDs (list or associative) - * @param array $excludedCategories + * @param array $excludedCategories * @param array $siteIdArr * @return array|Collection|\Illuminate\Support\Collection|mixed */ @@ -910,7 +910,7 @@ class ReleaseSearchService * Search TV Shows via APIv2. * * @param array $cat Category IDs (list or associative) - * @param array $excludedCategories + * @param array $excludedCategories * @param array $siteIdArr * @return Collection|mixed */ @@ -1082,7 +1082,7 @@ class ReleaseSearchService * Search anime releases. * * @param array $cat Category IDs (list or associative) - * @param array $excludedCategories + * @param array $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 $cat Category IDs (list or associative) - * @param array $excludedCategories + * @param array $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 $excludedCats + * @param array $excludedCats * @return array */ 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 $cat Category IDs (list or associative) - * @param array $excludedCats + * @param array $excludedCats * @param array $searchResult */ private function buildSearchWhereClause( diff --git a/app/View/Composers/GlobalDataComposer.php b/app/View/Composers/GlobalDataComposer.php index c5789c8c5..118741a6c 100644 --- a/app/View/Composers/GlobalDataComposer.php +++ b/app/View/Composers/GlobalDataComposer.php @@ -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; }); diff --git a/tests/Feature/ApiRequestMatrixTest.php b/tests/Feature/ApiRequestMatrixTest.php index 969fea254..9028078b9 100644 --- a/tests/Feature/ApiRequestMatrixTest.php +++ b/tests/Feature/ApiRequestMatrixTest.php @@ -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,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'); diff --git a/tests/Feature/UserExcludedCategoryTest.php b/tests/Feature/UserExcludedCategoryTest.php index dd00ce143..64e301904 100644 --- a/tests/Feature/UserExcludedCategoryTest.php +++ b/tests/Feature/UserExcludedCategoryTest.php @@ -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