diff --git a/overseerr-api.yml b/overseerr-api.yml index dfbbfd084..4e04b6788 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1932,6 +1932,11 @@ components: type: string native_name: type: string + OverrideRule: + type: object + properties: + id: + type: string securitySchemes: cookieAuth: type: apiKey @@ -6956,6 +6961,68 @@ paths: type: array items: $ref: '#/components/schemas/WatchProviderDetails' + /overrideRule: + get: + summary: Get override rules + description: Returns a list of all override rules with their conditions and settings + tags: + - overriderule + responses: + '200': + description: Override rules returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + post: + summary: Create override rule + description: Creates a new Override Rule from the request body. + tags: + - overriderule + responses: + '200': + description: 'Values were successfully created' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + /overrideRule/{ruleId}: + put: + summary: Update override rule + description: Updates an Override Rule from the request body. + tags: + - overriderule + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + delete: + summary: Delete override rule by ID + description: Deletes the override rule with the provided ruleId. + tags: + - overriderule + parameters: + - in: path + name: ruleId + required: true + schema: + type: number + responses: + '200': + description: Override rule successfully deleted + content: + application/json: + schema: + $ref: '#/components/schemas/OverrideRule' security: - cookieAuth: [] - apiKey: [] diff --git a/server/entity/OverrideRule.ts b/server/entity/OverrideRule.ts new file mode 100644 index 000000000..394cc4293 --- /dev/null +++ b/server/entity/OverrideRule.ts @@ -0,0 +1,53 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinTable, + ManyToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from './User'; + +@Entity() +class OverrideRule { + @PrimaryGeneratedColumn() + public id: number; + + @Column({ type: 'int', nullable: true }) + public radarrServiceId?: number; + + @Column({ type: 'int', nullable: true }) + public sonarrServiceId?: number; + + @Column({ nullable: true }) + public genre?: string; + + @Column({ nullable: true }) + public language?: string; + + @ManyToMany(() => User) + @JoinTable() + public users: User[]; + + @Column({ type: 'int', nullable: true }) + public profileId?: number; + + @Column({ nullable: true }) + public rootFolder?: string; + + @Column({ type: 'simple-array', nullable: true }) + public tags?: number[]; + + @CreateDateColumn() + public createdAt: Date; + + @UpdateDateColumn() + public updatedAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} + +export default OverrideRule; diff --git a/server/interfaces/api/overrideRuleInterfaces.ts b/server/interfaces/api/overrideRuleInterfaces.ts new file mode 100644 index 000000000..5ae61a684 --- /dev/null +++ b/server/interfaces/api/overrideRuleInterfaces.ts @@ -0,0 +1,3 @@ +import type OverrideRule from '@server/entity/OverrideRule'; + +export type OverrideRuleResultsResponse = OverrideRule[]; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 425fc1389..1417fbd8f 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -76,6 +76,7 @@ export interface DVRSettings { syncEnabled: boolean; preventSearch: boolean; tagRequests: boolean; + overrideRule: number[]; } export interface RadarrSettings extends DVRSettings { diff --git a/server/routes/index.ts b/server/routes/index.ts index 120e2e86b..f064e6031 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -15,6 +15,7 @@ import { checkUser, isAuthenticated } from '@server/middleware/auth'; import { mapWatchProviderDetails } from '@server/models/common'; import { mapProductionCompany } from '@server/models/Movie'; import { mapNetwork } from '@server/models/Tv'; +import overrideRuleRoutes from '@server/routes/overrideRule'; import settingsRoutes from '@server/routes/settings'; import watchlistRoutes from '@server/routes/watchlist'; import { @@ -160,6 +161,11 @@ router.use('/service', isAuthenticated(), serviceRoutes); router.use('/issue', isAuthenticated(), issueRoutes); router.use('/issueComment', isAuthenticated(), issueCommentRoutes); router.use('/auth', authRoutes); +router.use( + '/overrideRule', + isAuthenticated(Permission.ADMIN), + overrideRuleRoutes +); router.get('/regions', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); diff --git a/server/routes/overrideRule.ts b/server/routes/overrideRule.ts new file mode 100644 index 000000000..6662189d4 --- /dev/null +++ b/server/routes/overrideRule.ts @@ -0,0 +1,102 @@ +import { getRepository } from '@server/datasource'; +import OverrideRule from '@server/entity/OverrideRule'; +import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces'; +import { Permission } from '@server/lib/permissions'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; + +const overrideRuleRoutes = Router(); + +overrideRuleRoutes.get( + '/', + isAuthenticated(Permission.ADMIN), + async (req, res, next) => { + const overrideRuleRepository = getRepository(OverrideRule); + + try { + const rules = await overrideRuleRepository.find({}); + + return res.status(200).json(rules as OverrideRuleResultsResponse); + } catch (e) { + next({ status: 404, message: e.message }); + } + } +); + +overrideRuleRoutes.post< + Record, + OverrideRule, + { + genre?: string; + language?: string; + profileId?: number; + rootFolder?: string; + tags?: number[]; + radarrServiceId?: number; + sonarrServiceId?: number; + } +>('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => { + const overrideRuleRepository = getRepository(OverrideRule); + + try { + const rule = new OverrideRule({ + genre: req.body.genre, + language: req.body.language, + profileId: req.body.profileId, + rootFolder: req.body.rootFolder, + tags: req.body.tags, + radarrServiceId: req.body.radarrServiceId, + sonarrServiceId: req.body.sonarrServiceId, + }); + + const newIssue = await overrideRuleRepository.save(rule); + + return res.status(200).json(newIssue); + } catch (e) { + next({ status: 404, message: e.message }); + } +}); + +overrideRuleRoutes.put< + { ruleId: string }, + OverrideRule, + { + genre?: string; + language?: string; + profileId?: number; + rootFolder?: string; + tags?: number[]; + radarrServiceId?: number; + sonarrServiceId?: number; + } +>('/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => { + const overrideRuleRepository = getRepository(OverrideRule); + + try { + const rule = await overrideRuleRepository.findOne({ + where: { + id: Number(req.params.ruleId), + }, + }); + + if (!rule) { + return next({ status: 404, message: 'Override Rule not found.' }); + } + + rule.genre = req.body.genre; + rule.language = req.body.language; + rule.profileId = req.body.profileId; + rule.rootFolder = req.body.rootFolder; + rule.tags = req.body.tags; + rule.radarrServiceId = req.body.radarrServiceId; + rule.sonarrServiceId = req.body.sonarrServiceId; + + const newIssue = await overrideRuleRepository.save(rule); + + return res.status(200).json(newIssue); + } catch (e) { + next({ status: 404, message: e.message }); + } +}); + +export default overrideRuleRoutes; diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx index 4930bd85d..8cebf06f7 100644 --- a/src/components/Common/Modal/index.tsx +++ b/src/components/Common/Modal/index.tsx @@ -7,7 +7,7 @@ import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll'; import globalMessages from '@app/i18n/globalMessages'; import { Transition } from '@headlessui/react'; import type { MouseEvent } from 'react'; -import React, { Fragment, useRef } from 'react'; +import React, { Fragment, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; import { useIntl } from 'react-intl'; @@ -66,8 +66,12 @@ const Modal = React.forwardRef( ) => { const intl = useIntl(); const modalRef = useRef(null); + const backgroundClickableRef = useRef(backgroundClickable); // This ref is used to detect state change inside the useClickOutside hook + useEffect(() => { + backgroundClickableRef.current = backgroundClickable; + }, [backgroundClickable]); useClickOutside(modalRef, () => { - if (onCancel && backgroundClickable) { + if (onCancel && backgroundClickableRef.current) { onCancel(); } }); diff --git a/src/components/Settings/OverrideRuleModal.tsx b/src/components/Settings/OverrideRuleModal.tsx new file mode 100644 index 000000000..3220e87cc --- /dev/null +++ b/src/components/Settings/OverrideRuleModal.tsx @@ -0,0 +1,317 @@ +import Modal from '@app/components/Common/Modal'; +import LanguageSelector from '@app/components/LanguageSelector'; +import { GenreSelector } 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 { Field, Formik } from 'formik'; +import { useIntl } from 'react-intl'; +import Select from 'react-select'; +import { useToasts } from 'react-toast-notifications'; + +const messages = defineMessages('components.Settings.RadarrModal', { + createrule: 'New Override Rule', + editrule: 'Edit Override Rule', + create: 'Create rule', + 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; + testResponse: DVRTestResponse; + radarrId?: number; + sonarrId?: number; +} + +const OverrideRuleModal = ({ + onClose, + rule, + testResponse, + radarrId, + sonarrId, +}: OverrideRuleModalProps) => { + const intl = useIntl(); + const { addToast } = useToasts(); + const { currentSettings } = useSettings(); + + return ( + + { + try { + const submission = { + genre: values.genre || null, + language: values.language || null, + profileId: Number(values.profileId) || null, + rootFolder: values.rootFolder || null, + tags: values.tags || null, + radarrServiceId: radarrId, + sonarrServiceId: sonarrId, + }; + if (!rule) { + const res = await fetch('/api/v1/overrideRule', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(submission), + }); + if (!res.ok) throw new Error(); + addToast(intl.formatMessage(messages.ruleCreated), { + appearance: 'success', + autoDismiss: true, + }); + } else { + const res = await fetch(`/api/v1/overrideRule/${rule.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(submission), + }); + if (!res.ok) throw new Error(); + addToast(intl.formatMessage(messages.ruleUpdated), { + appearance: 'success', + autoDismiss: true, + }); + } + onClose(); + } catch (e) { + // set error here + } + }} + > + {({ + errors, + touched, + values, + handleSubmit, + setFieldValue, + isSubmitting, + isValid, + }) => { + return ( + handleSubmit()} + title={ + !rule + ? intl.formatMessage(messages.createrule) + : intl.formatMessage(messages.editrule) + } + > +
+

+ Condition +

+
+ +
+
+ { + setFieldValue( + 'genre', + genres?.map((v) => v.value).join(',') + ); + }} + /> +
+ {errors.genre && + touched.genre && + typeof errors.genre === 'string' && ( +
{errors.genre}
+ )} +
+
+
+ +
+
+ { + setFieldValue('language', value); + }} + /> +
+ {errors.genre && + touched.genre && + typeof errors.genre === 'string' && ( +
{errors.genre}
+ )} +
+
+

+ Settings +

+
+ +
+
+ + + {testResponse.rootFolders.length > 0 && + testResponse.rootFolders.map((folder) => ( + + ))} + +
+ {errors.rootFolder && + touched.rootFolder && + typeof errors.rootFolder === 'string' && ( +
{errors.rootFolder}
+ )} +
+
+
+ +
+
+ + + {testResponse.profiles.length > 0 && + testResponse.profiles.map((profile) => ( + + ))} + +
+ {errors.profileId && + touched.profileId && + typeof errors.profileId === 'string' && ( +
{errors.profileId}
+ )} +
+
+
+ +
+ + options={testResponse.tags.map((tag) => ({ + label: tag.label, + value: tag.id, + }))} + isMulti + placeholder={intl.formatMessage(messages.selecttags)} + className="react-select-container" + classNamePrefix="react-select" + value={ + (values?.tags + ?.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) + ); + }} + noOptionsMessage={() => + intl.formatMessage(messages.notagoptions) + } + /> +
+
+
+
+ ); + }} +
+
+ ); +}; + +export default OverrideRuleModal; diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index 51c74ba89..fbd97f39b 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -1,14 +1,21 @@ +import Button from '@app/components/Common/Button'; import Modal from '@app/components/Common/Modal'; import SensitiveInput from '@app/components/Common/SensitiveInput'; +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 type { RadarrSettings } from '@server/lib/settings'; +import { PlusIcon } from '@heroicons/react/24/solid'; +import type { TmdbGenre } from '@server/api/themoviedb/interfaces'; +import type OverrideRule from '@server/entity/OverrideRule'; +import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces'; +import type { Language, RadarrSettings } from '@server/lib/settings'; 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 useSWR from 'swr'; import * as Yup from 'yup'; type OptionType = { @@ -69,41 +76,46 @@ const messages = defineMessages('components.Settings.RadarrModal', { announced: 'Announced', inCinemas: 'In Cinemas', released: 'Released', + overrideRules: 'Override Rules', + addrule: 'New Override Rule', }); -interface TestResponse { - profiles: { - id: number; - name: string; - }[]; - rootFolders: { - id: number; - path: string; - }[]; - tags: { - id: number; - label: string; - }[]; - urlBase?: string; -} - interface RadarrModalProps { radarr: RadarrSettings | null; - onClose: () => void; + onClose?: () => void; onSave: () => void; + overrideRuleModal: { open: boolean; rule: OverrideRule | null }; + setOverrideRuleModal: ({ + open, + rule, + testResponse, + }: { + open: boolean; + rule: OverrideRule | null; + testResponse: DVRTestResponse; + }) => void; } -const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { +const RadarrModal = ({ + onClose, + radarr, + onSave, + overrideRuleModal, + setOverrideRuleModal, +}: RadarrModalProps) => { const intl = useIntl(); + const { data: rules, mutate: revalidate } = + useSWR('/api/v1/overrideRule'); const initialLoad = useRef(false); const { addToast } = useToasts(); const [isValidated, setIsValidated] = useState(radarr ? true : false); const [isTesting, setIsTesting] = useState(false); - const [testResponse, setTestResponse] = useState({ + const [testResponse, setTestResponse] = useState({ profiles: [], rootFolders: [], tags: [], }); + const RadarrSettingsSchema = Yup.object().shape({ name: Yup.string().required( intl.formatMessage(messages.validationNameRequired) @@ -220,6 +232,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { } }, [radarr, testConnection]); + useEffect(() => { + revalidate(); + }, [overrideRuleModal, revalidate]); + return ( { values.is4k ? messages.edit4kradarr : messages.editradarr ) } + backgroundClickable={!overrideRuleModal.open} >
@@ -753,6 +770,37 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
+

