feat: replace override rules with routing rules system

Replaces the override rule system with a new priority-based routing rules engine. Routing rules are
evaluated top-to-bottom with first-match-wins semantics, supporting conditions on users, genres,
languages, and keywords. Quality profiles, root folders, minimum availability, series type, and tags
move from instance-level settings to routing rules with support for instance switching, with
fallback rules acting as catch-all defaults. Includes a migration to convert existing instance
defaults and override rules into the new system, a routing resolver used at request time, updated
OpenAPI spec, and a new UI with drag-and-drop reordering, filter tabs, and inline rule expansion.

fix #232, fix #1560, fix #2058
This commit is contained in:
fallenbagel
2026-02-16 09:14:02 +08:00
parent 04b9d87174
commit 87dddbb879
18 changed files with 3241 additions and 2228 deletions

View File

@@ -1,544 +0,0 @@
import Modal from '@app/components/Common/Modal';
import LanguageSelector from '@app/components/LanguageSelector';
import {
GenreSelector,
KeywordSelector,
UserSelector,
} from '@app/components/Selector';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import type OverrideRule from '@server/entity/OverrideRule';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages('components.Settings.OverrideRuleModal', {
createrule: 'New Override Rule',
editrule: 'Edit Override Rule',
create: 'Create rule',
service: 'Service',
serviceDescription: 'Apply this rule to the selected service.',
selectService: 'Select service',
conditions: 'Conditions',
conditionsDescription:
'Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).',
settings: 'Settings',
settingsDescription:
'Specifies which settings will be changed when the above conditions are met.',
users: 'Users',
genres: 'Genres',
languages: 'Languages',
keywords: 'Keywords',
rootfolder: 'Root Folder',
selectRootFolder: 'Select root folder',
qualityprofile: 'Quality Profile',
selectQualityProfile: 'Select quality profile',
tags: 'Tags',
notagoptions: 'No tags.',
selecttags: 'Select tags',
ruleCreated: 'Override rule created successfully!',
ruleUpdated: 'Override rule updated successfully!',
});
type OptionType = {
value: number;
label: string;
};
interface OverrideRuleModalProps {
rule: OverrideRule | null;
onClose: () => void;
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
}
const OverrideRuleModal = ({
onClose,
rule,
radarrServices,
sonarrServices,
}: OverrideRuleModalProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const { currentSettings } = useSettings();
const [isValidated, setIsValidated] = useState(rule ? true : false);
const [isTesting, setIsTesting] = useState(false);
const [testResponse, setTestResponse] = useState<DVRTestResponse>({
profiles: [],
rootFolders: [],
tags: [],
});
const getServiceInfos = useCallback(
async (
{
hostname,
port,
apiKey,
baseUrl,
useSsl = false,
}: {
hostname: string;
port: number;
apiKey: string;
baseUrl?: string;
useSsl?: boolean;
},
type: 'radarr' | 'sonarr'
) => {
setIsTesting(true);
try {
const response = await axios.post<DVRTestResponse>(
`/api/v1/settings/${type}/test`,
{
hostname,
apiKey,
port: Number(port),
baseUrl,
useSsl,
}
);
setIsValidated(true);
setTestResponse(response.data);
} catch (e) {
setIsValidated(false);
} finally {
setIsTesting(false);
}
},
[]
);
useEffect(() => {
if (
rule?.radarrServiceId !== null &&
rule?.radarrServiceId !== undefined &&
radarrServices[rule?.radarrServiceId]
) {
getServiceInfos(radarrServices[rule?.radarrServiceId], 'radarr');
}
if (
rule?.sonarrServiceId !== null &&
rule?.sonarrServiceId !== undefined &&
sonarrServices[rule?.sonarrServiceId]
) {
getServiceInfos(sonarrServices[rule?.sonarrServiceId], 'sonarr');
}
}, [
getServiceInfos,
radarrServices,
rule?.radarrServiceId,
rule?.sonarrServiceId,
sonarrServices,
]);
return (
<Transition
as="div"
appear
show
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Formik
initialValues={{
radarrServiceId: rule?.radarrServiceId,
sonarrServiceId: rule?.sonarrServiceId,
users: rule?.users,
genre: rule?.genre,
language: rule?.language,
keywords: rule?.keywords,
profileId: rule?.profileId,
rootFolder: rule?.rootFolder,
tags: rule?.tags,
}}
onSubmit={async (values) => {
try {
const submission = {
users: values.users || null,
genre: values.genre || null,
language: values.language || null,
keywords: values.keywords || null,
profileId: Number(values.profileId) || null,
rootFolder: values.rootFolder || null,
tags: values.tags || null,
radarrServiceId: values.radarrServiceId,
sonarrServiceId: values.sonarrServiceId,
};
if (!rule) {
await axios.post('/api/v1/overrideRule', submission);
addToast(intl.formatMessage(messages.ruleCreated), {
appearance: 'success',
autoDismiss: true,
});
} else {
await axios.put(`/api/v1/overrideRule/${rule.id}`, submission);
addToast(intl.formatMessage(messages.ruleUpdated), {
appearance: 'success',
autoDismiss: true,
});
}
onClose();
} catch (e) {
// set error here
}
}}
>
{({
errors,
touched,
values,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
}) => {
return (
<Modal
onCancel={onClose}
okButtonType="primary"
okText={
isSubmitting
? intl.formatMessage(globalMessages.saving)
: rule
? intl.formatMessage(globalMessages.save)
: intl.formatMessage(messages.create)
}
okDisabled={
isSubmitting ||
!isValid ||
(!values.users &&
!values.genre &&
!values.language &&
!values.keywords) ||
(!values.rootFolder && !values.profileId && !values.tags)
}
onOk={() => handleSubmit()}
title={
!rule
? intl.formatMessage(messages.createrule)
: intl.formatMessage(messages.editrule)
}
>
<div className="mb-6">
<h3 className="text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.service)}
</h3>
<p className="description">
{intl.formatMessage(messages.serviceDescription)}
</p>
<div className="form-row">
<label htmlFor="service" className="text-label">
{intl.formatMessage(messages.service)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<select
id="service"
name="service"
defaultValue={
values.radarrServiceId !== null
? `radarr-${values.radarrServiceId}`
: `sonarr-${values.sonarrServiceId}`
}
onChange={(e) => {
const id = Number(e.target.value.split('-')[1]);
if (e.target.value.startsWith('radarr-')) {
setFieldValue('radarrServiceId', id);
setFieldValue('sonarrServiceId', null);
if (radarrServices[id]) {
getServiceInfos(radarrServices[id], 'radarr');
}
} else if (e.target.value.startsWith('sonarr-')) {
setFieldValue('radarrServiceId', null);
setFieldValue('sonarrServiceId', id);
if (sonarrServices[id]) {
getServiceInfos(sonarrServices[id], 'sonarr');
}
} else {
setFieldValue('radarrServiceId', null);
setFieldValue('sonarrServiceId', null);
setIsValidated(false);
}
}}
>
<option value="">
{intl.formatMessage(messages.selectService)}
</option>
{radarrServices.map((radarr) => (
<option
key={`radarr-${radarr.id}`}
value={`radarr-${radarr.id}`}
>
{radarr.name}
</option>
))}
{sonarrServices.map((sonarr) => (
<option
key={`sonarr-${sonarr.id}`}
value={`sonarr-${sonarr.id}`}
>
{sonarr.name}
</option>
))}
</select>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
<h3 className="text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.conditions)}
</h3>
<p className="description">
{intl.formatMessage(messages.conditionsDescription)}
</p>
<div className="form-row">
<label htmlFor="users" className="text-label">
{intl.formatMessage(messages.users)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<UserSelector
defaultValue={values.users}
isDisabled={!isValidated || isTesting}
isMulti
onChange={(users) => {
setFieldValue(
'users',
users?.map((v) => v.value).join(',')
);
}}
/>
</div>
{errors.users &&
touched.users &&
typeof errors.users === 'string' && (
<div className="error">{errors.users}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="genre" className="text-label">
{intl.formatMessage(messages.genres)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<GenreSelector
type={
values.radarrServiceId != null
? 'movie'
: values.sonarrServiceId != null
? 'tv'
: 'tv'
}
defaultValue={values.genre}
isMulti
isDisabled={!isValidated || isTesting}
onChange={(genres) => {
setFieldValue(
'genre',
genres?.map((v) => v.value).join(',')
);
}}
/>
</div>
{errors.genre &&
touched.genre &&
typeof errors.genre === 'string' && (
<div className="error">{errors.genre}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="language" className="text-label">
{intl.formatMessage(messages.languages)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<LanguageSelector
value={values.language}
serverValue={currentSettings.originalLanguage}
setFieldValue={(_key, value) => {
setFieldValue('language', value);
}}
isDisabled={!isValidated || isTesting}
/>
</div>
{errors.language &&
touched.language &&
typeof errors.language === 'string' && (
<div className="error">{errors.language}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="keywords" className="text-label">
{intl.formatMessage(messages.keywords)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<KeywordSelector
defaultValue={values.keywords}
isMulti
isDisabled={!isValidated || isTesting}
onChange={(value) => {
setFieldValue(
'keywords',
value?.map((v) => v.value).join(',')
);
}}
/>
</div>
{errors.keywords &&
touched.keywords &&
typeof errors.keywords === 'string' && (
<div className="error">{errors.keywords}</div>
)}
</div>
</div>
<h3 className="mt-4 text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.settings)}
</h3>
<p className="description">
{intl.formatMessage(messages.settingsDescription)}
</p>
<div className="form-row">
<label htmlFor="rootFolderRule" className="text-label">
{intl.formatMessage(messages.rootfolder)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="rootFolderRule"
name="rootFolder"
disabled={!isValidated || isTesting}
>
<option value="">
{intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="profileIdRule" className="text-label">
{intl.formatMessage(messages.qualityprofile)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="profileIdRule"
name="profileId"
disabled={!isValidated || isTesting}
>
<option value="">
{intl.formatMessage(messages.selectQualityProfile)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.profileId &&
touched.profileId &&
typeof errors.profileId === 'string' && (
<div className="error">{errors.profileId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
options={testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))}
isMulti
isDisabled={!isValidated || isTesting}
placeholder={intl.formatMessage(messages.selecttags)}
className="react-select-container"
classNamePrefix="react-select"
value={
(values?.tags
?.split(',')
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === Number(tagId)
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[]) || []
}
onChange={(value) => {
setFieldValue(
'tags',
value.map((option) => option.value).join(',')
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
</div>
</Modal>
);
}}
</Formik>
</Transition>
);
};
export default OverrideRuleModal;

View File

@@ -1,309 +0,0 @@
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { PencilIcon, TrashIcon } from '@heroicons/react/24/solid';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import type OverrideRule from '@server/entity/OverrideRule';
import type { User } from '@server/entity/User';
import type {
DVRSettings,
Language,
RadarrSettings,
SonarrSettings,
} from '@server/lib/settings';
import type { Keyword } from '@server/models/common';
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.Settings.OverrideRuleTile', {
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
tags: 'Tags',
users: 'Users',
genre: 'Genre',
language: 'Language',
keywords: 'Keywords',
conditions: 'Conditions',
settings: 'Settings',
});
interface OverrideRuleTilesProps {
rules: OverrideRule[];
setOverrideRuleModal: ({
open,
rule,
}: {
open: boolean;
rule: OverrideRule | null;
}) => void;
revalidate: () => void;
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
}
const OverrideRuleTiles = ({
rules,
setOverrideRuleModal,
revalidate,
radarrServices,
sonarrServices,
}: OverrideRuleTilesProps) => {
const intl = useIntl();
const [users, setUsers] = useState<User[] | null>(null);
const [keywords, setKeywords] = useState<Keyword[] | null>(null);
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/movie');
const [testResponses, setTestResponses] = useState<
(DVRTestResponse & { type: string; id: number })[]
>([]);
const getServiceInfos = useCallback(async () => {
const results: (DVRTestResponse & { type: string; id: number })[] = [];
const services: DVRSettings[] = [...radarrServices, ...sonarrServices];
for (const service of services) {
const { hostname, port, apiKey, baseUrl, useSsl = false } = service;
try {
const response = await axios.post<DVRTestResponse>(
`/api/v1/settings/${
radarrServices.includes(service as RadarrSettings)
? 'radarr'
: 'sonarr'
}/test`,
{
hostname,
apiKey,
port: Number(port),
baseUrl,
useSsl,
}
);
results.push({
type: radarrServices.includes(service as RadarrSettings)
? 'radarr'
: 'sonarr',
id: service.id,
...response.data,
});
} catch {
results.push({
type: radarrServices.includes(service as RadarrSettings)
? 'radarr'
: 'sonarr',
id: service.id,
profiles: [],
rootFolders: [],
tags: [],
});
}
}
setTestResponses(results);
}, [radarrServices, sonarrServices]);
useEffect(() => {
getServiceInfos();
}, [getServiceInfos]);
useEffect(() => {
(async () => {
const keywords = await Promise.all(
rules
.map((rule) => rule.keywords?.split(','))
.flat()
.filter((keywordId) => keywordId)
.map(async (keywordId) => {
const response = await axios.get<Keyword | null>(
`/api/v1/keyword/${keywordId}`
);
return response.data;
})
);
const validKeywords: Keyword[] = keywords.filter(
(keyword): keyword is Keyword => keyword !== null
);
setKeywords(validKeywords);
const allUsersFromRules = rules
.map((rule) => rule.users)
.filter((users) => users)
.join(',');
if (allUsersFromRules) {
const response = await axios.get(
`/api/v1/user?includeIds=${encodeURIComponent(allUsersFromRules)}`
);
const users: User[] = response.data.results;
setUsers(users);
}
})();
}, [rules, users]);
return (
<>
{rules.map((rule) => (
<li className="flex h-full flex-col rounded-lg bg-gray-800 text-left shadow ring-1 ring-gray-500">
<div className="flex w-full flex-1 items-center justify-between space-x-6 p-6">
<div className="flex-1 truncate">
<span className="text-lg">
{intl.formatMessage(messages.conditions)}
</span>
{rule.users && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.users)}
</span>
<div className="inline-flex gap-2">
{rule.users.split(',').map((userId) => {
return (
<span>
{
users?.find((user) => user.id === Number(userId))
?.displayName
}
</span>
);
})}
</div>
</p>
)}
{rule.genre && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.genre)}
</span>
<div className="inline-flex gap-2">
{rule.genre.split(',').map((genreId) => (
<span>
{genres?.find((g) => g.id === Number(genreId))?.name}
</span>
))}
</div>
</p>
)}
{rule.language && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.language)}
</span>
<div className="inline-flex gap-2">
{rule.language
.split('|')
.filter((languageId) => languageId !== 'server')
.map((languageId) => {
const language = languages?.find(
(language) => language.iso_639_1 === languageId
);
if (!language) return null;
const languageName =
intl.formatDisplayName(language.iso_639_1, {
type: 'language',
fallback: 'none',
}) ?? language.english_name;
return <span>{languageName}</span>;
})}
</div>
</p>
)}
{rule.keywords && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.keywords)}
</span>
<div className="inline-flex gap-2">
{rule.keywords.split(',').map((keywordId) => {
return (
<span>
{
keywords?.find(
(keyword) => keyword.id === Number(keywordId)
)?.name
}
</span>
);
})}
</div>
</p>
)}
<span className="text-lg">
{intl.formatMessage(messages.settings)}
</span>
{rule.profileId && (
<p className="runcate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.qualityprofile)}
</span>
{testResponses
.find(
(r) =>
(r.id === rule.radarrServiceId &&
r.type === 'radarr') ||
(r.id === rule.sonarrServiceId && r.type === 'sonarr')
)
?.profiles.find((profile) => rule.profileId === profile.id)
?.name || rule.profileId}
</p>
)}
{rule.rootFolder && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.rootfolder)}
</span>
{rule.rootFolder}
</p>
)}
{rule.tags && rule.tags.length > 0 && (
<p className="truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.tags)}
</span>
<div className="inline-flex gap-2">
{rule.tags.split(',').map((tag) => (
<span>
{testResponses
.find(
(r) =>
(r.id === rule.radarrServiceId &&
r.type === 'radarr') ||
(r.id === rule.sonarrServiceId &&
r.type === 'sonarr')
)
?.tags?.find((t) => t.id === Number(tag))?.label ||
tag}
</span>
))}
</div>
</p>
)}
</div>
</div>
<div className="border-t border-gray-500">
<div className="-mt-px flex">
<div className="flex w-0 flex-1 border-r border-gray-500">
<button
onClick={() => setOverrideRuleModal({ open: true, rule })}
className="focus:ring-blue relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
>
<PencilIcon className="mr-2 h-5 w-5" />
<span>{intl.formatMessage(globalMessages.edit)}</span>
</button>
</div>
<div className="-ml-px flex w-0 flex-1">
<button
onClick={async () => {
await axios.delete(`/api/v1/overrideRule/${rule.id}`);
revalidate();
}}
className="focus:ring-blue relative inline-flex w-0 flex-1 items-center justify-center rounded-br-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
>
<TrashIcon className="mr-2 h-5 w-5" />
<span>{intl.formatMessage(globalMessages.delete)}</span>
</button>
</div>
</div>
</div>
</li>
))}
</>
);
};
export default OverrideRuleTiles;

View File

@@ -10,15 +10,9 @@ import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
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('components.Settings.RadarrModal', {
createradarr: 'Add New Radarr Server',
create4kradarr: 'Add New 4K Radarr Server',
@@ -28,10 +22,6 @@ const messages = defineMessages('components.Settings.RadarrModal', {
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',
validationMinimumAvailabilityRequired:
'You must select a minimum availability',
toastRadarrTestSuccess: 'Radarr connection established successfully!',
toastRadarrTestFailure: 'Failed to connect to Radarr.',
add: 'Add Server',
@@ -43,22 +33,9 @@ const messages = defineMessages('components.Settings.RadarrModal', {
ssl: 'Use SSL',
apiKey: 'API Key',
baseUrl: 'URL Base',
server4k: '4K Server',
syncEnabled: 'Enable Scan',
externalUrl: 'External URL',
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
minimumAvailability: 'Minimum Availability',
server4k: '4K Server',
selectQualityProfile: 'Select quality profile',
selectRootFolder: 'Select root folder',
selectMinimumAvailability: 'Select minimum availability',
loadingprofiles: 'Loading quality profiles…',
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test connection to load root folders',
loadingTags: 'Loading tags…',
testFirstTags: 'Test connection to load tags',
tags: 'Tags',
enableSearch: 'Enable Automatic Search',
tagRequests: 'Tag Requests',
tagRequestsInfo:
@@ -67,17 +44,12 @@ const messages = defineMessages('components.Settings.RadarrModal', {
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
validationBaseUrlTrailingSlash: 'URL base must not end in a trailing slash',
notagoptions: 'No tags.',
selecttags: 'Select tags',
announced: 'Announced',
inCinemas: 'In Cinemas',
released: 'Released',
});
interface RadarrModalProps {
radarr: RadarrSettings | null;
onClose: () => void;
onSave: () => void;
onSave: (savedInstance: RadarrSettings) => Promise<void>;
}
const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
@@ -105,15 +77,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
apiKey: Yup.string().required(
intl.formatMessage(messages.validationApiKeyRequired)
),
rootFolder: Yup.string().required(
intl.formatMessage(messages.validationRootFolderRequired)
),
activeProfileId: Yup.string().required(
intl.formatMessage(messages.validationProfileRequired)
),
minimumAvailability: Yup.string().required(
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
),
externalUrl: Yup.string()
.test(
'valid-url',
@@ -221,10 +184,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
ssl: radarr?.useSsl ?? false,
apiKey: radarr?.apiKey,
baseUrl: radarr?.baseUrl,
activeProfileId: radarr?.activeProfileId,
rootFolder: radarr?.activeDirectory,
minimumAvailability: radarr?.minimumAvailability ?? 'released',
tags: radarr?.tags ?? [],
isDefault: radarr?.isDefault ?? false,
is4k: radarr?.is4k ?? false,
externalUrl: radarr?.externalUrl,
@@ -235,10 +194,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
validationSchema={RadarrSettingsSchema}
onSubmit={async (values) => {
try {
const profileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeProfileId)
)?.name;
const submission = {
name: values.name,
hostname: values.hostname,
@@ -246,30 +201,32 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
apiKey: values.apiKey,
useSsl: values.ssl,
baseUrl: values.baseUrl,
activeProfileId: Number(values.activeProfileId),
activeProfileName: profileName,
activeDirectory: values.rootFolder,
is4k: values.is4k,
minimumAvailability: values.minimumAvailability,
tags: values.tags,
isDefault: values.isDefault,
is4k: values.is4k,
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
};
let savedInstance: RadarrSettings;
if (!radarr) {
await axios.post('/api/v1/settings/radarr', submission);
const response = await axios.post<RadarrSettings>(
'/api/v1/settings/radarr',
submission
);
savedInstance = response.data;
} else {
await axios.put(
const response = await axios.put<RadarrSettings>(
`/api/v1/settings/radarr/${radarr.id}`,
submission
);
savedInstance = response.data;
}
onSave();
await onSave(savedInstance);
} catch (e) {
// set error here
// TODO: handle error
}
}}
>
@@ -501,176 +458,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="activeProfileId" className="text-label">
{intl.formatMessage(messages.qualityprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeProfileId"
name="activeProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(
messages.selectQualityProfile
)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeProfileId &&
touched.activeProfileId &&
typeof errors.activeProfileId === 'string' && (
<div className="error">{errors.activeProfileId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="rootFolder" className="text-label">
{intl.formatMessage(messages.rootfolder)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="rootFolder"
name="rootFolder"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(
messages.testFirstRootFolders
)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="minimumAvailability" className="text-label">
{intl.formatMessage(messages.minimumAvailability)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="minimumAvailability"
name="minimumAvailability"
>
<option value="announced">
{intl.formatMessage(messages.announced)}
</option>
<option value="inCinemas">
{intl.formatMessage(messages.inCinemas)}
</option>
<option value="released">
{intl.formatMessage(messages.released)}
</option>
</Field>
</div>
{errors.minimumAvailability &&
touched.minimumAvailability && (
<div className="error">
{errors.minimumAvailability}
</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
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)
}
className="react-select-container"
classNamePrefix="react-select"
value={
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) => {
setFieldValue(
'tags',
value.map((option) => option.value)
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="externalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}

View File

@@ -0,0 +1,599 @@
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal';
import RoutingRuleRow from '@app/components/Settings/RoutingRule/RoutingRuleRow';
import type { RoutingRule } from '@app/components/Settings/RoutingRule/types';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import {
ExclamationTriangleIcon,
InformationCircleIcon,
PlusIcon,
} from '@heroicons/react/24/solid';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import type { User } from '@server/entity/User';
import type {
Language,
RadarrSettings,
SonarrSettings,
} from '@server/lib/settings';
import type { Keyword } from '@server/models/common';
import axios from 'axios';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
type FilterType = 'all' | 'sonarr' | 'radarr';
type ServiceType = 'radarr' | 'sonarr';
const messages = defineMessages('components.Settings.RoutingRuleList', {
routingRules: 'Routing Rules',
routingRulesDescription:
'Rules are evaluated top-to-bottom. The first matching rule determines where the request is sent. Drag to reorder priority.',
routingRulesConditionLogic:
'Conditions use AND logic between fields (all must match) and OR logic within a field (any value can match).',
addRule: 'Add Rule',
all: 'All',
sonarr: 'Sonarr',
radarr: 'Radarr',
howRoutingWorks: 'How routing works:',
routingExplainer:
'When a request comes in, rules are checked from top to bottom. The first rule whose conditions all match will determine which instance and settings are used. Fallback rules (no conditions) catch everything that did not match above.',
noFallbackWarning:
'No fallback rule configured for {serviceType}. Requests that do not match any rule will fail.',
deleteConfirm: 'Are you sure you want to delete this routing rule?',
deleteRule: 'Delete Routing Rule',
animeRuleSuggestion:
'Want anime to use different settings? Add an anime routing rule.',
addAnimeRule: 'Add Anime Rule',
});
interface RoutingRuleListProps {
rules: RoutingRule[];
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
onAddRule: (prefillData?: Partial<RoutingRule>) => void;
onEditRule: (rule: RoutingRule) => void;
revalidate: () => void;
}
const isFallbackRule = (r: RoutingRule) => !!r.isFallback;
const hasFallback = (
rules: RoutingRule[],
serviceType: ServiceType,
is4k: boolean
) =>
rules.some(
(r) => r.serviceType === serviceType && !!r.isFallback && !!r.is4k === is4k
);
function getDefaultInstance(
serviceType: ServiceType,
is4k: boolean,
radarrServices: RadarrSettings[],
sonarrServices: SonarrSettings[]
) {
const services = serviceType === 'radarr' ? radarrServices : sonarrServices;
return services.find((s) => !!s.isDefault && !!s.is4k === is4k);
}
const RoutingRuleList = ({
rules,
radarrServices,
sonarrServices,
onAddRule,
onEditRule,
revalidate,
}: RoutingRuleListProps) => {
const intl = useIntl();
const [filter, setFilter] = useState<FilterType>('all');
const [expandedId, setExpandedId] = useState<number | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [deleteModal, setDeleteModal] = useState<{
open: boolean;
rule: RoutingRule | null;
}>({ open: false, rule: null });
const [users, setUsers] = useState<User[]>([]);
const [keywordsData, setKeywordsData] = useState<Keyword[]>([]);
const [testResponses, setTestResponses] = useState<
(DVRTestResponse & { type: string; id: number })[]
>([]);
const [localOrder, setLocalOrder] = useState<RoutingRule[] | null>(null);
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/movie');
const radarrDefaultNon4k = useMemo(
() => getDefaultInstance('radarr', false, radarrServices, sonarrServices),
[radarrServices, sonarrServices]
);
const radarrDefault4k = useMemo(
() => getDefaultInstance('radarr', true, radarrServices, sonarrServices),
[radarrServices, sonarrServices]
);
const sonarrDefaultNon4k = useMemo(
() => getDefaultInstance('sonarr', false, radarrServices, sonarrServices),
[radarrServices, sonarrServices]
);
const sonarrDefault4k = useMemo(
() => getDefaultInstance('sonarr', true, radarrServices, sonarrServices),
[radarrServices, sonarrServices]
);
const missingFallbacks = useMemo(
() => ({
radarrNon4k: !!radarrDefaultNon4k && !hasFallback(rules, 'radarr', false),
radarr4k: !!radarrDefault4k && !hasFallback(rules, 'radarr', true),
sonarrNon4k: !!sonarrDefaultNon4k && !hasFallback(rules, 'sonarr', false),
sonarr4k: !!sonarrDefault4k && !hasFallback(rules, 'sonarr', true),
}),
[
rules,
radarrDefaultNon4k,
radarrDefault4k,
sonarrDefaultNon4k,
sonarrDefault4k,
]
);
const getServiceInfos = useCallback(async () => {
const results: (DVRTestResponse & { type: string; id: number })[] = [];
const allServices = [
...radarrServices.map((s) => ({ ...s, _type: 'radarr' as const })),
...sonarrServices.map((s) => ({ ...s, _type: 'sonarr' as const })),
];
for (const service of allServices) {
try {
const response = await axios.post<DVRTestResponse>(
`/api/v1/settings/${service._type}/test`,
{
hostname: service.hostname,
apiKey: service.apiKey,
port: Number(service.port),
baseUrl: service.baseUrl,
useSsl: service.useSsl,
}
);
results.push({
type: service._type,
id: service.id,
...response.data,
});
} catch {
results.push({
type: service._type,
id: service.id,
profiles: [],
rootFolders: [],
tags: [],
});
}
}
setTestResponses(results);
}, [radarrServices, sonarrServices]);
useEffect(() => {
getServiceInfos();
}, [getServiceInfos]);
useEffect(() => {
(async () => {
const allKeywordIds = rules
.map((rule) => rule.keywords?.split(','))
.flat()
.filter((id): id is string => !!id);
if (allKeywordIds.length > 0) {
const keywordResults = await Promise.all(
[...new Set(allKeywordIds)].map(async (id) => {
try {
const response = await axios.get<Keyword | null>(
`/api/v1/keyword/${id}`
);
return response.data;
} catch {
return null;
}
})
);
setKeywordsData(keywordResults.filter((k): k is Keyword => k !== null));
}
const allUserIds = rules
.map((rule) => rule.users)
.filter((u): u is string => !!u)
.join(',');
if (allUserIds) {
try {
const response = await axios.get(
`/api/v1/user?includeIds=${encodeURIComponent(allUserIds)}`
);
setUsers(response.data.results);
} catch {
// ignore
}
}
})();
}, [rules]);
const sortedRules = useMemo(() => {
return [...rules].sort((a, b) => {
const aFallback = isFallbackRule(a);
const bFallback = isFallbackRule(b);
if (aFallback && !bFallback) return 1;
if (!aFallback && bFallback) return -1;
if (aFallback && bFallback) {
const a4k = !!a.is4k;
const b4k = !!b.is4k;
if (a4k !== b4k) return a4k ? 1 : -1;
return 0;
}
return b.priority - a.priority;
});
}, [rules]);
const filteredRules = useMemo(
() =>
sortedRules.filter((r) => filter === 'all' || r.serviceType === filter),
[sortedRules, filter]
);
const displayRules = localOrder ?? filteredRules;
const counts = {
all: rules.length,
sonarr: rules.filter((r) => r.serviceType === 'sonarr').length,
radarr: rules.filter((r) => r.serviceType === 'radarr').length,
};
const openMissingFallbackIfAny = () => {
const pickForService = (svc: ServiceType) => {
if (svc === 'radarr') {
if (missingFallbacks.radarrNon4k && radarrDefaultNon4k) {
return {
serviceType: 'radarr' as const,
is4k: false,
instance: radarrDefaultNon4k,
};
}
if (missingFallbacks.radarr4k && radarrDefault4k) {
return {
serviceType: 'radarr' as const,
is4k: true,
instance: radarrDefault4k,
};
}
} else {
if (missingFallbacks.sonarrNon4k && sonarrDefaultNon4k) {
return {
serviceType: 'sonarr' as const,
is4k: false,
instance: sonarrDefaultNon4k,
};
}
if (missingFallbacks.sonarr4k && sonarrDefault4k) {
return {
serviceType: 'sonarr' as const,
is4k: true,
instance: sonarrDefault4k,
};
}
}
return null;
};
let target: {
serviceType: ServiceType;
is4k: boolean;
instance: RadarrSettings | SonarrSettings;
} | null = null;
if (filter === 'radarr') target = pickForService('radarr');
else if (filter === 'sonarr') target = pickForService('sonarr');
else {
target = pickForService('radarr') ?? pickForService('sonarr');
}
if (!target) return false;
onAddRule({
name: `${target.instance.name} Default Route`,
serviceType: target.serviceType,
is4k: target.is4k,
targetServiceId: target.instance.id,
isFallback: true,
});
return true;
};
const missingAnimeRule = useMemo(() => {
const hasSonarrFallback = (is4k: boolean) =>
rules.some(
(r) => r.serviceType === 'sonarr' && r.isFallback && !!r.is4k === is4k
);
const hasAnimeRule = (is4k: boolean) =>
rules.some(
(r) =>
r.serviceType === 'sonarr' &&
!!r.is4k === is4k &&
r.keywords?.includes('210024')
);
return {
non4k: hasSonarrFallback(false) && !hasAnimeRule(false),
is4k: hasSonarrFallback(true) && !hasAnimeRule(true),
};
}, [rules]);
const handleAddRuleClick = () => {
if (openMissingFallbackIfAny()) return;
onAddRule();
};
const handleDragStart = (index: number) => {
setDragIndex(index);
setLocalOrder([...filteredRules]);
};
const handleDragOver = (
e: React.DragEvent<HTMLDivElement>,
index: number
) => {
e.preventDefault();
if (dragIndex === null || dragIndex === index || !localOrder) return;
if (
isFallbackRule(localOrder[index]) ||
isFallbackRule(localOrder[dragIndex])
) {
return;
}
const reordered = [...localOrder];
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(index, 0, moved);
setLocalOrder(reordered);
setDragIndex(index);
};
const handleDragEnd = async () => {
if (localOrder) {
const nonFallbackIds = localOrder
.filter((r) => !isFallbackRule(r))
.map((r) => r.id);
try {
await axios.post('/api/v1/routingRule/reorder', {
ruleIds: nonFallbackIds,
});
revalidate();
} catch {
revalidate();
}
}
setDragIndex(null);
setLocalOrder(null);
};
const handleDelete = async (rule: RoutingRule) => {
setDeleteModal({ open: true, rule });
};
const confirmDelete = async () => {
if (!deleteModal.rule) return;
try {
await axios.delete(`/api/v1/routingRule/${deleteModal.rule.id}`);
revalidate();
if (expandedId === deleteModal.rule.id) {
setExpandedId(null);
}
} catch {
// ignore
} finally {
setDeleteModal({ open: false, rule: null });
}
};
return (
<div>
<Transition
as={Fragment}
show={deleteModal.open}
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Modal
title={intl.formatMessage(messages.deleteRule)}
okText={intl.formatMessage(globalMessages.delete)}
okButtonType="danger"
onOk={() => confirmDelete()}
onCancel={() => setDeleteModal({ open: false, rule: null })}
>
{intl.formatMessage(messages.deleteConfirm)}
</Modal>
</Transition>
<div className="mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h3 className="heading">
{intl.formatMessage(messages.routingRules)}
</h3>
</div>
<Button
buttonType="ghost"
disabled={
radarrServices.length === 0 && sonarrServices.length === 0
}
onClick={handleAddRuleClick}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addRule)}</span>
</Button>
</div>
<p className="description">
{intl.formatMessage(messages.routingRulesDescription)}
</p>
</div>
<div className="mb-4 flex gap-1 rounded-lg bg-gray-800 p-1 ring-1 ring-gray-700">
{(
[
{ key: 'all', label: messages.all },
{ key: 'sonarr', label: messages.sonarr },
{ key: 'radarr', label: messages.radarr },
] as const
).map((tab) => (
<button
key={tab.key}
onClick={() => setFilter(tab.key)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-all ${
filter === tab.key
? 'bg-gray-700 text-white shadow-sm'
: 'text-gray-400 hover:text-gray-300'
}`}
>
{intl.formatMessage(tab.label)}
<span
className={`ml-1.5 ${
filter === tab.key ? 'text-gray-400' : 'text-gray-600'
}`}
>
{counts[tab.key]}
</span>
</button>
))}
</div>
{sonarrDefaultNon4k && missingFallbacks.sonarrNon4k && (
<div className="mb-3 flex items-center gap-2 rounded-lg bg-yellow-900/20 px-4 py-2 ring-1 ring-yellow-700/50">
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
<span className="text-sm text-yellow-200">
{intl.formatMessage(messages.noFallbackWarning, {
serviceType: 'Sonarr',
})}
</span>
</div>
)}
{sonarrDefault4k && missingFallbacks.sonarr4k && (
<div className="mb-3 flex items-center gap-2 rounded-lg bg-yellow-900/20 px-4 py-2 ring-1 ring-yellow-700/50">
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
<span className="text-sm text-yellow-200">
{intl.formatMessage(messages.noFallbackWarning, {
serviceType: 'Sonarr (4K)',
})}
</span>
</div>
)}
{missingAnimeRule.non4k && (
<div className="mb-3 flex items-center justify-between rounded-lg bg-blue-900/20 px-4 py-2 ring-1 ring-blue-700/50">
<div className="flex items-center gap-2">
<InformationCircleIcon className="h-4 w-4 text-blue-400" />
<span className="text-sm text-blue-200">
{intl.formatMessage(messages.animeRuleSuggestion)}
</span>
</div>
<Button
buttonType="ghost"
className="text-xs"
onClick={() => {
const sonarrFallback = rules.find(
(r) => r.serviceType === 'sonarr' && r.isFallback && !r.is4k
);
onAddRule({
name: 'Anime',
serviceType: 'sonarr',
is4k: false,
targetServiceId: sonarrFallback?.targetServiceId,
keywords: '210024',
seriesType: 'anime',
});
}}
>
{intl.formatMessage(messages.addAnimeRule)}
</Button>
</div>
)}
{radarrDefaultNon4k && missingFallbacks.radarrNon4k && (
<div className="mb-3 flex items-center gap-2 rounded-lg bg-yellow-900/20 px-4 py-2 ring-1 ring-yellow-700/50">
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
<span className="text-sm text-yellow-200">
{intl.formatMessage(messages.noFallbackWarning, {
serviceType: 'Radarr',
})}
</span>
</div>
)}
{radarrDefault4k && missingFallbacks.radarr4k && (
<div className="mb-3 flex items-center gap-2 rounded-lg bg-yellow-900/20 px-4 py-2 ring-1 ring-yellow-700/50">
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
<span className="text-sm text-yellow-200">
{intl.formatMessage(messages.noFallbackWarning, {
serviceType: 'Radarr (4K)',
})}
</span>
</div>
)}
<div className="space-y-2">
{displayRules.map((rule, index) => (
<div
key={rule.id}
draggable={!isFallbackRule(rule)}
onDragStart={() => handleDragStart(index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, index)}
>
<RoutingRuleRow
rule={rule}
index={index}
expanded={expandedId === rule.id}
isDragging={dragIndex === index}
onToggle={() =>
setExpandedId(expandedId === rule.id ? null : rule.id)
}
onEdit={() => onEditRule(rule)}
onDelete={() => handleDelete(rule)}
dragHandleProps={{}}
users={users}
genres={genres}
languages={languages}
keywords={keywordsData}
radarrServices={radarrServices}
sonarrServices={sonarrServices}
testResponses={testResponses}
/>
</div>
))}
</div>
{filteredRules.length > 0 && (
<div className="mt-4 rounded-lg bg-gray-800 p-4 ring-1 ring-gray-700">
<div className="flex gap-3">
<InformationCircleIcon className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-500" />
<div className="text-xs leading-relaxed text-gray-500">
<span className="font-medium text-gray-400">
{intl.formatMessage(messages.howRoutingWorks)}
</span>{' '}
{intl.formatMessage(messages.routingExplainer)}
</div>
</div>
</div>
)}
</div>
);
};
export default RoutingRuleList;

View File

@@ -0,0 +1,797 @@
import Badge from '@app/components/Common/Badge';
import Modal from '@app/components/Common/Modal';
import LanguageSelector from '@app/components/LanguageSelector';
import {
GenreSelector,
KeywordSelector,
UserSelector,
} from '@app/components/Selector';
import type { RoutingRule } from '@app/components/Settings/RoutingRule/types';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { InformationCircleIcon } from '@heroicons/react/24/solid';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
type OptionType = { value: number; label: string };
type ServiceType = 'radarr' | 'sonarr';
const messages = defineMessages('components.Settings.RoutingRuleModal', {
createRule: 'New Routing Rule',
editRule: 'Edit Routing Rule',
create: 'Create Rule',
ruleName: 'Rule Name',
ruleNamePlaceholder: 'e.g. Anime Content, Japanese Dramas',
serviceType: 'Service Type',
targetInstance: 'Target Instance',
selectInstance: 'Select instance',
firstInstanceSetup: 'First instance setup!',
firstInstanceSetup4k: 'First 4K instance setup!',
firstInstanceSetupBody:
'Were creating a fallback rule that catches all {mediaType} requests. You can customize defaults below or save to use instance defaults.',
fallbackMustBeDefault: 'Fallback rules must target a default instance.',
fallbackMustBe4k:
'This fallback is for 4K requests, so it must target a 4K instance.',
nonFallbackNeedsCondition:
'Non-fallback rules must have at least one condition.',
conditions: 'Conditions',
conditionsDescription:
'All condition types must match (AND). Within each type, any value can match (OR). Leave all empty for a fallback rule.',
target: 'Target Settings',
targetDescription:
'Override settings for the target instance. Leave empty to use instance defaults.',
users: 'Users',
genres: 'Genres',
languages: 'Languages',
keywords: 'Keywords',
rootFolder: 'Root Folder',
selectRootFolder: 'Select root folder',
qualityProfile: 'Quality Profile',
selectQualityProfile: 'Select quality profile',
minimumAvailability: 'Minimum Availability',
announced: 'Announced',
inCinemas: 'In Cinemas',
released: 'Released',
seriesType: 'Series Type',
tags: 'Tags',
selectTags: 'Select tags',
noTagOptions: 'No tags.',
badgeDefault: 'Default',
badge4k: '4K',
conditionalShouldNotBeDefault:
'Conditional rules should target a non-default instance.',
ruleCreated: 'Routing rule created successfully!',
ruleUpdated: 'Routing rule updated successfully!',
validationNameRequired: 'You must provide a rule name',
validationTargetRequired: 'You must select a target instance',
validationRootFolderRequired: 'You must select a root folder',
validationProfileRequired: 'You must select a quality profile',
validationMinimumAvailabilityRequired:
'You must select a minimum availability',
});
interface RoutingRuleModalProps {
rule: RoutingRule | null;
onClose: () => void;
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
prefillData?: Partial<RoutingRule>;
}
const RoutingRuleModal = ({
onClose,
rule,
radarrServices,
sonarrServices,
prefillData,
}: RoutingRuleModalProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const { currentSettings } = useSettings();
const [isValidated, setIsValidated] = useState(!!rule);
const [isTesting, setIsTesting] = useState(false);
const [testResponse, setTestResponse] = useState<DVRTestResponse>({
profiles: [],
rootFolders: [],
tags: [],
});
const isFallbackMode = !!(rule?.isFallback || prefillData?.isFallback);
const isPrefillFallback = !rule && !!prefillData?.isFallback;
const requires4kFallback = !!(
isFallbackMode &&
(rule?.is4k || prefillData?.is4k)
);
const getServiceInfos = useCallback(
async (service: RadarrSettings | SonarrSettings, type: ServiceType) => {
setIsTesting(true);
try {
const response = await axios.post<DVRTestResponse>(
`/api/v1/settings/${type}/test`,
{
hostname: service.hostname,
apiKey: service.apiKey,
port: Number(service.port),
baseUrl: service.baseUrl,
useSsl: service.useSsl,
}
);
setIsValidated(true);
setTestResponse(response.data);
} catch {
setIsValidated(false);
setTestResponse({ profiles: [], rootFolders: [], tags: [] });
} finally {
setIsTesting(false);
}
},
[]
);
useEffect(() => {
const data = rule ?? prefillData;
if (!data?.serviceType || data.targetServiceId == null) return;
const services =
data.serviceType === 'radarr' ? radarrServices : sonarrServices;
const svc = services.find((s) => s.id === data.targetServiceId);
if (!svc) return;
getServiceInfos(svc, data.serviceType);
}, [rule, prefillData, radarrServices, sonarrServices, getServiceInfos]);
const RoutingRuleSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired)
),
targetServiceId: Yup.number()
.required(intl.formatMessage(messages.validationTargetRequired))
.min(0, intl.formatMessage(messages.validationTargetRequired)),
isFallback: Yup.boolean().default(isFallbackMode),
rootFolder: Yup.string().when('isFallback', {
is: true,
then: (s) =>
s.required(intl.formatMessage(messages.validationRootFolderRequired)),
otherwise: (s) => s.nullable(),
}),
activeProfileId: Yup.number()
.transform((val, orig) =>
orig === '' || orig == null ? null : Number(orig)
)
.nullable()
.when('isFallback', {
is: true,
then: (s) =>
s.required(intl.formatMessage(messages.validationProfileRequired)),
otherwise: (s) => s.nullable(),
}),
minimumAvailability: Yup.string().when(['isFallback', 'serviceType'], {
is: (isFallback: boolean, serviceType: ServiceType) =>
isFallback && serviceType === 'radarr',
then: (s) =>
s.required(
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
),
otherwise: (s) => s.nullable(),
}),
});
const getDerivedFlags = (svc?: RadarrSettings | SonarrSettings) => {
const isDefault = !!(svc && svc.isDefault);
const is4k = !!(svc && svc.is4k);
return { isDefault, is4k };
};
return (
<Transition
as="div"
appear
show
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Formik
initialValues={{
name: rule?.name ?? prefillData?.name ?? '',
serviceType: (rule?.serviceType ??
prefillData?.serviceType ??
'sonarr') as ServiceType,
targetServiceId:
rule?.targetServiceId ?? prefillData?.targetServiceId ?? -1,
isFallback: isFallbackMode,
users: rule?.users ?? prefillData?.users ?? undefined,
genres: rule?.genres ?? prefillData?.genres ?? undefined,
languages: rule?.languages ?? prefillData?.languages ?? undefined,
keywords: rule?.keywords ?? prefillData?.keywords ?? undefined,
activeProfileId:
rule?.activeProfileId ?? prefillData?.activeProfileId ?? undefined,
rootFolder: rule?.rootFolder ?? prefillData?.rootFolder ?? undefined,
minimumAvailability:
rule?.minimumAvailability ??
prefillData?.minimumAvailability ??
'released',
seriesType: rule?.seriesType ?? prefillData?.seriesType ?? undefined,
tags: rule?.tags ?? prefillData?.tags ?? undefined,
}}
validationSchema={RoutingRuleSchema}
onSubmit={async (values) => {
try {
const services =
values.serviceType === 'radarr' ? radarrServices : sonarrServices;
const selectedService = services.find(
(s) => s.id === values.targetServiceId
);
const derived = getDerivedFlags(selectedService);
const activeProfileId =
values.activeProfileId == null
? null
: Number(values.activeProfileId);
const profileName =
testResponse.profiles.find(
(p) => p.id === Number(values.activeProfileId)
)?.name ?? null;
const submission = {
name: values.name,
serviceType: values.serviceType,
targetServiceId: values.targetServiceId,
isFallback: values.isFallback,
is4k: derived.is4k,
users: values.isFallback ? null : values.users || null,
genres: values.isFallback ? null : values.genres || null,
languages: values.isFallback ? null : values.languages || null,
keywords: values.isFallback ? null : values.keywords || null,
activeProfileId,
activeProfileName: profileName,
rootFolder: values.rootFolder || null,
minimumAvailability:
values.serviceType === 'radarr'
? values.minimumAvailability || null
: null,
seriesType:
values.serviceType === 'sonarr'
? values.seriesType || null
: null,
tags: values.tags || null,
};
if (!rule) {
await axios.post('/api/v1/routingRule', submission);
addToast(intl.formatMessage(messages.ruleCreated), {
appearance: 'success',
autoDismiss: true,
});
} else {
await axios.put(`/api/v1/routingRule/${rule.id}`, submission);
addToast(intl.formatMessage(messages.ruleUpdated), {
appearance: 'success',
autoDismiss: true,
});
}
onClose();
} catch {
// TODO: handle error
}
}}
>
{({
errors,
touched,
values,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
}) => {
const services =
values.serviceType === 'radarr' ? radarrServices : sonarrServices;
const selectedService = services.find(
(s) => s.id === values.targetServiceId
);
const derived = getDerivedFlags(selectedService);
const hasAnyCondition = !!(
values.users ||
values.genres ||
values.languages ||
values.keywords
);
const fallbackTargetOk =
derived.isDefault && (!requires4kFallback || derived.is4k);
const canSave =
isValid &&
isValidated &&
(!values.isFallback ? hasAnyCondition : fallbackTargetOk);
const optionsDisabled = !isValidated || isTesting;
return (
<Modal
onCancel={onClose}
okButtonType="primary"
okText={
isSubmitting
? intl.formatMessage(globalMessages.saving)
: rule
? intl.formatMessage(globalMessages.save)
: intl.formatMessage(messages.create)
}
okDisabled={isSubmitting || !isValid || !canSave}
onOk={() => handleSubmit()}
title={
!rule
? intl.formatMessage(messages.createRule)
: intl.formatMessage(messages.editRule)
}
>
<div className="mb-6">
{isPrefillFallback && (
<div className="mb-4 rounded-lg border border-blue-500/30 bg-blue-900/10 p-3">
<div className="flex items-start gap-2">
<InformationCircleIcon className="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-400" />
<div className="text-sm text-blue-200">
<strong>
{intl.formatMessage(
requires4kFallback
? messages.firstInstanceSetup4k
: messages.firstInstanceSetup
)}
</strong>{' '}
{intl.formatMessage(messages.firstInstanceSetupBody, {
mediaType:
values.serviceType === 'radarr' ? 'movie' : 'TV',
})}
</div>
</div>
</div>
)}
<div className="form-row">
<label htmlFor="name" className="text-label">
{intl.formatMessage(messages.ruleName)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="name"
name="name"
type="text"
placeholder={intl.formatMessage(
messages.ruleNamePlaceholder
)}
/>
</div>
{errors.name &&
touched.name &&
typeof errors.name === 'string' && (
<div className="error">{errors.name}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="serviceType" className="text-label">
{intl.formatMessage(messages.serviceType)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="serviceType"
name="serviceType"
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
setFieldValue('serviceType', e.target.value);
setFieldValue('targetServiceId', -1);
setIsValidated(false);
setTestResponse({
profiles: [],
rootFolders: [],
tags: [],
});
setFieldValue('activeProfileId', undefined);
setFieldValue('rootFolder', undefined);
setFieldValue('tags', undefined);
setFieldValue('seriesType', undefined);
setFieldValue('minimumAvailability', 'released');
}}
>
<option value="sonarr">Sonarr</option>
<option value="radarr">Radarr</option>
</Field>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="targetServiceId" className="text-label">
{intl.formatMessage(messages.targetInstance)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="targetServiceId"
name="targetServiceId"
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const id = Number(e.target.value);
setFieldValue('targetServiceId', id);
const svc = services.find((s) => s.id === id);
if (svc) {
getServiceInfos(svc, values.serviceType);
} else {
setIsValidated(false);
setTestResponse({
profiles: [],
rootFolders: [],
tags: [],
});
}
}}
>
<option value={-1}>
{intl.formatMessage(messages.selectInstance)}
</option>
{services.map((service) => (
<option key={service.id} value={service.id}>
{service.name}
</option>
))}
</Field>
</div>
{selectedService && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{derived.isDefault && (
<Badge badgeType="primary">
{intl.formatMessage(messages.badgeDefault)}
</Badge>
)}
{derived.is4k && (
<Badge badgeType="warning">
{intl.formatMessage(messages.badge4k)}
</Badge>
)}
</div>
)}
{values.isFallback &&
values.targetServiceId >= 0 &&
!derived.isDefault && (
<div className="mt-2 rounded-md border border-red-500/30 bg-red-900/10 p-2 text-sm text-red-200">
{intl.formatMessage(messages.fallbackMustBeDefault)}
</div>
)}
{values.isFallback &&
requires4kFallback &&
values.targetServiceId >= 0 &&
derived.isDefault &&
!derived.is4k && (
<div className="mt-2 rounded-md border border-red-500/30 bg-red-900/10 p-2 text-sm text-red-200">
{intl.formatMessage(messages.fallbackMustBe4k)}
</div>
)}
{!values.isFallback && !hasAnyCondition && (
<div className="mt-2 text-xs text-gray-400">
{intl.formatMessage(messages.nonFallbackNeedsCondition)}
</div>
)}
{errors.targetServiceId &&
touched.targetServiceId &&
typeof errors.targetServiceId === 'string' && (
<div className="error">{errors.targetServiceId}</div>
)}
</div>
</div>
{!values.isFallback && (
<>
<h3 className="mt-4 text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.conditions)}
</h3>
<p className="description">
{intl.formatMessage(messages.conditionsDescription)}
</p>
<div className="form-row">
<label htmlFor="users" className="text-label">
{intl.formatMessage(messages.users)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<UserSelector
defaultValue={values.users}
isMulti
onChange={(selectedUsers) => {
setFieldValue(
'users',
selectedUsers?.map((v) => v.value).join(',') ||
undefined
);
}}
/>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="genres" className="text-label">
{intl.formatMessage(messages.genres)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<GenreSelector
type={
values.serviceType === 'radarr' ? 'movie' : 'tv'
}
defaultValue={values.genres}
isMulti
onChange={(selectedGenres) => {
setFieldValue(
'genres',
selectedGenres?.map((v) => v.value).join(',') ||
undefined
);
}}
/>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="languages" className="text-label">
{intl.formatMessage(messages.languages)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<LanguageSelector
value={values.languages}
serverValue={currentSettings.originalLanguage}
setFieldValue={(_key, value) => {
setFieldValue('languages', value);
}}
/>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="keywords" className="text-label">
{intl.formatMessage(messages.keywords)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<KeywordSelector
defaultValue={values.keywords}
isMulti
onChange={(value) => {
setFieldValue(
'keywords',
value?.map((v) => v.value).join(',') ||
undefined
);
}}
/>
</div>
</div>
</div>
</>
)}
<h3 className="mt-4 text-lg font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.target)}
</h3>
<p className="description">
{intl.formatMessage(messages.targetDescription)}
</p>
<div className="form-row">
<label htmlFor="rootFolder" className="text-label">
{intl.formatMessage(messages.rootFolder)}
{values.isFallback && (
<span className="label-required">*</span>
)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="rootFolder"
name="rootFolder"
disabled={optionsDisabled}
>
<option value="">
{intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.map((folder) => (
<option key={folder.id} value={folder.path}>
{folder.path}
</option>
))}
</Field>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="activeProfileId" className="text-label">
{intl.formatMessage(messages.qualityProfile)}
{values.isFallback && (
<span className="label-required">*</span>
)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeProfileId"
name="activeProfileId"
disabled={optionsDisabled}
>
<option value="">
{intl.formatMessage(messages.selectQualityProfile)}
</option>
{testResponse.profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeProfileId &&
touched.activeProfileId &&
typeof errors.activeProfileId === 'string' && (
<div className="error">{errors.activeProfileId}</div>
)}
</div>
</div>
{values.serviceType === 'radarr' && (
<div className="form-row">
<label htmlFor="minimumAvailability" className="text-label">
{intl.formatMessage(messages.minimumAvailability)}
{values.isFallback && (
<span className="label-required">*</span>
)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="minimumAvailability"
name="minimumAvailability"
disabled={optionsDisabled}
>
<option value="announced">
{intl.formatMessage(messages.announced)}
</option>
<option value="inCinemas">
{intl.formatMessage(messages.inCinemas)}
</option>
<option value="released">
{intl.formatMessage(messages.released)}
</option>
</Field>
</div>
{errors.minimumAvailability &&
touched.minimumAvailability &&
typeof errors.minimumAvailability === 'string' && (
<div className="error">
{errors.minimumAvailability}
</div>
)}
</div>
</div>
)}
{values.serviceType === 'sonarr' && (
<div className="form-row">
<label htmlFor="seriesType" className="text-label">
{intl.formatMessage(messages.seriesType)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="seriesType"
name="seriesType"
disabled={optionsDisabled}
>
<option value=""></option>
<option value="standard">Standard</option>
<option value="daily">Daily</option>
<option value="anime">Anime</option>
</Field>
</div>
</div>
</div>
)}
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
options={
isValidated
? testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))
: []
}
isMulti
isDisabled={optionsDisabled}
placeholder={intl.formatMessage(messages.selectTags)}
className="react-select-container"
classNamePrefix="react-select"
value={
(values.tags
?.split(',')
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === Number(tagId)
);
if (!foundTag) return undefined;
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[]) ?? []
}
onChange={(value) => {
setFieldValue(
'tags',
value.map((option) => option.value).join(',') ||
undefined
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.noTagOptions)
}
/>
</div>
</div>
</div>
</Modal>
);
}}
</Formik>
</Transition>
);
};
export default RoutingRuleModal;

View File

@@ -0,0 +1,555 @@
import Badge from '@app/components/Common/Badge';
import type { RoutingRule } from '@app/components/Settings/RoutingRule/types';
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import {
ChevronDownIcon,
PencilIcon,
TrashIcon,
} from '@heroicons/react/24/solid';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import type { User } from '@server/entity/User';
import type {
Language,
RadarrSettings,
SonarrSettings,
} from '@server/lib/settings';
import type { Keyword } from '@server/models/common';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Settings.RoutingRuleRow', {
fallback: 'Fallback',
conditions: 'Conditions',
routeTo: 'Route To',
matchesAll: 'Matches all requests',
instanceDefaults: 'Uses instance defaults',
instance: 'Instance',
rootFolder: 'Root Folder',
qualityProfile: 'Quality Profile',
minimumAvailability: 'Minimum Availability',
seriesType: 'Series Type',
tags: 'Tags',
users: 'Users',
genres: 'Genres',
languages: 'Languages',
keywords: 'Keywords',
sonarr: 'Sonarr',
radarr: 'Radarr',
});
interface RoutingRuleRowProps {
rule: RoutingRule;
index: number;
expanded: boolean;
isDragging: boolean;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
dragHandleProps: Record<string, unknown>;
users?: User[];
genres?: TmdbGenre[];
languages?: Language[];
keywords?: Keyword[];
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
testResponses: (DVRTestResponse & { type: string; id: number })[];
}
const ConditionBadges = ({
rule,
users,
genres,
languages,
keywords,
}: {
rule: RoutingRule;
users?: User[];
genres?: TmdbGenre[];
languages?: Language[];
keywords?: Keyword[];
}) => {
const intl = useIntl();
const hasConditions =
!!rule.users || !!rule.genres || !!rule.languages || !!rule.keywords;
if (!hasConditions) {
return (
<span className="text-sm italic text-gray-500">
{intl.formatMessage(messages.matchesAll)}
</span>
);
}
return (
<div className="flex flex-wrap gap-1.5">
{rule.keywords
?.split(',')
.filter(Boolean)
.map((keywordId) => {
const keyword = keywords?.find((k) => k.id === Number(keywordId));
return (
<Badge key={`kw-${keywordId}`} badgeType="warning">
{keyword?.name ?? keywordId}
</Badge>
);
})}
{rule.genres
?.split(',')
.filter(Boolean)
.map((genreId) => {
const genre = genres?.find((g) => g.id === Number(genreId));
return (
<Badge key={`g-${genreId}`} badgeType="warning">
{genre?.name ?? genreId}
</Badge>
);
})}
{rule.languages
?.split('|')
.filter((l) => l && l !== 'server')
.map((langCode) => {
const lang = languages?.find((l) => l.iso_639_1 === langCode);
const name =
intl.formatDisplayName(langCode, {
type: 'language',
fallback: 'none',
}) ??
lang?.english_name ??
langCode;
return (
<Badge key={`l-${langCode}`} badgeType="success">
{name}
</Badge>
);
})}
{rule.users
?.split(',')
.filter(Boolean)
.map((userId) => {
const user = users?.find((u) => u.id === Number(userId));
return (
<Badge key={`u-${userId}`}>{user?.displayName ?? userId}</Badge>
);
})}
</div>
);
};
const TargetBadges = ({
rule,
radarrServices,
sonarrServices,
testResponses,
}: {
rule: RoutingRule;
radarrServices: RadarrSettings[];
sonarrServices: SonarrSettings[];
testResponses: (DVRTestResponse & { type: string; id: number })[];
}) => {
const intl = useIntl();
const services =
rule.serviceType === 'sonarr' ? sonarrServices : radarrServices;
const targetService = services.find((s) => s.id === rule.targetServiceId);
const testResponse = testResponses.find(
(r) => r.id === rule.targetServiceId && r.type === rule.serviceType
);
const profileName =
(rule.activeProfileId != null
? testResponse?.profiles.find(
(p) => p.id === Number(rule.activeProfileId)
)?.name
: null) ??
rule.activeProfileName ??
null;
const hasOverrides = Boolean(
rule.rootFolder ||
rule.activeProfileId != null ||
rule.seriesType ||
rule.tags ||
(rule.serviceType === 'radarr' && rule.minimumAvailability)
);
return (
<div className="flex flex-wrap items-center gap-1.5">
<Badge badgeType="primary">{targetService?.name ?? 'Unknown'}</Badge>
{rule.rootFolder && <Badge>{rule.rootFolder}</Badge>}
{rule.activeProfileId != null && (
<Badge>{profileName ?? String(rule.activeProfileId)}</Badge>
)}
{rule.seriesType && <Badge badgeType="warning">{rule.seriesType}</Badge>}
{rule.tags?.split(',').map((tagId) => {
const tag = testResponse?.tags.find((t) => t.id === Number(tagId));
return <Badge key={`t-${tagId}`}>{tag?.label ?? tagId}</Badge>;
})}
{!hasOverrides && (
<span className="text-xs text-gray-500">
{intl.formatMessage(messages.instanceDefaults)}
</span>
)}
</div>
);
};
const DragHandle = (props: Record<string, unknown>) => (
<div
{...props}
className="flex cursor-grab flex-col items-center justify-center gap-[3px] px-3 py-3 text-gray-600 transition-colors hover:text-gray-400 active:cursor-grabbing"
>
{[0, 1, 2].map((i) => (
<div key={i} className="flex gap-[3px]">
<div className="h-[3px] w-[3px] rounded-full bg-current" />
<div className="h-[3px] w-[3px] rounded-full bg-current" />
</div>
))}
</div>
);
const RoutingRuleRow = ({
rule,
index,
expanded,
isDragging,
onToggle,
onEdit,
onDelete,
dragHandleProps,
users,
genres,
languages,
keywords,
radarrServices,
sonarrServices,
testResponses,
}: RoutingRuleRowProps) => {
const intl = useIntl();
const isFallback = !!rule.isFallback;
const services =
rule.serviceType === 'sonarr' ? sonarrServices : radarrServices;
const targetService = services.find((s) => s.id === rule.targetServiceId);
const testResponse = testResponses.find(
(r) => r.id === rule.targetServiceId && r.type === rule.serviceType
);
const profileName =
(rule.activeProfileId != null
? testResponse?.profiles.find(
(p) => p.id === Number(rule.activeProfileId)
)?.name
: null) ??
rule.activeProfileName ??
null;
return (
<div
className={`rounded-lg transition-all duration-200 ${
isDragging
? 'scale-[1.01] bg-gray-700 shadow-lg ring-2 ring-indigo-500'
: expanded
? 'bg-gray-800 ring-1 ring-gray-500'
: 'bg-gray-800 ring-1 ring-gray-700 hover:ring-gray-500'
}`}
>
<div className="flex items-center">
{!isFallback && <DragHandle {...dragHandleProps} />}
{isFallback && <div className="w-9" />}
<button
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-3 py-3 pr-4 text-left"
>
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-gray-700 font-mono text-xs text-gray-400">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="truncate text-sm font-medium text-white">
{rule.name}
</span>
{isFallback && (
<Badge badgeType="success">
{intl.formatMessage(messages.fallback)}
</Badge>
)}
<Badge
badgeType={rule.serviceType === 'sonarr' ? 'primary' : 'danger'}
>
{rule.serviceType === 'sonarr'
? intl.formatMessage(messages.sonarr)
: intl.formatMessage(messages.radarr)}
</Badge>
{rule.is4k && <Badge badgeType="warning">4K</Badge>}
</div>
{!expanded && (
<div className="flex items-center gap-2 text-xs">
<ConditionBadges
rule={rule}
users={users}
genres={genres}
languages={languages}
keywords={keywords}
/>
<span className="text-gray-600"></span>
<TargetBadges
rule={rule}
radarrServices={radarrServices}
sonarrServices={sonarrServices}
testResponses={testResponses}
/>
</div>
)}
</div>
<ChevronDownIcon
className={`h-4 w-4 flex-shrink-0 text-gray-500 transition-transform ${
expanded ? 'rotate-180' : ''
}`}
/>
</button>
</div>
{expanded && (
<div className="border-t border-gray-700 px-4 pb-4 pl-12">
<div className="grid grid-cols-2 gap-6 pt-4">
{/* Conditions */}
<div>
<h4 className="mb-2 text-xs font-bold uppercase tracking-wider text-gray-400">
{intl.formatMessage(messages.conditions)}
</h4>
{!rule.users &&
!rule.genres &&
!rule.languages &&
!rule.keywords ? (
<p className="text-sm italic text-gray-500">
{intl.formatMessage(messages.matchesAll)}
</p>
) : (
<div className="space-y-2">
{rule.keywords && (
<div className="flex items-start gap-2">
<span className="w-20 pt-0.5 text-xs text-gray-500">
{intl.formatMessage(messages.keywords)}
</span>
<div className="flex flex-wrap gap-1">
{rule.keywords
.split(',')
.filter(Boolean)
.map((keywordId) => {
const keyword = keywords?.find(
(k) => k.id === Number(keywordId)
);
return (
<Badge key={keywordId} badgeType="warning">
{keyword?.name ?? keywordId}
</Badge>
);
})}
</div>
</div>
)}
{rule.genres && (
<div className="flex items-start gap-2">
<span className="w-20 pt-0.5 text-xs text-gray-500">
{intl.formatMessage(messages.genres)}
</span>
<div className="flex flex-wrap gap-1">
{rule.genres
.split(',')
.filter(Boolean)
.map((genreId) => {
const genre = genres?.find(
(g) => g.id === Number(genreId)
);
return (
<Badge key={genreId} badgeType="warning">
{genre?.name ?? genreId}
</Badge>
);
})}
</div>
</div>
)}
{rule.languages && (
<div className="flex items-start gap-2">
<span className="w-20 pt-0.5 text-xs text-gray-500">
{intl.formatMessage(messages.languages)}
</span>
<div className="flex flex-wrap gap-1">
{rule.languages
.split('|')
.filter((l) => l && l !== 'server')
.map((langCode) => {
const name =
intl.formatDisplayName(langCode, {
type: 'language',
fallback: 'none',
}) ?? langCode;
return (
<Badge key={langCode} badgeType="success">
{name}
</Badge>
);
})}
</div>
</div>
)}
{rule.users && (
<div className="flex items-start gap-2">
<span className="w-20 pt-0.5 text-xs text-gray-500">
{intl.formatMessage(messages.users)}
</span>
<div className="flex flex-wrap gap-1">
{rule.users
.split(',')
.filter(Boolean)
.map((userId) => {
const user = users?.find(
(u) => u.id === Number(userId)
);
return (
<Badge key={userId}>
{user?.displayName ?? userId}
</Badge>
);
})}
</div>
</div>
)}
</div>
)}
</div>
<div>
<h4 className="mb-2 text-xs font-bold uppercase tracking-wider text-gray-400">
{intl.formatMessage(messages.routeTo)}
</h4>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-500">
{intl.formatMessage(messages.instance)}
</span>
<Badge badgeType="primary">
{targetService?.name ?? 'Unknown'}
</Badge>
</div>
{rule.rootFolder && (
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-500">
{intl.formatMessage(messages.rootFolder)}
</span>
<span className="font-mono text-xs text-gray-300">
{rule.rootFolder}
</span>
</div>
)}
{rule.activeProfileId != null && (
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-500">
{intl.formatMessage(messages.qualityProfile)}
</span>
<span className="text-xs text-gray-300">
{profileName ?? String(rule.activeProfileId)}
</span>
</div>
)}
{rule.serviceType === 'radarr' && rule.minimumAvailability && (
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-500">
{intl.formatMessage(messages.minimumAvailability)}
</span>
<Badge badgeType="warning">
{rule.minimumAvailability}
</Badge>
</div>
)}
{rule.seriesType && (
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-500">
{intl.formatMessage(messages.seriesType)}
</span>
<Badge badgeType="warning">{rule.seriesType}</Badge>
</div>
)}
{rule.tags && (
<div className="flex items-center gap-2">
<span className="w-20 text-xs text-gray-500">
{intl.formatMessage(messages.tags)}
</span>
<div className="flex gap-1">
{rule.tags
.split(',')
.filter(Boolean)
.map((tagId) => {
const tag = testResponse?.tags.find(
(t) => t.id === Number(tagId)
);
return (
<Badge key={tagId}>{tag?.label ?? tagId}</Badge>
);
})}
</div>
</div>
)}
{!rule.rootFolder &&
rule.activeProfileId == null &&
!rule.minimumAvailability &&
!rule.seriesType &&
!rule.tags && (
<p className="text-xs italic text-gray-500">
{intl.formatMessage(messages.instanceDefaults)}
</p>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="mt-4 flex justify-end gap-2 border-t border-gray-700 pt-3">
<button
onClick={onEdit}
className="inline-flex items-center gap-1.5 rounded-lg border border-transparent bg-gray-700 px-3 py-1.5 text-xs font-medium text-gray-200 transition duration-150 hover:text-white"
>
<PencilIcon className="h-3 w-3" />
{intl.formatMessage(globalMessages.edit)}
</button>
{!isFallback && (
<button
onClick={onDelete}
className="inline-flex items-center gap-1.5 rounded-lg border border-transparent px-3 py-1.5 text-xs font-medium text-gray-400 transition duration-150 hover:bg-red-900/20 hover:text-red-400"
>
<TrashIcon className="h-3 w-3" />
{intl.formatMessage(globalMessages.delete)}
</button>
)}
</div>
</div>
)}
</div>
);
};
export default RoutingRuleRow;

View File

@@ -0,0 +1,23 @@
export interface RoutingRule {
id: number;
serviceType: 'radarr' | 'sonarr';
isFallback: boolean;
is4k: boolean;
priority: number;
name: string;
users?: string | null;
genres?: string | null;
languages?: string | null;
keywords?: string | null;
targetServiceId: number;
activeProfileId?: number | null;
activeProfileName: string | null;
rootFolder?: string | null;
seriesType?: string | null;
tags?: string | null;
minimumAvailability?: 'announced' | 'inCinemas' | 'released' | null;
createdAt: string;
updatedAt: string;
}
export type RoutingRuleResultsResponse = RoutingRule[];

View File

@@ -1,21 +1,19 @@
import RadarrLogo from '@app/assets/services/radarr.svg';
import SonarrLogo from '@app/assets/services/sonarr.svg';
import Alert from '@app/components/Common/Alert';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle';
import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
import OverrideRuleTiles from '@app/components/Settings/OverrideRule/OverrideRuleTiles';
import RadarrModal from '@app/components/Settings/RadarrModal';
import RoutingRuleList from '@app/components/Settings/RoutingRule/RoutingRuleList';
import RoutingRuleModal from '@app/components/Settings/RoutingRule/RoutingRuleModal';
import type { RoutingRule } from '@app/components/Settings/RoutingRule/types';
import SonarrModal from '@app/components/Settings/SonarrModal';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
import type OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Fragment, useState } from 'react';
@@ -24,32 +22,22 @@ import useSWR, { mutate } from 'swr';
const messages = defineMessages('components.Settings', {
services: 'Services',
radarrsettings: 'Radarr Settings',
sonarrsettings: 'Sonarr Settings',
serviceSettingsDescription:
'Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.',
instances: 'Instances',
instancesDescription:
'Configure your Sonarr and Radarr server connections below. Routing rules determine which instance handles each request.',
deleteserverconfirm: 'Are you sure you want to delete this server?',
ssl: 'SSL',
default: 'Default',
default4k: 'Default 4K',
is4k: '4K',
address: 'Address',
activeProfile: 'Active Profile',
addradarr: 'Add Radarr Server',
addsonarr: 'Add Sonarr Server',
noDefaultServer:
'At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.',
noDefaultNon4kServer:
'If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should <strong>NOT</strong> be designated as a 4K server.',
noDefault4kServer:
'A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.',
mediaTypeMovie: 'movie',
mediaTypeSeries: 'series',
routingRules: 'Routing Rules',
noRules: 'No routing rules configured',
ruleCount: '{count} routing {count, plural, one {rule} other {rules}}',
addInstance: 'Add Instance',
addRadarr: 'Add Radarr',
addSonarr: 'Add Sonarr',
deleteServer: 'Delete {serverType} Server',
overrideRules: 'Override Rules',
overrideRulesDescription:
'Override rules allow you to specify properties that will be replaced if a request matches the rule.',
addrule: 'New Override Rule',
});
interface ServerInstanceProps {
@@ -60,7 +48,7 @@ interface ServerInstanceProps {
port: number;
isSSL?: boolean;
externalUrl?: string;
profileName: string;
ruleCount: number;
isSonarr?: boolean;
onEdit: () => void;
onDelete: () => void;
@@ -97,11 +85,11 @@ const ServerInstance = ({
name,
hostname,
port,
profileName,
is4k = false,
isDefault = false,
is4k = false,
isSSL = false,
isSonarr = false,
ruleCount,
externalUrl,
onEdit,
onDelete,
@@ -159,9 +147,11 @@ const ServerInstance = ({
</p>
<p className="mt-1 truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.activeProfile)}
{intl.formatMessage(messages.routingRules)}
</span>
{profileName}
{ruleCount === 0
? intl.formatMessage(messages.noRules)
: intl.formatMessage(messages.ruleCount, { count: ruleCount })}
</p>
</div>
<a
@@ -215,8 +205,10 @@ const SettingsServices = () => {
error: sonarrError,
mutate: revalidateSonarr,
} = useSWR<SonarrSettings[]>('/api/v1/settings/sonarr');
const { data: rules, mutate: revalidate } =
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
const { data: routingRules, mutate: revalidateRules } = useSWR<RoutingRule[]>(
'/api/v1/routingRule'
);
const [editRadarrModal, setEditRadarrModal] = useState<{
open: boolean;
radarr: RadarrSettings | null;
@@ -240,9 +232,10 @@ const SettingsServices = () => {
type: 'radarr',
serverId: null,
});
const [overrideRuleModal, setOverrideRuleModal] = useState<{
const [routingRuleModal, setRoutingRuleModal] = useState<{
open: boolean;
rule: OverrideRule | null;
rule: RoutingRule | null;
prefillData?: Partial<RoutingRule>;
}>({
open: false,
rule: null,
@@ -256,6 +249,73 @@ const SettingsServices = () => {
revalidateRadarr();
revalidateSonarr();
mutate('/api/v1/settings/public');
revalidateRules();
};
const handleRadarrSave = async (savedInstance: RadarrSettings) => {
setEditRadarrModal({ open: false, radarr: null });
revalidateRadarr();
mutate('/api/v1/settings/public');
if (!savedInstance.isDefault) return;
const rules = (await revalidateRules()) ?? [];
const existingDefault = rules.find(
(r) =>
r.serviceType === 'radarr' &&
r.is4k === savedInstance.is4k &&
r.isFallback
);
setRoutingRuleModal({
open: true,
rule: existingDefault
? { ...existingDefault, targetServiceId: savedInstance.id }
: null,
prefillData: existingDefault
? undefined
: {
name: `${savedInstance.name} Default Route`,
serviceType: 'radarr',
is4k: savedInstance.is4k,
targetServiceId: savedInstance.id,
isFallback: true,
},
});
};
const handleSonarrSave = async (savedInstance: SonarrSettings) => {
setEditSonarrModal({ open: false, sonarr: null });
revalidateSonarr();
mutate('/api/v1/settings/public');
if (!savedInstance.isDefault) return;
const rules = (await revalidateRules()) ?? [];
const existingDefault = rules.find(
(r) =>
r.serviceType === 'sonarr' &&
r.is4k === savedInstance.is4k &&
r.isFallback
);
setRoutingRuleModal({
open: true,
rule: existingDefault
? { ...existingDefault, targetServiceId: savedInstance.id }
: null,
prefillData: existingDefault
? undefined
: {
name: `${savedInstance.name} Default Route`,
serviceType: 'sonarr',
is4k: savedInstance.is4k,
targetServiceId: savedInstance.id,
isFallback: true,
},
});
};
return (
@@ -266,44 +326,22 @@ const SettingsServices = () => {
intl.formatMessage(globalMessages.settings),
]}
/>
<div className="mb-6">
<h3 className="heading">
{intl.formatMessage(messages.radarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.serviceSettingsDescription, {
serverType: 'Radarr',
})}
</p>
</div>
{editRadarrModal.open && (
<RadarrModal
radarr={editRadarrModal.radarr}
onClose={() => {
if (!overrideRuleModal.open)
setEditRadarrModal({ open: false, radarr: null });
}}
onSave={() => {
revalidateRadarr();
mutate('/api/v1/settings/public');
setEditRadarrModal({ open: false, radarr: null });
}}
onClose={() => setEditRadarrModal({ open: false, radarr: null })}
onSave={handleRadarrSave}
/>
)}
{editSonarrModal.open && (
<SonarrModal
sonarr={editSonarrModal.sonarr}
onClose={() => {
if (!overrideRuleModal.open)
setEditSonarrModal({ open: false, sonarr: null });
}}
onSave={() => {
revalidateSonarr();
mutate('/api/v1/settings/public');
setEditSonarrModal({ open: false, sonarr: null });
}}
onClose={() => setEditSonarrModal({ open: false, sonarr: null })}
onSave={handleSonarrSave}
/>
)}
<Transition
as={Fragment}
show={deleteServerModal.open}
@@ -333,226 +371,128 @@ const SettingsServices = () => {
{intl.formatMessage(messages.deleteserverconfirm)}
</Modal>
</Transition>
<div className="section">
{!radarrData && !radarrError && <LoadingSpinner />}
{radarrData && !radarrError && (
<>
{radarrData.length > 0 &&
(!radarrData.some((radarr) => radarr.isDefault) ? (
<Alert
title={intl.formatMessage(messages.noDefaultServer, {
serverType: 'Radarr',
mediaType: intl.formatMessage(messages.mediaTypeMovie),
})}
/>
) : !radarrData.some(
(radarr) => radarr.isDefault && !radarr.is4k
) ? (
<Alert
title={intl.formatMessage(messages.noDefaultNon4kServer, {
serverType: 'Radarr',
strong: (msg: React.ReactNode) => (
<strong className="font-semibold text-white">
{msg}
</strong>
),
})}
/>
) : (
radarrData.some((radarr) => radarr.is4k) &&
!radarrData.some(
(radarr) => radarr.isDefault && radarr.is4k
) && (
<Alert
title={intl.formatMessage(messages.noDefault4kServer, {
serverType: 'Radarr',
mediaType: intl.formatMessage(messages.mediaTypeMovie),
})}
/>
)
))}
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{radarrData.map((radarr) => (
<ServerInstance
key={`radarr-config-${radarr.id}`}
name={radarr.name}
hostname={radarr.hostname}
port={radarr.port}
profileName={radarr.activeProfileName}
isSSL={radarr.useSsl}
isDefault={radarr.isDefault}
is4k={radarr.is4k}
externalUrl={radarr.externalUrl}
onEdit={() => setEditRadarrModal({ open: true, radarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: radarr.id,
type: 'radarr',
})
}
/>
))}
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
className="mb-3 mt-3"
onClick={() =>
setEditRadarrModal({ open: true, radarr: null })
}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addradarr)}</span>
</Button>
</div>
</li>
</ul>
</>
)}
</div>
<div className="mb-6 mt-10">
<h3 className="heading">
{intl.formatMessage(messages.sonarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.serviceSettingsDescription, {
serverType: 'Sonarr',
})}
</p>
</div>
<div className="section">
{!sonarrData && !sonarrError && <LoadingSpinner />}
{sonarrData && !sonarrError && (
<>
{sonarrData.length > 0 &&
(!sonarrData.some((sonarr) => sonarr.isDefault) ? (
<Alert
title={intl.formatMessage(messages.noDefaultServer, {
serverType: 'Sonarr',
mediaType: intl.formatMessage(messages.mediaTypeSeries),
})}
/>
) : !sonarrData.some(
(sonarr) => sonarr.isDefault && !sonarr.is4k
) ? (
<Alert
title={intl.formatMessage(messages.noDefaultNon4kServer, {
serverType: 'Sonarr',
strong: (msg: React.ReactNode) => (
<strong className="font-semibold text-white">
{msg}
</strong>
),
})}
/>
) : (
sonarrData.some((sonarr) => sonarr.is4k) &&
!sonarrData.some(
(sonarr) => sonarr.isDefault && sonarr.is4k
) && (
<Alert
title={intl.formatMessage(messages.noDefault4kServer, {
serverType: 'Sonarr',
mediaType: intl.formatMessage(messages.mediaTypeSeries),
})}
/>
)
))}
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{sonarrData.map((sonarr) => (
<ServerInstance
key={`sonarr-config-${sonarr.id}`}
name={sonarr.name}
hostname={sonarr.hostname}
port={sonarr.port}
profileName={sonarr.activeProfileName}
isSSL={sonarr.useSsl}
isSonarr
isDefault={sonarr.isDefault}
is4k={sonarr.is4k}
externalUrl={sonarr.externalUrl}
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: sonarr.id,
type: 'sonarr',
})
}
/>
))}
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
onClick={() =>
setEditSonarrModal({ open: true, sonarr: null })
}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addsonarr)}</span>
</Button>
</div>
</li>
</ul>
</>
)}
</div>
<div className="mb-6 mt-10">
<h3 className="heading">
{intl.formatMessage(messages.overrideRules)}
</h3>
<p className="description">
{intl.formatMessage(messages.overrideRulesDescription, {
serverType: 'Sonarr',
})}
</p>
</div>
<div className="section">
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{rules && radarrData && sonarrData && (
<OverrideRuleTiles
rules={rules}
radarrServices={radarrData}
sonarrServices={sonarrData}
setOverrideRuleModal={setOverrideRuleModal}
revalidate={revalidate}
/>
)}
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
disabled={!radarrData?.length && !sonarrData?.length}
onClick={() =>
setOverrideRuleModal({
open: true,
rule: null,
})
}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addrule)}</span>
</Button>
</div>
</li>
</ul>
</div>
{overrideRuleModal.open && radarrData && sonarrData && (
<OverrideRuleModal
rule={overrideRuleModal.rule}
{routingRuleModal.open && radarrData && sonarrData && (
<RoutingRuleModal
rule={routingRuleModal.rule}
onClose={() => {
setOverrideRuleModal({
open: false,
rule: null,
});
revalidate();
setRoutingRuleModal({ open: false, rule: null });
revalidateRules();
}}
radarrServices={radarrData}
sonarrServices={sonarrData}
prefillData={routingRuleModal.prefillData}
/>
)}
<div className="mb-6">
<h3 className="heading">{intl.formatMessage(messages.instances)}</h3>
<p className="description">
{intl.formatMessage(messages.instancesDescription)}
</p>
</div>
<div className="section">
{(!radarrData && !radarrError) || (!sonarrData && !sonarrError) ? (
<LoadingSpinner />
) : (
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{radarrData?.map((radarr) => (
<ServerInstance
key={`radarr-config-${radarr.id}`}
name={radarr.name}
isDefault={radarr.isDefault}
hostname={radarr.hostname}
port={radarr.port}
ruleCount={
routingRules?.filter(
(r) =>
r.serviceType === 'radarr' &&
r.targetServiceId === radarr.id
).length ?? 0
}
isSSL={radarr.useSsl}
is4k={radarr.is4k}
externalUrl={radarr.externalUrl}
onEdit={() => setEditRadarrModal({ open: true, radarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: radarr.id,
type: 'radarr',
})
}
/>
))}
{sonarrData?.map((sonarr) => (
<ServerInstance
key={`sonarr-config-${sonarr.id}`}
name={sonarr.name}
isDefault={sonarr.isDefault}
hostname={sonarr.hostname}
port={sonarr.port}
ruleCount={
routingRules?.filter(
(r) =>
r.serviceType === 'sonarr' &&
r.targetServiceId === sonarr.id
).length ?? 0
}
isSSL={sonarr.useSsl}
isSonarr
is4k={sonarr.is4k}
externalUrl={sonarr.externalUrl}
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: sonarr.id,
type: 'sonarr',
})
}
/>
))}
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<Button
buttonType="ghost"
onClick={() =>
setEditRadarrModal({ open: true, radarr: null })
}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addRadarr)}</span>
</Button>
<Button
buttonType="ghost"
onClick={() =>
setEditSonarrModal({ open: true, sonarr: null })
}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addSonarr)}</span>
</Button>
</div>
</li>
</ul>
)}
</div>
<div className="mt-10">
{radarrData && sonarrData && routingRules && (
<RoutingRuleList
rules={routingRules}
radarrServices={radarrData}
sonarrServices={sonarrData}
onAddRule={(prefillData) =>
setRoutingRuleModal({ open: true, rule: null, prefillData })
}
onEditRule={(rule) => setRoutingRuleModal({ open: true, rule })}
revalidate={revalidateRules}
/>
)}
</div>
</>
);
};

View File

@@ -10,16 +10,9 @@ import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
import { 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('components.Settings.SonarrModal', {
createsonarr: 'Add New Sonarr Server',
create4ksonarr: 'Add New 4K Sonarr Server',
@@ -29,9 +22,6 @@ const messages = defineMessages('components.Settings.SonarrModal', {
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',
@@ -43,27 +33,8 @@ const messages = defineMessages('components.Settings.SonarrModal', {
ssl: 'Use SSL',
apiKey: 'API Key',
baseUrl: 'URL Base',
qualityprofile: 'Quality Profile',
languageprofile: 'Language Profile',
rootfolder: 'Root Folder',
seriesType: 'Series Type',
animeSeriesType: 'Anime Series Type',
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',
seasonfolders: 'Season Folders',
syncEnabled: 'Enable Scan',
externalUrl: 'External URL',
enableSearch: 'Enable Automatic Search',
@@ -74,16 +45,12 @@ const messages = defineMessages('components.Settings.SonarrModal', {
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 SonarrModalProps {
sonarr: SonarrSettings | null;
onClose: () => void;
onSave: () => void;
onSave: (savedInstance: SonarrSettings) => Promise<void>;
}
const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
@@ -112,17 +79,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
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: testResponse.languageProfiles
? Yup.number().required(
intl.formatMessage(messages.validationLanguageProfileRequired)
)
: Yup.number(),
externalUrl: Yup.string()
.test(
'valid-url',
@@ -230,16 +186,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
ssl: sonarr?.useSsl ?? false,
apiKey: sonarr?.apiKey,
baseUrl: sonarr?.baseUrl,
activeProfileId: sonarr?.activeProfileId,
activeLanguageProfileId: sonarr?.activeLanguageProfileId,
rootFolder: sonarr?.activeDirectory,
seriesType: sonarr?.seriesType,
animeSeriesType: sonarr?.animeSeriesType,
activeAnimeProfileId: sonarr?.activeAnimeProfileId,
activeAnimeLanguageProfileId: sonarr?.activeAnimeLanguageProfileId,
activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
tags: sonarr?.tags ?? [],
animeTags: sonarr?.animeTags ?? [],
isDefault: sonarr?.isDefault ?? false,
is4k: sonarr?.is4k ?? false,
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
@@ -251,13 +197,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
validationSchema={SonarrSettingsSchema}
onSubmit={async (values) => {
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,
@@ -265,44 +204,32 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
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,
seriesType: values.seriesType,
animeSeriesType: values.animeSeriesType,
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,
is4k: values.is4k,
enableSeasonFolders: values.enableSeasonFolders,
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
};
let savedInstance: SonarrSettings;
if (!sonarr) {
await axios.post('/api/v1/settings/sonarr', submission);
const response = await axios.post<SonarrSettings>(
'/api/v1/settings/sonarr',
submission
);
savedInstance = response.data;
} else {
await axios.put(
const response = await axios.put<SonarrSettings>(
`/api/v1/settings/sonarr/${sonarr.id}`,
submission
);
savedInstance = response.data;
}
onSave();
await onSave(savedInstance);
} catch (e) {
// set error here
// TODO: handle error
}
}}
>
@@ -534,444 +461,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="seriesType" className="text-label">
{intl.formatMessage(messages.seriesType)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="seriesType"
name="seriesType"
disabled={!isValidated || isTesting}
>
<option value="standard">Standard</option>
<option value="daily">Daily</option>
</Field>
</div>
</div>
{errors.seriesType && touched.seriesType && (
<div className="error">{errors.seriesType}</div>
)}
</div>
<div className="form-row">
<label htmlFor="activeProfileId" className="text-label">
{intl.formatMessage(messages.qualityprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeProfileId"
name="activeProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(
messages.selectQualityProfile
)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeProfileId &&
touched.activeProfileId &&
typeof errors.activeProfileId === 'string' && (
<div className="error">{errors.activeProfileId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="rootFolder" className="text-label">
{intl.formatMessage(messages.rootfolder)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="rootFolder"
name="rootFolder"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(
messages.testFirstRootFolders
)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
{testResponse.languageProfiles && (
<div className="form-row">
<label
htmlFor="activeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.languageprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeLanguageProfileId"
name="activeLanguageProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadinglanguageprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstLanguageProfiles
)
: intl.formatMessage(
messages.selectLanguageProfile
)}
</option>
{testResponse.languageProfiles.length > 0 &&
testResponse.languageProfiles.map((language) => (
<option
key={`loaded-profile-${language.id}`}
value={language.id}
>
{language.name}
</option>
))}
</Field>
</div>
{errors.activeLanguageProfileId &&
touched.activeLanguageProfileId && (
<div className="error">
{errors.activeLanguageProfileId}
</div>
)}
</div>
</div>
)}
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
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<OptionType, true>) => {
setFieldValue(
'tags',
value.map((option) => option.value)
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="animeSeriesType" className="text-label">
{intl.formatMessage(messages.animeSeriesType)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="animeSeriesType"
name="animeSeriesType"
disabled={!isValidated || isTesting}
>
<option value="standard">Standard</option>
<option value="anime">Anime</option>
</Field>
</div>
</div>
{errors.animeSeriesType && touched.animeSeriesType && (
<div className="error">{errors.animeSeriesType}</div>
)}
</div>
<div className="form-row">
<label htmlFor="activeAnimeProfileId" className="text-label">
{intl.formatMessage(messages.animequalityprofile)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeAnimeProfileId"
name="activeAnimeProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(
messages.selectQualityProfile
)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeAnimeProfileId &&
touched.activeAnimeProfileId && (
<div className="error">
{errors.activeAnimeProfileId}
</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="activeAnimeRootFolder" className="text-label">
{intl.formatMessage(messages.animerootfolder)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeAnimeRootFolder"
name="activeAnimeRootFolder"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(
messages.testFirstRootFolders
)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.activeAnimeRootFolder &&
touched.activeAnimeRootFolder && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
{testResponse.languageProfiles && (
<div className="form-row">
<label
htmlFor="activeAnimeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.animelanguageprofile)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeAnimeLanguageProfileId"
name="activeAnimeLanguageProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadinglanguageprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstLanguageProfiles
)
: intl.formatMessage(
messages.selectLanguageProfile
)}
</option>
{testResponse.languageProfiles.length > 0 &&
testResponse.languageProfiles.map((language) => (
<option
key={`loaded-profile-${language.id}`}
value={language.id}
>
{language.name}
</option>
))}
</Field>
</div>
{errors.activeAnimeLanguageProfileId &&
touched.activeAnimeLanguageProfileId && (
<div className="error">
{errors.activeAnimeLanguageProfileId}
</div>
)}
</div>
</div>
)}
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.animeTags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
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)
}
/>
</div>
</div>
<div className="form-row">
<label
htmlFor="enableSeasonFolders"
className="checkbox-label"
>
{intl.formatMessage(messages.seasonfolders)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="enableSeasonFolders"
name="enableSeasonFolders"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="externalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}
@@ -992,6 +481,21 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="enableSeasonFolders"
className="checkbox-label"
>
{intl.formatMessage(messages.seasonfolders)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="enableSeasonFolders"
name="enableSeasonFolders"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="syncEnabled" className="checkbox-label">
{intl.formatMessage(messages.syncEnabled)}