From 97fe899cb0b27d01d58a4535c112bbe3ad29c8b1 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 16 Nov 2025 14:24:20 +0100 Subject: [PATCH] feat: approve jellyserr requests (#1214) --- .../jellyseerr/page.tsx | 107 +++++++++++++++++- hooks/useJellyseerr.ts | 16 +++ translations/en.json | 10 +- 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx index fdaf98ad..c8ab71ab 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx @@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Platform, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { toast } from "sonner-native"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { GenreTags } from "@/components/GenreTags"; @@ -33,8 +34,16 @@ import { type IssueType, IssueTypeName, } from "@/utils/jellyseerr/server/constants/issue"; -import { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import { + MediaRequestStatus, + MediaType, +} from "@/utils/jellyseerr/server/constants/media"; +import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { + hasPermission, + Permission, +} from "@/utils/jellyseerr/server/lib/permissions"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieResult, @@ -58,7 +67,7 @@ const Page: React.FC = () => { } & Partial; const navigation = useNavigation(); - const { jellyseerrApi, requestMedia } = useJellyseerr(); + const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const [issueType, setIssueType] = useState(); const [issueMessage, setIssueMessage] = useState(); @@ -91,6 +100,46 @@ const Page: React.FC = () => { const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details); + const canManageRequests = useMemo(() => { + if (!jellyseerrUser) return false; + return hasPermission( + Permission.MANAGE_REQUESTS, + jellyseerrUser.permissions, + ); + }, [jellyseerrUser]); + + const pendingRequest = useMemo(() => { + return details?.mediaInfo?.requests?.find( + (r: MediaRequest) => r.status === MediaRequestStatus.PENDING, + ); + }, [details]); + + const handleApproveRequest = useCallback(async () => { + if (!pendingRequest?.id) return; + + try { + await jellyseerrApi?.approveRequest(pendingRequest.id); + toast.success(t("jellyseerr.toasts.request_approved")); + refetch(); + } catch (error) { + toast.error(t("jellyseerr.toasts.failed_to_approve_request")); + console.error("Failed to approve request:", error); + } + }, [jellyseerrApi, pendingRequest, refetch, t]); + + const handleDeclineRequest = useCallback(async () => { + if (!pendingRequest?.id) return; + + try { + await jellyseerrApi?.declineRequest(pendingRequest.id); + toast.success(t("jellyseerr.toasts.request_declined")); + refetch(); + } catch (error) { + toast.error(t("jellyseerr.toasts.failed_to_decline_request")); + console.error("Failed to decline request:", error); + } + }, [jellyseerrApi, pendingRequest, refetch, t]); + const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( { ) )} + {canManageRequests && pendingRequest && ( + + + + + {t("jellyseerr.requested_by", { + user: + pendingRequest.requestedBy?.displayName || + pendingRequest.requestedBy?.username || + pendingRequest.requestedBy?.jellyfinUsername || + t("jellyseerr.unknown_user"), + })} + + + + + + + + )} diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index 9bdd4394..d57b9ebc 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -244,6 +244,22 @@ export class JellyseerrApi { .then(({ data }) => data); } + async approveRequest(requestId: number): Promise { + return this.axios + ?.post( + `${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/approve`, + ) + .then(({ data }) => data); + } + + async declineRequest(requestId: number): Promise { + return this.axios + ?.post( + `${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/decline`, + ) + .then(({ data }) => data); + } + async requests( params = { filter: "all", diff --git a/translations/en.json b/translations/en.json index bd21767b..4266ee69 100644 --- a/translations/en.json +++ b/translations/en.json @@ -514,6 +514,10 @@ "number_episodes": "{{episode_number}} Episodes", "born": "Born", "appearances": "Appearances", + "approve": "Approve", + "decline": "Decline", + "requested_by": "Requested by {{user}}", + "unknown_user": "Unknown User", "toasts": { "jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseerr_test_failed": "Seerr test failed. Please try again.", @@ -521,7 +525,11 @@ "issue_submitted": "Issue Submitted!", "requested_item": "Requested {{item}}!", "you_dont_have_permission_to_request": "You don't have permission to request!", - "something_went_wrong_requesting_media": "Something went wrong requesting media!" + "something_went_wrong_requesting_media": "Something went wrong requesting media!", + "request_approved": "Request Approved!", + "request_declined": "Request Declined!", + "failed_to_approve_request": "Failed to Approve Request", + "failed_to_decline_request": "Failed to Decline Request" } }, "tabs": {