+ {intl.formatMessage(messages.overrideRules)} +

+
    + {rules && ( + + )} +
  • +
    + +
    +
  • +
); }} @@ -761,4 +809,113 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { ); }; +interface OverrideRulesProps { + rules: OverrideRule[]; + setOverrideRuleModal: ({ + open, + rule, + testResponse, + }: { + open: boolean; + rule: OverrideRule | null; + testResponse: DVRTestResponse; + }) => void; + testResponse: DVRTestResponse; + radarr: RadarrSettings | null; +} +const OverrideRules = ({ + rules, + setOverrideRuleModal, + testResponse, + radarr, +}: OverrideRulesProps) => { + const intl = useIntl(); + const { data: languages } = useSWR('/api/v1/languages'); + const { data: genres } = useSWR('/api/v1/genres/movie'); + + return rules + ?.filter( + (rule) => + rule.radarrServiceId !== null && rule.radarrServiceId === radarr?.id + ) + .map((rule) => ( + + )); +}; + export default RadarrModal; diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index 63cd463f7..06e627c32 100644 --- a/src/components/Settings/SettingsServices.tsx +++ b/src/components/Settings/SettingsServices.tsx @@ -6,12 +6,14 @@ 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/OverrideRuleModal'; import RadarrModal from '@app/components/Settings/RadarrModal'; 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 { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import { Fragment, useState } from 'react'; import { useIntl } from 'react-intl'; @@ -57,6 +59,22 @@ interface ServerInstanceProps { onDelete: () => void; } +export interface DVRTestResponse { + profiles: { + id: number; + name: string; + }[]; + rootFolders: { + id: number; + path: string; + }[]; + tags: { + id: number; + label: string; + }[]; + urlBase?: string; +} + const ServerInstance = ({ name, hostname, @@ -193,6 +211,15 @@ const SettingsServices = () => { type: 'radarr', serverId: null, }); + const [overrideRuleModal, setOverrideRuleModal] = useState<{ + open: boolean; + rule: OverrideRule | null; + testResponse: DVRTestResponse | null; + }>({ + open: false, + rule: null, + testResponse: null, + }); const deleteServer = async () => { const res = await fetch( @@ -227,15 +254,35 @@ const SettingsServices = () => { })}

