api = $api; $this->releaseSearchService = $releaseSearchService; $this->releaseBrowseService = $releaseBrowseService; } /** * Validate API token and return cached user, or a normalized JSON API error on failure. * Caches user lookup for 5 minutes to reduce DB hits. */ private function resolveUser(Request $request): User|JsonResponse { if ($request->missing('api_token') || $request->isNotFilled('api_token')) { return apiJsonError(200, 'Missing parameter (api_token)'); } $apiToken = $request->input('api_token'); $userCacheKey = 'api_user:'.md5((string) $apiToken); $user = Cache::remember($userCacheKey, 300, function () use ($apiToken) { return User::query() ->whereApiToken((string) $apiToken) ->first(); }); if (! $user || ! $user->hasVerifiedEmail()) { return apiJsonError(100); } if ($user->is_disabled || $user->hasRole('Disabled')) { return apiJsonError(101); } $user->loadMissing('role'); return $user; } /** * Build the standard user stats portion of an API response. * Uses the consolidated single-query + 60s cache from ApiController. * * @return array */ private function buildUserStatsResponse(User $user): array { $userStats = $this->api->getCachedUserStats($user->id); return [ 'apiCurrent' => (int) ($userStats->api_count ?? 0), 'apiMax' => $user->role->apirequests, 'grabCurrent' => (int) ($userStats->grab_count ?? 0), 'grabMax' => $user->role->downloadrequests, 'apiOldestTime' => $userStats->api_time ? Carbon::parse($userStats->api_time)->toRfc2822String() : '', 'grabOldestTime' => $userStats->grab_time ? Carbon::parse($userStats->grab_time)->toRfc2822String() : '', ]; } /** * Build the standard search-results JSON response. * * Replaces the legacy Fractal `['Results' => fractal(...)]` envelope with a * lower-case `results` array of {@see ReleaseData} payloads. Pagination * total and per-user API/grab quotas are kept inline alongside the array * because a JSON object cannot carry both top-level metadata fields and a * bare array body. * * @param iterable $rows */ private function buildSearchResponse(iterable $rows, User $user): JsonResponse { $rowsArray = is_array($rows) ? $rows : iterator_to_array($rows, false); $total = (int) ($rowsArray[0]->_totalrows ?? 0); $results = array_map( static fn ($row): array => ReleaseData::fromRelease($row, $user)->toArray(), $rowsArray, ); return response()->json(array_merge( ['Total' => $total], $this->buildUserStatsResponse($user), ['results' => $results], )); } private function parseMaxAge(Request $request): int|JsonResponse { if (! $request->has('maxage')) { return -1; } if ($request->isNotFilled('maxage')) { return response()->json(['error' => 'Incorrect parameter (maxage must not be empty)'], 400); } if (! is_numeric($request->input('maxage'))) { return response()->json(['error' => 'Incorrect parameter (maxage must be numeric)'], 400); } return (int) $request->input('maxage'); } private function parseSort(Request $request): string|JsonResponse { if (! $request->has('sort')) { return 'posted_desc'; } $sort = strtolower(trim((string) $request->input('sort'))); if ($sort === '') { return response()->json(['error' => 'Incorrect parameter (sort must not be empty)'], 400); } if (! preg_match('/^(cat|name|size|files|stats|posted)_(asc|desc)$/', $sort)) { return response()->json(['error' => 'Incorrect parameter (sort must be one of: cat_asc/desc, name_asc/desc, size_asc/desc, files_asc/desc, stats_asc/desc, posted_asc/desc)'], 400); } return $sort; } public function capabilities(): JsonResponse { // Cache the full capabilities response for 10 minutes $capabilities = Cache::remember('api_v2_capabilities', 600, function () { $category = Category::getForApi(); return [ 'server' => [ 'title' => config('app.name'), 'strapline' => Settings::settingValue('strapline'), 'email' => config('mail.from.address'), 'url' => url('/'), ], 'limits' => [ 'max' => 100, 'default' => 100, ], 'searching' => [ 'search' => ['available' => 'yes', 'supportedParams' => 'id,group,minsize,maxsize,maxage,cat,limit,offset,sort'], 'tv-search' => ['available' => 'yes', 'supportedParams' => 'id,vid,tvdbid,traktid,rid,tvmazeid,imdbid,tmdbid,season,ep,cat,minsize,maxsize,maxage,limit,offset,sort'], 'movie-search' => ['available' => 'yes', 'supportedParams' => 'id,imdbid,tmdbid,traktid,genre,cat,minsize,maxsize,maxage,limit,offset,sort'], 'audio-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,sort'], 'book-search' => ['available' => 'yes', 'supportedParams' => 'id,cat,minsize,maxsize,maxage,group,limit,offset,sort'], 'anime-search' => ['available' => 'yes', 'supportedParams' => 'id,anidbid,anilistid,cat,minsize,maxsize,maxage,limit,offset,sort'], ], 'categories' => $category->map(static fn ($c) => CategoryData::fromCategory($c))->values(), 'groups' => Schema::hasTable('usenet_groups') ? UsenetGroup::query() ->where('active', 1) ->orderBy('name') ->get(['name', 'description', 'last_updated']) ->map(static fn (UsenetGroup $group): array => [ 'name' => $group->name, 'description' => (string) ($group->description ?? ''), 'lastupdate' => $group->last_updated ? Carbon::parse($group->last_updated)->toRfc2822String() : '', ]) ->values() : collect(), 'genres' => Schema::hasTable('genres') ? Genre::query() ->enabled() ->orderBy('title') ->get(['id', 'title', 'type']) ->map(static fn (Genre $genre): array => [ 'id' => $genre->id, 'name' => $genre->title, 'categoryid' => (int) ($genre->type ?? 0), ]) ->values() : collect(), ]; }); $registrationStatus = app(RegistrationStatusService::class)->resolve(); $capabilities['registration'] = [ 'available' => $registrationStatus['available'] ? 'yes' : 'no', 'open' => $registrationStatus['is_open'] ? 'yes' : 'no', ]; return response()->json($capabilities); } /** * @throws \Throwable */ public function movie(Request $request): JsonResponse { $user = $this->resolveUser($request); if ($user instanceof JsonResponse) { return $user; } UserRequest::addApiRequest($user->id, $request->getRequestUri()); event(new UserAccessedApi($user, $request->ip())); // Get request parameters efficiently $imdbId = (string) Str::replace('tt', '', (string) $request->input('imdbid', '')); $tmdbId = (int) $request->input('tmdbid', -1); $traktId = (int) $request->input('traktid', -1); $minSize = max(0, (int) $request->input('minsize', 0)); $searchName = $request->input('id', ''); if ($searchName === '' && ! imdb_id_is_valid($imdbId) && $tmdbId <= 0 && $traktId <= 0) { return response()->json(['error' => 'Specify id (query), imdbid, tmdbid, or traktid'], 400); } $offset = $this->api->offset($request); $limit = $this->api->limit($request); $categoryID = $this->api->categoryID($request); $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; } $sort = $this->parseSort($request); if (! is_string($sort)) { return $sort; } $catExclusions = User::getCategoryExclusionById($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 ( $imdbId, $tmdbId, $traktId, $offset, $limit, $searchName, $sort, $categoryID, $maxAge, $minSize, $catExclusions ) { return $this->releaseSearchService->moviesSearch( $imdbId, $tmdbId, $traktId, $offset, $limit, $searchName, $categoryID, $maxAge, $minSize, $catExclusions, $sort ); }); return $this->buildSearchResponse($relData, $user); } public function audio(Request $request): JsonResponse|Response { $user = $this->resolveUser($request); if ($user instanceof JsonResponse) { return $user; } UserRequest::addApiRequest($user->id, $request->getRequestUri()); event(new UserAccessedApi($user, $request->ip())); $q = (string) $request->input('id', ''); if ($q === '') { return response()->json(['error' => 'Missing id (search query)'], 400); } $offset = $this->api->offset($request); $limit = $this->api->limit($request); $categoryID = $this->api->categoryID($request); $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; } $sort = $this->parseSort($request); if (! is_string($sort)) { return $sort; } $minSize = max(0, (int) $request->input('minsize', 0)); $catExclusions = User::getCategoryExclusionById($user->id); $groupName = $this->api->group($request); $relData = $this->releaseSearchService->apiMusicSearch( $q, $groupName, $offset, $limit, $maxAge, $catExclusions, $categoryID, $minSize, $sort ); return $this->buildSearchResponse($relData, $user); } public function books(Request $request): JsonResponse|Response { $user = $this->resolveUser($request); if ($user instanceof JsonResponse) { return $user; } UserRequest::addApiRequest($user->id, $request->getRequestUri()); event(new UserAccessedApi($user, $request->ip())); $q = (string) $request->input('id', ''); if ($q === '') { return response()->json(['error' => 'Missing id (search query)'], 400); } $offset = $this->api->offset($request); $limit = $this->api->limit($request); $categoryID = $this->api->categoryID($request); $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; } $sort = $this->parseSort($request); if (! is_string($sort)) { return $sort; } $minSize = max(0, (int) $request->input('minsize', 0)); $catExclusions = User::getCategoryExclusionById($user->id); $groupName = $this->api->group($request); $relData = $this->releaseSearchService->apiBookSearch( $q, $groupName, $offset, $limit, $maxAge, $catExclusions, $categoryID, $minSize, $sort ); return $this->buildSearchResponse($relData, $user); } public function anime(Request $request): JsonResponse|Response { $user = $this->resolveUser($request); if ($user instanceof JsonResponse) { return $user; } UserRequest::addApiRequest($user->id, $request->getRequestUri()); event(new UserAccessedApi($user, $request->ip())); $q = (string) $request->input('id', ''); $anidb = (int) $request->input('anidbid', -1); $anilist = (int) $request->input('anilistid', -1); if ($q === '' && $anidb <= 0 && $anilist <= 0) { return response()->json(['error' => 'Specify id (query), anidbid, or anilistid'], 400); } $offset = $this->api->offset($request); $limit = $this->api->limit($request); $categoryID = $this->api->categoryID($request); $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; } $sort = $this->parseSort($request); if (! is_string($sort)) { return $sort; } $catExclusions = User::getCategoryExclusionById($user->id); $relData = $this->releaseSearchService->animeSearch( $anidb, $offset, $limit, $q, $categoryID, $maxAge, $catExclusions, $anilist, $sort ); return $this->buildSearchResponse($relData, $user); } /** * @throws \Exception * @throws \Throwable */ public function apiSearch(Request $request): JsonResponse { $user = $this->resolveUser($request); if ($user instanceof JsonResponse) { return $user; } UserRequest::addApiRequest($user->id, $request->getRequestUri()); event(new UserAccessedApi($user, $request->ip())); $offset = $this->api->offset($request); $catExclusions = User::getCategoryExclusionById($user->id); $minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0; $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; } $sort = $this->parseSort($request); if (! is_string($sort)) { return $sort; } $groupName = $this->api->group($request); if (is_array($groupName)) { $groupName = $groupName[0] ?? -1; } $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( 1, $categoryID, $offset, $limit, $sort, $maxAge, $catExclusions, $groupName, $minSize ); } return $this->buildSearchResponse($relData, $user); } /** * @throws \Exception * @throws \Throwable */ public function tv(Request $request): JsonResponse { $user = $this->resolveUser($request); if ($user instanceof JsonResponse) { return $user; } $catExclusions = User::getCategoryExclusionById($user->id); $minSize = $request->has('minsize') && $request->input('minsize') > 0 ? $request->input('minsize') : 0; $this->api->verifyEmptyParameter($request, 'id'); $this->api->verifyEmptyParameter($request, 'vid'); $this->api->verifyEmptyParameter($request, 'tvdbid'); $this->api->verifyEmptyParameter($request, 'traktid'); $this->api->verifyEmptyParameter($request, 'rid'); $this->api->verifyEmptyParameter($request, 'tvmazeid'); $this->api->verifyEmptyParameter($request, 'imdbid'); $this->api->verifyEmptyParameter($request, 'tmdbid'); $this->api->verifyEmptyParameter($request, 'season'); $this->api->verifyEmptyParameter($request, 'ep'); if (! $this->hasTvSearchParameters($request)) { return response()->json(['error' => 'Specify id (query), vid, tvdbid, traktid, rid, tvmazeid, imdbid, or tmdbid'], 400); } $maxAge = $this->parseMaxAge($request); if (! is_int($maxAge)) { return $maxAge; } $sort = $this->parseSort($request); if (! is_string($sort)) { return $sort; } UserRequest::addApiRequest($user->id, $request->getRequestUri()); event(new UserAccessedApi($user, $request->ip())); $siteIdArr = [ 'id' => $request->input('vid') ?? null, 'tvdb' => $request->input('tvdbid') ?? null, 'trakt' => $request->input('traktid') ?? null, 'tvrage' => $request->input('rid') ?? null, 'tvmaze' => $request->input('tvmazeid') ?? null, 'imdb' => $request->input('imdbid') ?? null, 'tmdb' => $request->input('tmdbid') ?? null, ]; // Process season only queries or Season and Episode/Airdate queries $series = $request->input('season') ?? ''; $episode = $request->input('ep') ?? ''; if (preg_match('#^(19|20)\d{2}$#', $series, $year) && str_contains($episode, '/')) { $airDate = str_replace('/', '-', $year[0].'-'.$episode); } $relData = $this->releaseSearchService->apiTvSearch( $siteIdArr, $series, $episode, $airDate ?? '', $this->api->offset($request), $this->api->limit($request), $request->input('id') ?? '', $this->api->categoryID($request), $maxAge, $minSize, $catExclusions, $sort ); return $this->buildSearchResponse($relData, $user); } public function getNzb(Request $request): Application|ResponseFactory|JsonResponse|Redirector|RedirectResponse { $user = $this->resolveUser($request); if ($user instanceof JsonResponse) { return $user; } event(new UserAccessedApi($user, $request->ip())); UserRequest::addApiRequest($user->id, $request->getRequestUri()); $relData = Release::checkGuidForApi($request->input('id')); if ($relData) { return redirect('/getnzb?r='.$request->input('api_token').'&id='.$request->input('id').(($request->has('del') && $request->input('del') === '1') ? '&del=1' : '')); } return response()->json(['data' => 'No such item (the guid you provided has no release in our database)'], 404); } public function details(Request $request): JsonResponse { $user = $this->resolveUser($request); if ($user instanceof JsonResponse) { return $user; } if ($request->missing('id')) { 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())); $relData = Release::getByGuidForApi($request->input('id')); if ($relData === null) { return response()->json(['error' => 'No such item'], 404); } return response()->json(DetailsData::fromRelease($relData, $user)->toArray()); } private function hasTvSearchParameters(Request $request): bool { return $request->filled('id') || $request->filled('vid') || $request->filled('tvdbid') || $request->filled('traktid') || $request->filled('rid') || $request->filled('tvmazeid') || $request->filled('imdbid') || $request->filled('tmdbid'); } }