From 01ee665a240fe0ec373b9961b4866cc5386c178c Mon Sep 17 00:00:00 2001 From: DariusIII Date: Sun, 12 Jul 2026 11:06:31 +0200 Subject: [PATCH] Update APIv2 speed --- app/Data/Api/DetailsData.php | 49 +++++++---- app/Data/Api/ReleaseData.php | 14 ++- app/Http/Controllers/Api/ApiV2Controller.php | 58 +++++++------ app/Listeners/UpdateUserAccessedApi.php | 6 +- tests/Feature/ApiRequestMatrixTest.php | 90 ++++++++++++++++++++ tests/Feature/UpdateUserAccessedApiTest.php | 70 +++++++++++++++ tests/Unit/ApiPerformanceHelpersTest.php | 34 +++++++- 7 files changed, 273 insertions(+), 48 deletions(-) diff --git a/app/Data/Api/DetailsData.php b/app/Data/Api/DetailsData.php index accd003fe..5c7f7a0b5 100644 --- a/app/Data/Api/DetailsData.php +++ b/app/Data/Api/DetailsData.php @@ -44,14 +44,31 @@ final class DetailsData extends Data public static function fromRelease(Release|\stdClass $release, User $user): self { + return new self(...self::toArrayFromRelease($release, $user)); + } + + /** + * Build the API array directly for hot details response paths. + * + * @return array + */ + public static function toArrayFromRelease( + Release|\stdClass $release, + User $user, + ?string $detailsBaseUrl = null, + ?string $getNzbBaseUrl = null + ): array { $get = static fn (string $key, mixed $default = null): mixed => $release->{$key} ?? $default; $categoriesId = (int) $get('categories_id', 0); $guid = (string) $get('guid', ''); + $detailsBaseUrl ??= url('/details').'/'; + $getNzbBaseUrl ??= url('/getnzb'); + $base = [ 'title' => (string) $get('searchname', ''), - 'details' => url('/').'/details/'.$guid, - 'link' => url('/').'/getnzb?id='.$guid.'.nzb&r='.$user->api_token, + 'details' => $detailsBaseUrl.$guid, + 'link' => $getNzbBaseUrl.'?id='.$guid.'.nzb&r='.$user->api_token, 'category' => $categoriesId, 'category_name' => $get('category_name'), 'added' => Carbon::parse($get('adddate'))->toRssString(), @@ -64,25 +81,23 @@ final class DetailsData extends Data ]; if (in_array($categoriesId, Category::MOVIES_GROUP, true)) { - return new self( - ...$base, - imdbid: $get('imdbid'), - ); + return $base + [ + 'imdbid' => $get('imdbid'), + ]; } if (in_array($categoriesId, Category::TV_GROUP, true)) { - return new self( - ...$base, - imdbid: $get('imdb'), - tmdbid: $get('tmdb'), - traktid: $get('trakt'), - tvairdate: $get('firstaired'), - tvdbid: $get('tvdb'), - tvrageid: $get('tvrage'), - tvmazeid: $get('tvmaze'), - ); + return $base + [ + 'imdbid' => $get('imdb'), + 'tmdbid' => $get('tmdb'), + 'traktid' => $get('trakt'), + 'tvairdate' => $get('firstaired'), + 'tvdbid' => $get('tvdb'), + 'tvrageid' => $get('tvrage'), + 'tvmazeid' => $get('tvmaze'), + ]; } - return new self(...$base); + return $base; } } diff --git a/app/Data/Api/ReleaseData.php b/app/Data/Api/ReleaseData.php index 03c3350b7..4913547b6 100644 --- a/app/Data/Api/ReleaseData.php +++ b/app/Data/Api/ReleaseData.php @@ -64,17 +64,23 @@ final class ReleaseData extends Data * * @return array */ - public static function toArrayFromRelease(Release|\stdClass $release, User $user): array - { + public static function toArrayFromRelease( + Release|\stdClass $release, + User $user, + ?string $detailsBaseUrl = null, + ?string $getNzbBaseUrl = null + ): array { $get = static fn (string $key, mixed $default = null): mixed => $release->{$key} ?? $default; $categoriesId = (int) $get('categories_id', 0); $guid = (string) $get('guid', ''); + $detailsBaseUrl ??= url('/details').'/'; + $getNzbBaseUrl ??= url('/getnzb'); $base = [ 'title' => (string) $get('searchname', ''), - 'details' => url('/details/'.$guid), - 'url' => url('/getnzb').'?id='.$guid.'.nzb&r='.$user->api_token, + 'details' => $detailsBaseUrl.$guid, + 'url' => $getNzbBaseUrl.'?id='.$guid.'.nzb&r='.$user->api_token, 'category' => $categoriesId, 'category_name' => $get('category_name'), 'added' => Carbon::parse($get('adddate'))->toRssString(), diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index d7ede74cb..e4b256919 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -126,6 +126,12 @@ class ApiV2Controller extends BasePageController return $this->resolvedUserStats[$user->id] ??= $this->api->getCachedUserStats($user->id); } + private function recordApiRequest(User $user, Request $request): void + { + UserRequest::addApiRequest($user->id, $request->getRequestUri()); + event(new UserAccessedApi($user, $request->ip())); + } + /** * Build the standard search-results JSON response. * @@ -141,11 +147,13 @@ class ApiV2Controller extends BasePageController { $rowsArray = is_array($rows) ? $rows : iterator_to_array($rows, false); $total = (int) ($rowsArray[0]->_totalrows ?? 0); + $detailsBaseUrl = url('/details').'/'; + $getNzbBaseUrl = url('/getnzb'); - $results = array_map( - static fn ($row): array => ReleaseData::toArrayFromRelease($row, $user), - $rowsArray, - ); + $results = []; + foreach ($rowsArray as $row) { + $results[] = ReleaseData::toArrayFromRelease($row, $user, $detailsBaseUrl, $getNzbBaseUrl); + } return response()->json(array_merge( ['Total' => $total], @@ -211,7 +219,10 @@ class ApiV2Controller extends BasePageController 'book-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,sort'], 'anime-search' => ['available' => 'yes', 'supportedParams' => 'id,anidbid,anilistid,cat,minsize,maxsize,maxage,limit,offset,sort'], ], - 'categories' => $category->map(static fn (RootCategory $rootCategory): CategoryData => CategoryData::fromCategory($rootCategory))->values(), + 'categories' => $category + ->map(static fn (RootCategory $rootCategory): array => CategoryData::fromCategory($rootCategory)->toArray()) + ->values() + ->all(), 'groups' => Schema::hasTable('usenet_groups') ? UsenetGroup::query() ->where('active', 1) @@ -223,7 +234,8 @@ class ApiV2Controller extends BasePageController 'lastupdate' => $group->last_updated ? Carbon::parse($group->last_updated)->toRfc2822String() : '', ]) ->values() - : collect(), + ->all() + : [], 'genres' => Schema::hasTable('genres') ? Genre::query() ->enabled() @@ -235,7 +247,8 @@ class ApiV2Controller extends BasePageController 'categoryid' => (int) ($genre->type ?? 0), ]) ->values() - : collect(), + ->all() + : [], ]; }); @@ -258,8 +271,7 @@ class ApiV2Controller extends BasePageController return $user; } - UserRequest::addApiRequest($user->id, $request->getRequestUri()); - event(new UserAccessedApi($user, $request->ip())); + $this->recordApiRequest($user, $request); // Get request parameters efficiently $imdbId = (string) Str::replace('tt', '', (string) $request->input('imdbid', '')); @@ -324,8 +336,7 @@ class ApiV2Controller extends BasePageController return $user; } - UserRequest::addApiRequest($user->id, $request->getRequestUri()); - event(new UserAccessedApi($user, $request->ip())); + $this->recordApiRequest($user, $request); if ($request->has('id') && $request->isNotFilled('id')) { return response()->json(['error' => 'Incorrect parameter (id must not be empty)'], 400); @@ -402,8 +413,7 @@ class ApiV2Controller extends BasePageController return $user; } - UserRequest::addApiRequest($user->id, $request->getRequestUri()); - event(new UserAccessedApi($user, $request->ip())); + $this->recordApiRequest($user, $request); if ($request->has('id') && $request->isNotFilled('id')) { return response()->json(['error' => 'Incorrect parameter (id must not be empty)'], 400); @@ -480,8 +490,7 @@ class ApiV2Controller extends BasePageController return $user; } - UserRequest::addApiRequest($user->id, $request->getRequestUri()); - event(new UserAccessedApi($user, $request->ip())); + $this->recordApiRequest($user, $request); $q = (string) $request->input('id', ''); $anidb = (int) $request->input('anidbid', -1); @@ -540,8 +549,7 @@ class ApiV2Controller extends BasePageController return $user; } - UserRequest::addApiRequest($user->id, $request->getRequestUri()); - event(new UserAccessedApi($user, $request->ip())); + $this->recordApiRequest($user, $request); $offset = $this->api->offset($request); $catExclusions = User::getCachedCategoryExclusionById($user->id); @@ -637,8 +645,7 @@ class ApiV2Controller extends BasePageController if (! is_string($sort)) { return $sort; } - UserRequest::addApiRequest($user->id, $request->getRequestUri()); - event(new UserAccessedApi($user, $request->ip())); + $this->recordApiRequest($user, $request); $siteIdArr = [ 'id' => $request->input('vid') ?? null, @@ -703,8 +710,7 @@ class ApiV2Controller extends BasePageController return $user; } - event(new UserAccessedApi($user, $request->ip())); - UserRequest::addApiRequest($user->id, $request->getRequestUri()); + $this->recordApiRequest($user, $request); $relData = Release::checkGuidForApi($request->input('id')); if ($relData) { $request->attributes->set(GetNzbController::REQUEST_USER_ATTRIBUTE, $user); @@ -725,8 +731,7 @@ class ApiV2Controller extends BasePageController return response()->json(['error' => 'Missing parameter (guid is required for single release details)'], 400); } - UserRequest::addApiRequest($user->id, $request->getRequestUri()); - event(new UserAccessedApi($user, $request->ip())); + $this->recordApiRequest($user, $request); $guid = $request->input('id'); $relData = $this->releaseRowCache->remember('v2', 'details', [ 'guid' => $guid, @@ -736,7 +741,12 @@ class ApiV2Controller extends BasePageController return response()->json(['error' => 'No such item'], 404); } - return response()->json(DetailsData::fromRelease($relData, $user)->toArray()); + return response()->json(DetailsData::toArrayFromRelease( + $relData, + $user, + url('/details').'/', + url('/getnzb') + )); } private function hasTvSearchParameters(Request $request): bool diff --git a/app/Listeners/UpdateUserAccessedApi.php b/app/Listeners/UpdateUserAccessedApi.php index 310f5d5bb..b742c38fe 100644 --- a/app/Listeners/UpdateUserAccessedApi.php +++ b/app/Listeners/UpdateUserAccessedApi.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Listeners; use App\Events\UserAccessedApi; -use App\Models\User; +use Illuminate\Support\Facades\DB; class UpdateUserAccessedApi { @@ -30,6 +30,8 @@ class UpdateUserAccessedApi $updateData['host'] = $event->ip; } - User::find($event->user->id)->update($updateData); + DB::table('users') + ->where('id', $event->user->id) + ->update($updateData); } } diff --git a/tests/Feature/ApiRequestMatrixTest.php b/tests/Feature/ApiRequestMatrixTest.php index 9028078b9..4dfcc1c33 100644 --- a/tests/Feature/ApiRequestMatrixTest.php +++ b/tests/Feature/ApiRequestMatrixTest.php @@ -568,6 +568,24 @@ class ApiRequestMatrixTest extends TestCase ->assertJsonPath('genres.0.name', 'Test Genre'); } + public function test_v2_details_response_shape_is_unchanged(): void + { + $token = (string) DB::table('users')->value('api_token'); + + $this->getJson('/api/v2/details?api_token='.$token.'&id=release-guid') + ->assertOk() + ->assertJsonPath('title', 'Ubuntu.Release') + ->assertJsonPath('details', 'http://localhost/details/release-guid') + ->assertJsonPath('link', 'http://localhost/getnzb?id=release-guid.nzb&r='.$token) + ->assertJsonPath('category', 5030) + ->assertJsonPath('category_name', 'TV > SD') + ->assertJsonPath('size', 123456) + ->assertJsonPath('files', 10) + ->assertJsonPath('grabs', 2) + ->assertJsonPath('comments', 1) + ->assertJsonPath('password', 0); + } + private function createSchema(): void { Schema::create('roles', function (Blueprint $table): void { @@ -662,6 +680,54 @@ class ApiRequestMatrixTest extends TestCase $table->timestamp('timestamp')->nullable(); }); + Schema::create('videos', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->unsignedInteger('tvdb')->nullable(); + $table->unsignedInteger('trakt')->nullable(); + $table->unsignedInteger('tvrage')->nullable(); + $table->unsignedInteger('tvmaze')->nullable(); + $table->string('imdb')->nullable(); + $table->unsignedInteger('tmdb')->nullable(); + }); + + Schema::create('tv_episodes', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->string('title')->nullable(); + $table->string('series')->nullable(); + $table->string('episode')->nullable(); + $table->date('firstaired')->nullable(); + }); + + Schema::create('movieinfo', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->string('imdbid')->nullable(); + $table->unsignedInteger('tmdbid')->nullable(); + $table->unsignedInteger('traktid')->nullable(); + }); + + Schema::create('releases', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->string('searchname'); + $table->string('guid')->index(); + $table->dateTime('postdate'); + $table->unsignedInteger('categories_id'); + $table->unsignedBigInteger('size'); + $table->unsignedInteger('totalpart'); + $table->string('fromname')->nullable(); + $table->integer('passwordstatus')->default(0); + $table->unsignedInteger('grabs')->default(0); + $table->unsignedInteger('comments')->default(0); + $table->dateTime('adddate'); + $table->unsignedInteger('videos_id')->default(0); + $table->unsignedInteger('tv_episodes_id')->default(0); + $table->integer('haspreview')->default(0); + $table->integer('nfostatus')->default(0); + $table->unsignedInteger('movieinfo_id')->default(0); + $table->unsignedInteger('musicinfo_id')->default(0); + $table->unsignedInteger('consoleinfo_id')->default(0); + $table->unsignedInteger('groups_id')->nullable(); + }); + Schema::create('usenet_groups', function (Blueprint $table): void { $table->increments('id'); $table->string('name'); @@ -754,12 +820,36 @@ class ApiRequestMatrixTest extends TestCase ]); DB::table('usenet_groups')->insert([ + 'id' => 1, 'name' => 'alt.binaries.test', 'active' => 1, 'description' => 'Test usenet group', 'last_updated' => now(), ]); + DB::table('releases')->insert([ + 'id' => 1, + 'searchname' => 'Ubuntu.Release', + 'guid' => 'release-guid', + 'postdate' => '2026-01-02 00:00:00', + 'categories_id' => 5030, + 'size' => 123456, + 'totalpart' => 10, + 'fromname' => 'poster', + 'passwordstatus' => 0, + 'grabs' => 2, + 'comments' => 1, + 'adddate' => '2026-01-03 00:00:00', + 'videos_id' => 0, + 'tv_episodes_id' => 0, + 'haspreview' => 0, + 'nfostatus' => 0, + 'movieinfo_id' => 0, + 'musicinfo_id' => 0, + 'consoleinfo_id' => 0, + 'groups_id' => 1, + ]); + DB::table('genres')->insert([ 'id' => 1, 'title' => 'Test Genre', diff --git a/tests/Feature/UpdateUserAccessedApiTest.php b/tests/Feature/UpdateUserAccessedApiTest.php index 9ab1d4b4d..16658ab88 100644 --- a/tests/Feature/UpdateUserAccessedApiTest.php +++ b/tests/Feature/UpdateUserAccessedApiTest.php @@ -4,11 +4,28 @@ namespace Tests\Feature; use App\Events\UserAccessedApi; use App\Listeners\UpdateUserAccessedApi; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use stdClass; use Tests\TestCase; final class UpdateUserAccessedApiTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + 'app.key' => 'base64:'.base64_encode(random_bytes(32)), + ]); + + DB::purge(); + DB::reconnect(); + } + public function test_event_has_ip_property(): void { // Use a simple stdClass as the event just stores the user reference @@ -43,4 +60,57 @@ final class UpdateUserAccessedApiTest extends TestCase $this->assertEquals(999, $event->user->id); $this->assertEquals('10.0.0.1', $event->ip); } + + public function test_listener_updates_api_access_and_host(): void + { + $this->createUsersTable(); + + DB::table('users')->insert([ + 'id' => 1, + 'host' => null, + 'apiaccess' => null, + ]); + + $user = new stdClass; + $user->id = 1; + + (new UpdateUserAccessedApi)->handle(new UserAccessedApi($user, '192.168.1.100')); + + $row = DB::table('users')->where('id', 1)->first(); + + $this->assertSame('192.168.1.100', $row->host); + $this->assertNotNull($row->apiaccess); + } + + public function test_listener_updates_with_a_single_query_without_loading_user_model(): void + { + $this->createUsersTable(); + + DB::table('users')->insert([ + 'id' => 1, + 'host' => null, + 'apiaccess' => null, + ]); + + $user = new stdClass; + $user->id = 1; + + DB::flushQueryLog(); + DB::enableQueryLog(); + + (new UpdateUserAccessedApi)->handle(new UserAccessedApi($user, '10.0.0.1')); + + $queries = DB::getQueryLog(); + $this->assertCount(1, $queries); + $this->assertStringStartsWith('update ', strtolower((string) $queries[0]['query'])); + } + + private function createUsersTable(): void + { + Schema::create('users', function (Blueprint $table): void { + $table->increments('id'); + $table->string('host')->nullable(); + $table->timestamp('apiaccess')->nullable(); + }); + } } diff --git a/tests/Unit/ApiPerformanceHelpersTest.php b/tests/Unit/ApiPerformanceHelpersTest.php index 707d2709f..069e68133 100644 --- a/tests/Unit/ApiPerformanceHelpersTest.php +++ b/tests/Unit/ApiPerformanceHelpersTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit; +use App\Data\Api\DetailsData; use App\Data\Api\ReleaseData; use App\Facades\Search; use App\Models\Release; @@ -133,7 +134,38 @@ class ApiPerformanceHelpersTest extends TestCase $this->assertSame( ReleaseData::fromRelease($release, $user)->toArray(), - ReleaseData::toArrayFromRelease($release, $user) + ReleaseData::toArrayFromRelease($release, $user, url('/details').'/', url('/getnzb')) + ); + } + + public function test_details_data_fast_array_matches_existing_data_output(): void + { + $release = (object) [ + 'searchname' => 'Ubuntu.Release', + 'guid' => 'release-guid', + 'categories_id' => 5030, + 'category_name' => 'TV > SD', + 'adddate' => '2026-01-03 00:00:00', + 'size' => 123456, + 'totalpart' => 10, + 'grabs' => 2, + 'comments' => 1, + 'passwordstatus' => 0, + 'postdate' => '2026-01-02 00:00:00', + 'imdb' => '1234567', + 'tmdb' => 234, + 'trakt' => 345, + 'firstaired' => '2026-01-01', + 'tvdb' => 456, + 'tvrage' => 567, + 'tvmaze' => 678, + ]; + $user = new User; + $user->api_token = 'api-token'; + + $this->assertSame( + DetailsData::fromRelease($release, $user)->toArray(), + DetailsData::toArrayFromRelease($release, $user, url('/details').'/', url('/getnzb')) ); }