From 2dc49735f42faba9b02cb4d49527549e420bad49 Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Tue, 7 Jan 2025 23:53:10 -0500 Subject: [PATCH] [Jellyseerr] Show media configuration for admins implements #331 --- .../jellyseerr/page.tsx | 57 +++-- augmentations/number.ts | 18 +- components/common/Dropdown.tsx | 95 +++++++ components/jellyseerr/RequestModal.tsx | 233 ++++++++++++++++++ components/posters/JellyseerrPoster.tsx | 2 +- components/series/JellyseerrSeasons.tsx | 48 ++-- hooks/useJellyseerr.ts | 25 ++ utils/_jellyseerr/useJellyseerrCanRequest.ts | 17 +- utils/jellyseerr | 2 +- 9 files changed, 454 insertions(+), 43 deletions(-) create mode 100644 components/common/Dropdown.tsx create mode 100644 components/jellyseerr/RequestModal.tsx 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 228d67eb..3605a665 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx @@ -28,10 +28,13 @@ import { import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; import { TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as DropdownMenu from "zeego/dropdown-menu"; +import RequestModal from "@/components/jellyseerr/RequestModal"; +import {ANIME_KEYWORD_ID} from "@/utils/jellyseerr/server/api/themoviedb/constants"; +import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; const Page: React.FC = () => { const insets = useSafeAreaInsets(); @@ -49,6 +52,7 @@ const Page: React.FC = () => { const [issueType, setIssueType] = useState(); const [issueMessage, setIssueMessage] = useState(); + const advancedReqModalRef = useRef(null); const bottomSheetModalRef = useRef(null); const { @@ -72,7 +76,7 @@ const Page: React.FC = () => { }, }); - const canRequest = useJellyseerrCanRequest(details); + const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( @@ -98,19 +102,27 @@ const Page: React.FC = () => { }, [jellyseerrApi, details, result, issueType, issueMessage]); const request = useCallback(async () => { - requestMedia( - mediaTitle, - { - mediaId: Number(result.id!!), - mediaType: result.mediaType!!, - tvdbId: details?.externalIds?.tvdbId, - seasons: (details as TvDetails)?.seasons - ?.filter?.((s) => s.seasonNumber !== 0) - ?.map?.((s) => s.seasonNumber), - }, - refetch - ); - }, [details, result, requestMedia]); + const body: MediaRequestBody = { + mediaId: Number(result.id!!), + mediaType: result.mediaType!!, + tvdbId: details?.externalIds?.tvdbId, + seasons: (details as TvDetails)?.seasons + ?.filter?.((s) => s.seasonNumber !== 0) + ?.map?.((s) => s.seasonNumber), + } + + if (hasAdvancedRequestPermission) { + advancedReqModalRef?.current?.present?.(body) + return + } + + requestMedia(mediaTitle, body, refetch); + }, [details, result, requestMedia, hasAdvancedRequestPermission]); + + const isAnime = useMemo( + () => (details?.keywords.some(k => k.id === ANIME_KEYWORD_ID) || false) && result.mediaType === MediaType.TV, + [details] + ) useEffect(() => { if (details) { @@ -229,6 +241,10 @@ const Page: React.FC = () => { result={result as TvResult} details={details as TvDetails} refetch={refetch} + hasAdvancedRequest={hasAdvancedRequestPermission} + onAdvancedRequest={(data) => + advancedReqModalRef?.current?.present(data) + } /> )} { + { + advancedReqModalRef?.current?.close() + refetch() + }} + /> = 1) return `${gb.toFixed(0)} GB`; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - const mb = bytes / 1024.0 / 1024.0; - if (mb >= 1) return `${mb.toFixed(0)} MB`; + const i = Math.floor(Math.log(bytes) / Math.log(k)); - const kb = bytes / 1024.0; - if (kb >= 1) return `${kb.toFixed(0)} KB`; - - return `${bytes.toFixed(2)} B`; + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; }; Number.prototype.secondsToMilliseconds = function () { diff --git a/components/common/Dropdown.tsx b/components/common/Dropdown.tsx new file mode 100644 index 00000000..d9612d48 --- /dev/null +++ b/components/common/Dropdown.tsx @@ -0,0 +1,95 @@ +import * as DropdownMenu from "zeego/dropdown-menu"; +import {TouchableOpacity, View, ViewProps} from "react-native"; +import {Text} from "@/components/common/Text"; +import React, {PropsWithChildren, useEffect, useState} from "react"; + +interface Props { + data: T[] + placeholderText?: string, + keyExtractor: (item: T) => string + titleExtractor: (item: T) => string + title: string, + label: string, + onSelected: (...item: T[]) => void + multi?: boolean +} + +const Dropdown = ({ + data, + placeholderText, + keyExtractor, + titleExtractor, + title, + label, + onSelected, + multi = false, + ...props +}: PropsWithChildren & ViewProps>) => { + const [selected, setSelected] = useState(); + + useEffect(() => { + if (selected !== undefined) { + onSelected(...selected) + } + }, [selected]); + + return ( + + + + + + {title} + + + + {selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText} + + + + + + {label} + {data.map((item, idx) => ( + multi ? ( + keyExtractor(s) == keyExtractor(item)) ? 'on' : 'off'} + key={keyExtractor(item)} + onValueChange={(next, previous) => + setSelected((p) => { + const prev = p || [] + if (next == 'on') { + return [...prev, item] + } + return [...prev.filter(p => keyExtractor(p) !== keyExtractor(item))] + }) + } + > + {titleExtractor(item)} + + ) + : ( + setSelected([item])} + > + {titleExtractor(item)} + + ) + ))} + + + + ) +}; + +export default Dropdown; \ No newline at end of file diff --git a/components/jellyseerr/RequestModal.tsx b/components/jellyseerr/RequestModal.tsx new file mode 100644 index 00000000..2f735bd7 --- /dev/null +++ b/components/jellyseerr/RequestModal.tsx @@ -0,0 +1,233 @@ +import React, {forwardRef, useCallback, useMemo, useState} from "react"; +import {View, ViewProps} from "react-native"; +import {useJellyseerr} from "@/hooks/useJellyseerr"; +import {useQuery} from "@tanstack/react-query"; +import {MediaType} from "@/utils/jellyseerr/server/constants/media"; +import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet"; +import Dropdown from "@/components/common/Dropdown"; +import {QualityProfile, RootFolder, Tag} from "@/utils/jellyseerr/server/api/servarr/base"; +import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import {BottomSheetModalMethods} from "@gorhom/bottom-sheet/lib/typescript/types"; +import {Button} from "@/components/Button"; +import {Text} from "@/components/common/Text"; + +interface Props { + id: number; + title: string, + type: MediaType; + isAnime?: boolean; + is4k?: boolean; + onRequested?: () => void; +} + +const RequestModal = forwardRef>(({ + id, + title, + type, + isAnime = false, + onRequested, + ...props +}, ref) => { + const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr(); + const [requestOverrides, setRequestOverrides] = + useState({ + mediaId: Number(id), + mediaType: type, + userId: jellyseerrUser?.id + }); + + const [modalRequestProps, setModalRequestProps] = useState(); + + const {data: serviceSettings} = useQuery({ + queryKey: ["jellyseerr", "request", type, 'service'], + queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'), + enabled: !!jellyseerrApi && !!jellyseerrUser, + refetchOnMount: 'always' + }); + + const {data: users} = useQuery({ + queryKey: ["jellyseerr", "users"], + queryFn: async () => jellyseerrApi?.user({take: 1000, sort: 'displayname'}), + enabled: !!jellyseerrApi && !!jellyseerrUser, + refetchOnMount: 'always' + }); + + const defaultService = useMemo( + () => serviceSettings?.find?.(v => v.isDefault), + [serviceSettings] + ); + + const {data: defaultServiceDetails} = useQuery({ + queryKey: ["jellyseerr", "request", type, 'service', 'details', defaultService?.id], + queryFn: async () => { + setRequestOverrides((prev) => ({ + ...prev, + serverId: defaultService?.id + })) + return jellyseerrApi?.serviceDetails(type === 'movie' ? 'radarr' : 'sonarr', defaultService!!.id) + }, + enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService, + refetchOnMount: 'always', + }); + + const defaultProfile: QualityProfile = useMemo( + () => defaultServiceDetails?.profiles + .find(p => + p.id === (isAnime ? defaultServiceDetails.server?.activeAnimeProfileId : defaultServiceDetails.server?.activeProfileId) + ), + [defaultServiceDetails] + ); + + const defaultFolder: RootFolder = useMemo( + () => defaultServiceDetails?.rootFolders + .find(f => + f.path === (isAnime ? defaultServiceDetails?.server.activeAnimeDirectory : defaultServiceDetails.server?.activeDirectory) + ), + [defaultServiceDetails] + ); + + const defaultTags: Tag[] = useMemo( + () => { + const tags = defaultServiceDetails?.tags + .filter(t => + (isAnime + ? defaultServiceDetails?.server.activeAnimeTags + : defaultServiceDetails?.server.activeTags + )?.includes(t.id) + ) ?? [] + + console.log(tags) + return tags + }, + [defaultServiceDetails] + ); + + const seasonTitle = useMemo( + () => modalRequestProps?.seasons?.length ? `Season (${modalRequestProps?.seasons})` : undefined, + [modalRequestProps?.seasons] + ); + + const request = useCallback(() => {requestMedia( + seasonTitle ? `${title}, ${seasonTitle}` : title, + { + is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k, + profileId: defaultProfile.id, + rootFolder: defaultFolder.path, + tags: defaultTags.map(t => t.id), + ...modalRequestProps, + ...requestOverrides + }, + onRequested + ) + }, [requestOverrides, defaultProfile, defaultFolder, defaultTags]); + + const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`; + + return ( + setModalRequestProps(undefined)} + handleIndicatorStyle={{ + backgroundColor: "white", + }} + backgroundStyle={{ + backgroundColor: "#171717", + }} + backdropComponent={(sheetProps: BottomSheetBackdropProps) => + + } + > + {(data) => { + setModalRequestProps(data?.data as MediaRequestBody) + return + + + Advanced + {seasonTitle && + {seasonTitle} + } + + + {(defaultService && defaultServiceDetails && users) && ( + <> + item.name} + placeholderText={defaultProfile.name} + keyExtractor={(item) => item.id.toString()} + label={"Quality Profile"} + onSelected={(item) => + item && setRequestOverrides((prev) => ({ + ...prev, + profileId: item?.id + })) + } + title={"Quality Profile"} + /> + item.id.toString()} + label={"Root Folder"} + onSelected={(item) => + item && setRequestOverrides((prev) => ({ + ...prev, + rootFolder: item.path + }))} + title={"Root Folder"} + /> + item.label} + placeholderText={defaultTags.map(t => t.label).join(",")} + keyExtractor={(item) => item.id.toString()} + label={"Tags"} + onSelected={(...item) => + item && setRequestOverrides((prev) => ({ + ...prev, + tags: item.map(i => i.id) + })) + } + title={"Tags"} + /> + item.displayName} + placeholderText={jellyseerrUser!!.displayName} + keyExtractor={(item) => item.id.toString() || ""} + label={"Request As"} + onSelected={(item) => + item && setRequestOverrides((prev) => ({ + ...prev, + userId: item?.id + })) + } + title={"Request As"} + /> + + ) + } + + + + + }} + + ); +}); + +export default RequestModal; \ No newline at end of file diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/JellyseerrPoster.tsx index 11fb4941..1c3ce45b 100644 --- a/components/posters/JellyseerrPoster.tsx +++ b/components/posters/JellyseerrPoster.tsx @@ -57,7 +57,7 @@ const JellyseerrPoster: React.FC = ({ item, ...props }) => { [item] ); - const canRequest = useJellyseerrCanRequest(item); + const [canRequest] = useJellyseerrCanRequest(item); return ( void; refetch: (options?: (RefetchOptions | undefined)) => Promise>; -}> = ({ isLoading, result, details, refetch }) => { +}> = ({ + isLoading, + result, + details, + refetch, + hasAdvancedRequest, + onAdvancedRequest, +}) => { if (!details) return null; const { jellyseerrApi, requestMedia } = useJellyseerr(); @@ -142,7 +152,7 @@ const JellyseerrSeasons: React.FC<{ const requestAll = useCallback(() => { if (details && jellyseerrApi) { - requestMedia(result?.name!!, { + const body: MediaRequestBody = { mediaId: details.id, mediaType: MediaType.TV, tvdbId: details.externalIds?.tvdbId, @@ -151,9 +161,15 @@ const JellyseerrSeasons: React.FC<{ (s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0 ) .map((s) => s.seasonNumber), - }); + } + + if (hasAdvancedRequest) { + return onAdvancedRequest?.(body) + } + + requestMedia(result?.name!!, body, refetch); } - }, [jellyseerrApi, seasons, details]); + }, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]); const promptRequestAll = useCallback( () => @@ -172,18 +188,20 @@ const JellyseerrSeasons: React.FC<{ const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => { if (canRequest) { - requestMedia( - `${result?.name!!}, Season ${seasonNumber}`, - { - mediaId: details.id, - mediaType: MediaType.TV, - tvdbId: details.externalIds?.tvdbId, - seasons: [seasonNumber], - }, - refetch - ) + const body: MediaRequestBody = { + mediaId: details.id, + mediaType: MediaType.TV, + tvdbId: details.externalIds?.tvdbId, + seasons: [seasonNumber], + } + + if (hasAdvancedRequest) { + return onAdvancedRequest?.(body) + } + + requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch); } - }, [requestMedia]); + }, [requestMedia, hasAdvancedRequest, onAdvancedRequest]); if (isLoading) return ( diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index 8d3eba30..c6eb9a34 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -34,6 +34,11 @@ import { } from "@/utils/jellyseerr/server/models/Person"; import { useQueryClient } from "@tanstack/react-query"; import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; +import {UserResultsResponse} from "@/utils/jellyseerr/server/interfaces/api/userInterfaces"; +import { + ServiceCommonServer, + ServiceCommonServerWithDetails +} from "@/utils/jellyseerr/server/interfaces/api/serviceInterfaces"; interface SearchParams { query: string; @@ -66,6 +71,8 @@ export enum Endpoints { MOVIE = "/movie", RATINGS = "/ratings", ISSUE = "/issue", + USER = "/user", + SERVICE = "/service", TV = "/tv", SETTINGS = "/settings", NETWORK = "/network", @@ -282,6 +289,12 @@ export class JellyseerrApi { }); } + async user(params: any) { + return this.axios + ?.get(`${Endpoints.API_V1}${Endpoints.USER}`, { params }) + .then(({data}) => data.results) + } + imageProxy( path?: string, filter: string = "original", @@ -315,6 +328,18 @@ export class JellyseerrApi { }); } + async service(type: 'radarr' | 'sonarr') { + return this.axios + ?.get(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}`) + .then(({data}) => data); + } + + async serviceDetails(type: 'radarr' | 'sonarr', id: number) { + return this.axios + ?.get(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}` + `/${id}`) + .then(({data}) => data); + } + private setInterceptors() { this.axios.interceptors.response.use( async (response) => { diff --git a/utils/_jellyseerr/useJellyseerrCanRequest.ts b/utils/_jellyseerr/useJellyseerrCanRequest.ts index ba692df3..c58a8928 100644 --- a/utils/_jellyseerr/useJellyseerrCanRequest.ts +++ b/utils/_jellyseerr/useJellyseerrCanRequest.ts @@ -48,5 +48,20 @@ export const useJellyseerrCanRequest = ( return userHasPermission && !canNotRequest; }, [item, jellyseerrUser]); - return canRequest; + const hasAdvancedRequestPermission = useMemo(() => { + if (!jellyseerrUser) return false; + + return hasPermission( + [ + Permission.REQUEST_ADVANCED, + Permission.MANAGE_REQUESTS + ], + jellyseerrUser.permissions, + {type: 'or'} + ) + }, + [jellyseerrUser] + ); + + return [canRequest, hasAdvancedRequestPermission]; }; diff --git a/utils/jellyseerr b/utils/jellyseerr index a15f2ab3..4401b164 160000 --- a/utils/jellyseerr +++ b/utils/jellyseerr @@ -1 +1 @@ -Subproject commit a15f2ab336936f49e38ea37f8b224da40e12588e +Subproject commit 4401b16414af604a7372dacac326c38b18ad8555