+ {overrideRuleModal.open && overrideRuleModal.testResponse && ( + + setOverrideRuleModal({ + open: false, + rule: null, + testResponse: null, + }) + } + testResponse={overrideRuleModal.testResponse} + radarrId={editRadarrModal.radarr?.id} + sonarrId={editSonarrModal.sonarr?.id} + /> + )} {editRadarrModal.open && ( setEditRadarrModal({ open: false, radarr: null })} + onClose={() => { + if (!overrideRuleModal.open) + setEditRadarrModal({ open: false, radarr: null }); + }} onSave={() => { revalidateRadarr(); mutate('/api/v1/settings/public'); setEditRadarrModal({ open: false, radarr: null }); }} + overrideRuleModal={overrideRuleModal} + setOverrideRuleModal={setOverrideRuleModal} /> )} {editSonarrModal.open && ( diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index 7890d4b5c..c0bdd75d0 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -1,5 +1,6 @@ import Modal from '@app/components/Common/Modal'; import SensitiveInput from '@app/components/Common/SensitiveInput'; +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'; @@ -77,26 +78,11 @@ const messages = defineMessages('components.Settings.SonarrModal', { selecttags: 'Select tags', }); -interface TestResponse { - profiles: { +interface SonarrTestResponse extends DVRTestResponse { + languageProfiles: { id: number; name: string; - }[]; - rootFolders: { - id: number; - path: string; - }[]; - languageProfiles: - | { - id: number; - name: string; - }[] - | null; - tags: { - id: number; - label: string; - }[]; - urlBase?: string; + }[] | null; } interface SonarrModalProps { @@ -111,7 +97,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { const { addToast } = useToasts(); const [isValidated, setIsValidated] = useState(sonarr ? true : false); const [isTesting, setIsTesting] = useState(false); - const [testResponse, setTestResponse] = useState({ + const [testResponse, setTestResponse] = useState({ profiles: [], rootFolders: [], languageProfiles: null, @@ -197,7 +183,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { }), }); if (!res.ok) throw new Error(); - const data: TestResponse = await res.json(); + const data: SonarrTestResponse = await res.json(); setIsValidated(true); setTestResponse(data);