import Modal from '@app/components/Common/Modal'; import SensitiveInput from '@app/components/Common/SensitiveInput'; import globalMessages from '@app/i18n/globalMessages'; import { Transition } from '@headlessui/react'; import type { SonarrSettings } from '@server/lib/settings'; import axios from 'axios'; import { Field, Formik } from 'formik'; import { useCallback, useEffect, useRef, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import type { OnChangeValue } from 'react-select'; import Select from 'react-select'; import { useToasts } from 'react-toast-notifications'; import * as Yup from 'yup'; type OptionType = { value: number; label: string; }; const messages = defineMessages({ createsonarr: 'Add New Sonarr Server', create4ksonarr: 'Add New 4K Sonarr Server', editsonarr: 'Edit Sonarr Server', edit4ksonarr: 'Edit 4K Sonarr Server', validationNameRequired: 'You must provide a server name', validationHostnameRequired: 'You must provide a valid hostname or IP address', validationPortRequired: 'You must provide a valid port number', validationApiKeyRequired: 'You must provide an API key', validationRootFolderRequired: 'You must select a root folder', validationProfileRequired: 'You must select a quality profile', validationLanguageProfileRequired: 'You must select a language profile', toastSonarrTestSuccess: 'Sonarr connection established successfully!', toastSonarrTestFailure: 'Failed to connect to Sonarr.', add: 'Add Server', defaultserver: 'Default Server', default4kserver: 'Default 4K Server', servername: 'Server Name', hostname: 'Hostname or IP Address', port: 'Port', ssl: 'Use SSL', apiKey: 'API Key', baseUrl: 'URL Base', qualityprofile: 'Quality Profile', languageprofile: 'Language Profile', rootfolder: 'Root Folder', animequalityprofile: 'Anime Quality Profile', animelanguageprofile: 'Anime Language Profile', animerootfolder: 'Anime Root Folder', seasonfolders: 'Season Folders', server4k: '4K Server', selectQualityProfile: 'Select quality profile', selectRootFolder: 'Select root folder', selectLanguageProfile: 'Select language profile', loadingprofiles: 'Loading quality profiles…', testFirstQualityProfiles: 'Test connection to load quality profiles', loadingrootfolders: 'Loading root folders…', testFirstRootFolders: 'Test connection to load root folders', loadinglanguageprofiles: 'Loading language profiles…', testFirstLanguageProfiles: 'Test connection to load language profiles', loadingTags: 'Loading tags…', testFirstTags: 'Test connection to load tags', syncEnabled: 'Enable Scan', externalUrl: 'External URL', enableSearch: 'Enable Automatic Search', tagRequests: 'Tag Requests', tagRequestsInfo: "Automatically add an additional tag with the requester's user ID & display name", validationApplicationUrl: 'You must provide a valid URL', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationBaseUrlLeadingSlash: 'Base URL must have a leading slash', validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash', tags: 'Tags', animeTags: 'Anime Tags', notagoptions: 'No tags.', selecttags: 'Select tags', }); interface TestResponse { profiles: { id: number; name: string; }[]; rootFolders: { id: number; path: string; }[]; languageProfiles: { id: number; name: string; }[]; tags: { id: number; label: string; }[]; urlBase?: string; } interface SonarrModalProps { sonarr: SonarrSettings | null; onClose: () => void; onSave: () => void; } const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { const intl = useIntl(); const initialLoad = useRef(false); const { addToast } = useToasts(); const [isValidated, setIsValidated] = useState(sonarr ? true : false); const [isTesting, setIsTesting] = useState(false); const [testResponse, setTestResponse] = useState({ profiles: [], rootFolders: [], languageProfiles: [], tags: [], }); const SonarrSettingsSchema = Yup.object().shape({ name: Yup.string().required( intl.formatMessage(messages.validationNameRequired) ), hostname: Yup.string() .required(intl.formatMessage(messages.validationHostnameRequired)) .matches( /^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i, intl.formatMessage(messages.validationHostnameRequired) ), port: Yup.number() .nullable() .required(intl.formatMessage(messages.validationPortRequired)), apiKey: Yup.string().required( intl.formatMessage(messages.validationApiKeyRequired) ), rootFolder: Yup.string().required( intl.formatMessage(messages.validationRootFolderRequired) ), activeProfileId: Yup.string().required( intl.formatMessage(messages.validationProfileRequired) ), activeLanguageProfileId: Yup.number().required( intl.formatMessage(messages.validationLanguageProfileRequired) ), externalUrl: Yup.string() .url(intl.formatMessage(messages.validationApplicationUrl)) .test( 'no-trailing-slash', intl.formatMessage(messages.validationApplicationUrlTrailingSlash), (value) => !value || !value.endsWith('/') ), baseUrl: Yup.string() .test( 'leading-slash', intl.formatMessage(messages.validationBaseUrlLeadingSlash), (value) => !value || value.startsWith('/') ) .test( 'no-trailing-slash', intl.formatMessage(messages.validationBaseUrlTrailingSlash), (value) => !value || !value.endsWith('/') ), }); const testConnection = useCallback( async ({ hostname, port, apiKey, baseUrl, useSsl = false, }: { hostname: string; port: number; apiKey: string; baseUrl?: string; useSsl?: boolean; }) => { setIsTesting(true); try { const response = await axios.post( '/api/v1/settings/sonarr/test', { hostname, apiKey, port: Number(port), baseUrl, useSsl, } ); setIsValidated(true); setTestResponse(response.data); if (initialLoad.current) { addToast(intl.formatMessage(messages.toastSonarrTestSuccess), { appearance: 'success', autoDismiss: true, }); } } catch (e) { setIsValidated(false); if (initialLoad.current) { addToast(intl.formatMessage(messages.toastSonarrTestFailure), { appearance: 'error', autoDismiss: true, }); } } finally { setIsTesting(false); initialLoad.current = true; } }, [addToast, intl] ); useEffect(() => { if (sonarr) { testConnection({ apiKey: sonarr.apiKey, hostname: sonarr.hostname, port: sonarr.port, baseUrl: sonarr.baseUrl, useSsl: sonarr.useSsl, }); } }, [sonarr, testConnection]); return ( { try { const profileName = testResponse.profiles.find( (profile) => profile.id === Number(values.activeProfileId) )?.name; const animeProfileName = testResponse.profiles.find( (profile) => profile.id === Number(values.activeAnimeProfileId) )?.name; const submission = { name: values.name, hostname: values.hostname, port: Number(values.port), apiKey: values.apiKey, useSsl: values.ssl, baseUrl: values.baseUrl, activeProfileId: Number(values.activeProfileId), activeLanguageProfileId: values.activeLanguageProfileId ? Number(values.activeLanguageProfileId) : undefined, activeProfileName: profileName, activeDirectory: values.rootFolder, activeAnimeProfileId: values.activeAnimeProfileId ? Number(values.activeAnimeProfileId) : undefined, activeAnimeLanguageProfileId: values.activeAnimeLanguageProfileId ? Number(values.activeAnimeLanguageProfileId) : undefined, activeAnimeProfileName: animeProfileName ?? undefined, activeAnimeDirectory: values.activeAnimeRootFolder, tags: values.tags, animeTags: values.animeTags, is4k: values.is4k, isDefault: values.isDefault, enableSeasonFolders: values.enableSeasonFolders, externalUrl: values.externalUrl, syncEnabled: values.syncEnabled, preventSearch: !values.enableSearch, tagRequests: values.tagRequests, }; if (!sonarr) { await axios.post('/api/v1/settings/sonarr', submission); } else { await axios.put( `/api/v1/settings/sonarr/${sonarr.id}`, submission ); } onSave(); } catch (e) { // set error here } }} > {({ errors, touched, values, handleSubmit, setFieldValue, isSubmitting, isValid, }) => { return ( { if (values.apiKey && values.hostname && values.port) { testConnection({ apiKey: values.apiKey, baseUrl: values.baseUrl, hostname: values.hostname, port: values.port, useSsl: values.ssl, }); if (!values.baseUrl || values.baseUrl === '/') { setFieldValue('baseUrl', testResponse.urlBase); } } }} secondaryDisabled={ !values.apiKey || !values.hostname || !values.port || isTesting || isSubmitting } okDisabled={!isValidated || isSubmitting || isTesting || !isValid} onOk={() => handleSubmit()} title={ !sonarr ? intl.formatMessage( values.is4k ? messages.create4ksonarr : messages.createsonarr ) : intl.formatMessage( values.is4k ? messages.edit4ksonarr : messages.editsonarr ) } >
) => { setIsValidated(false); setFieldValue('name', e.target.value); }} />
{errors.name && touched.name && typeof errors.name === 'string' && (
{errors.name}
)}
{values.ssl ? 'https://' : 'http://'} ) => { setIsValidated(false); setFieldValue('hostname', e.target.value); }} className="rounded-r-only" />
{errors.hostname && touched.hostname && typeof errors.hostname === 'string' && (
{errors.hostname}
)}
) => { setIsValidated(false); setFieldValue('port', e.target.value); }} /> {errors.port && touched.port && typeof errors.port === 'string' && (
{errors.port}
)}
{ setIsValidated(false); setFieldValue('ssl', !values.ssl); }} />
) => { setIsValidated(false); setFieldValue('apiKey', e.target.value); }} />
{errors.apiKey && touched.apiKey && typeof errors.apiKey === 'string' && (
{errors.apiKey}
)}
) => { setIsValidated(false); setFieldValue('baseUrl', e.target.value); }} />
{errors.baseUrl && touched.baseUrl && typeof errors.baseUrl === 'string' && (
{errors.baseUrl}
)}
{testResponse.profiles.length > 0 && testResponse.profiles.map((profile) => ( ))}
{errors.activeProfileId && touched.activeProfileId && typeof errors.activeProfileId === 'string' && (
{errors.activeProfileId}
)}
{testResponse.rootFolders.length > 0 && testResponse.rootFolders.map((folder) => ( ))}
{errors.rootFolder && touched.rootFolder && typeof errors.rootFolder === 'string' && (
{errors.rootFolder}
)}
{testResponse.languageProfiles.length > 0 && testResponse.languageProfiles.map((language) => ( ))}
{errors.activeLanguageProfileId && touched.activeLanguageProfileId && (
{errors.activeLanguageProfileId}
)}
options={ isValidated ? testResponse.tags.map((tag) => ({ label: tag.label, value: tag.id, })) : [] } isMulti isDisabled={!isValidated || isTesting} placeholder={ !isValidated ? intl.formatMessage(messages.testFirstTags) : isTesting ? intl.formatMessage(messages.loadingTags) : intl.formatMessage(messages.selecttags) } isLoading={isTesting} className="react-select-container" classNamePrefix="react-select" value={ isTesting ? [] : (values.tags .map((tagId) => { const foundTag = testResponse.tags.find( (tag) => tag.id === tagId ); if (!foundTag) { return undefined; } return { value: foundTag.id, label: foundTag.label, }; }) .filter( (option) => option !== undefined ) as OptionType[]) } onChange={(value: OnChangeValue) => { setFieldValue( 'tags', value.map((option) => option.value) ); }} noOptionsMessage={() => intl.formatMessage(messages.notagoptions) } />
{testResponse.profiles.length > 0 && testResponse.profiles.map((profile) => ( ))}
{errors.activeAnimeProfileId && touched.activeAnimeProfileId && (
{errors.activeAnimeProfileId}
)}
{testResponse.rootFolders.length > 0 && testResponse.rootFolders.map((folder) => ( ))}
{errors.activeAnimeRootFolder && touched.activeAnimeRootFolder && (
{errors.rootFolder}
)}
{testResponse.languageProfiles.length > 0 && testResponse.languageProfiles.map((language) => ( ))}
{errors.activeAnimeLanguageProfileId && touched.activeAnimeLanguageProfileId && (
{errors.activeAnimeLanguageProfileId}
)}
options={ isValidated ? testResponse.tags.map((tag) => ({ label: tag.label, value: tag.id, })) : [] } isMulti isDisabled={!isValidated} placeholder={ !isValidated ? intl.formatMessage(messages.testFirstTags) : isTesting ? intl.formatMessage(messages.loadingTags) : intl.formatMessage(messages.selecttags) } isLoading={isTesting} className="react-select-container" classNamePrefix="react-select" value={ isTesting ? [] : (values.animeTags .map((tagId) => { const foundTag = testResponse.tags.find( (tag) => tag.id === tagId ); if (!foundTag) { return undefined; } return { value: foundTag.id, label: foundTag.label, }; }) .filter( (option) => option !== undefined ) as OptionType[]) } onChange={(value) => { setFieldValue( 'animeTags', value.map((option) => option.value) ); }} noOptionsMessage={() => intl.formatMessage(messages.notagoptions) } />
{errors.externalUrl && touched.externalUrl && typeof errors.externalUrl === 'string' && (
{errors.externalUrl}
)}
); }}
); }; export default SonarrModal;