diff --git a/overseerr-api.yml b/overseerr-api.yml
index 6a387a6b6..cf019068b 100644
--- a/overseerr-api.yml
+++ b/overseerr-api.yml
@@ -1944,6 +1944,11 @@ components:
properties:
id:
type: string
+ AutoApprovalRule:
+ type: object
+ properties:
+ id:
+ type: string
securitySchemes:
cookieAuth:
type: apiKey
@@ -7042,6 +7047,21 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/OverrideRule'
+ /autoApprovalRule:
+ get:
+ summary: 'Get autoapproval rules'
+ description: 'Returns a list of all autoapproval rules with their conditions'
+ tags:
+ - 'autoapprovalrule'
+ responses:
+ '200':
+ description: 'Values were successfully created'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/AutoApprovalRule'
security:
- cookieAuth: []
- apiKey: []
diff --git a/server/entity/AutoApproval/AutoApprovalSpecificationBase.ts b/server/entity/AutoApproval/AutoApprovalSpecificationBase.ts
new file mode 100644
index 000000000..e614e3f48
--- /dev/null
+++ b/server/entity/AutoApproval/AutoApprovalSpecificationBase.ts
@@ -0,0 +1,11 @@
+class AutoApprovalSpecificationBase {
+ public implementationName: string;
+ public comparator: string;
+ public value: unknown;
+
+ isSatisfiedBy(): boolean {
+ return false;
+ }
+}
+
+export default AutoApprovalSpecificationBase;
diff --git a/server/entity/AutoApproval/GenreSpecification.tsx b/server/entity/AutoApproval/GenreSpecification.tsx
new file mode 100644
index 000000000..d0084fc5f
--- /dev/null
+++ b/server/entity/AutoApproval/GenreSpecification.tsx
@@ -0,0 +1,19 @@
+import AutoApprovalSpecificationBase from '@server/entity/AutoApproval/AutoApprovalSpecificationBase';
+
+class GenreSpecification extends AutoApprovalSpecificationBase {
+ public implementationName = 'genre';
+ public isSatisfiedBy(): boolean {
+ return false;
+ }
+
+ public value: string;
+
+ public comparator = 'is';
+ public constructor(comparator?: string, value?: string) {
+ super();
+ this.comparator = comparator ?? 'is';
+ this.value = value ?? '';
+ }
+}
+
+export default GenreSpecification;
diff --git a/server/entity/AutoApproval/ReleaseYearSpecification.tsx b/server/entity/AutoApproval/ReleaseYearSpecification.tsx
new file mode 100644
index 000000000..f4ac5355a
--- /dev/null
+++ b/server/entity/AutoApproval/ReleaseYearSpecification.tsx
@@ -0,0 +1,43 @@
+import { AutoApprovalSpecificationBase } from '@server/entity/AutoApproval/AutoApprovalSpecificationBase';
+
+class ReleaseYearSpecification extends AutoApprovalSpecificationBase {
+ public implementationName = 'releaseyear';
+ public comparator = 'equals';
+ public value: string;
+ public options = [
+ { value: 'equals', text: '=' },
+ { value: 'equalsnot', text: '!=' },
+ { value: 'greaterthan', text: '>' },
+ { value: 'lessthan', text: '<' },
+ ];
+ public optionsContent;
+ constructor(comparison: string, value?: string) {
+ super({});
+ this.comparator = comparison;
+ this.value = value ?? '';
+ this.optionsContent = this.options.map((option) => (
+
+ ));
+ }
+ public Component = () => {
+ return (
+ <>
+
+
+ >
+ );
+ };
+}
+
+export default ReleaseYearSpecification;
diff --git a/server/entity/AutoApproval/UserSpecification.ts b/server/entity/AutoApproval/UserSpecification.ts
new file mode 100644
index 000000000..9d1d06b65
--- /dev/null
+++ b/server/entity/AutoApproval/UserSpecification.ts
@@ -0,0 +1,17 @@
+import AutoApprovalSpecificationBase from './AutoApprovalSpecificationBase';
+
+class UserSpecification extends AutoApprovalSpecificationBase {
+ public implementationName = 'user';
+ public isSatisfiedBy(): boolean {
+ return false;
+ }
+ public value: number;
+ public comparator: string;
+ public constructor(comparator?: string, value?: number) {
+ super();
+ this.comparator = comparator ?? 'is';
+ this.value = value ?? 0;
+ }
+}
+
+export default UserSpecification;
diff --git a/server/lib/autoapproval.ts b/server/lib/autoapproval.ts
new file mode 100644
index 000000000..572d2c481
--- /dev/null
+++ b/server/lib/autoapproval.ts
@@ -0,0 +1,5 @@
+import type AutoApprovalSpecificationBase from '@server/entity/AutoApproval/AutoApprovalSpecificationBase';
+export interface AutoApprovalRule {
+ name: string;
+ conditions: AutoApprovalSpecificationBase[];
+}
diff --git a/server/routes/autoApprovalRule.ts b/server/routes/autoApprovalRule.ts
new file mode 100644
index 000000000..0b8863712
--- /dev/null
+++ b/server/routes/autoApprovalRule.ts
@@ -0,0 +1,27 @@
+import GenreSpecification from '@server/entity/AutoApproval/GenreSpecification';
+import UserSpecification from '@server/entity/AutoApproval/UserSpecification';
+import type { AutoApprovalRule } from '@server/lib/autoapproval';
+import { isAuthenticated } from '@server/middleware/auth';
+import { Router } from 'express';
+
+const autoApprovalRuleRoutes = Router();
+
+autoApprovalRuleRoutes.get('/', isAuthenticated(), async (req, res, next) => {
+ try {
+ const data = [
+ {
+ name: 'Test Rule',
+ conditions: [
+ new UserSpecification('is', 1),
+ new GenreSpecification('is', '16,14'),
+ ],
+ },
+ ];
+
+ return res.status(200).json(data as AutoApprovalRule[]);
+ } catch (e) {
+ next({ status: 404, message: e.message });
+ }
+});
+
+export default autoApprovalRuleRoutes;
diff --git a/server/routes/index.ts b/server/routes/index.ts
index f064e6031..07d8ea63b 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 autoApprovalRuleRoutes from '@server/routes/autoApprovalRule';
import overrideRuleRoutes from '@server/routes/overrideRule';
import settingsRoutes from '@server/routes/settings';
import watchlistRoutes from '@server/routes/watchlist';
@@ -166,6 +167,11 @@ router.use(
isAuthenticated(Permission.ADMIN),
overrideRuleRoutes
);
+router.use(
+ '/autoApprovalRule',
+ isAuthenticated(Permission.ADMIN),
+ autoApprovalRuleRoutes
+);
router.get('/regions', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb();
diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx
index a947e2626..a26f3a77d 100644
--- a/src/components/Layout/Sidebar/index.tsx
+++ b/src/components/Layout/Sidebar/index.tsx
@@ -29,6 +29,7 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', {
blacklist: 'Blacklist',
issues: 'Issues',
users: 'Users',
+ autoapproval: 'Auto Approval',
settings: 'Settings',
});
diff --git a/src/components/Settings/SettingsAutoApproval/RuleModal/Specifications/GenreSpecificationItem.tsx b/src/components/Settings/SettingsAutoApproval/RuleModal/Specifications/GenreSpecificationItem.tsx
new file mode 100644
index 000000000..667f22fa3
--- /dev/null
+++ b/src/components/Settings/SettingsAutoApproval/RuleModal/Specifications/GenreSpecificationItem.tsx
@@ -0,0 +1,47 @@
+import Table from '@app/components/Common/Table';
+import { GenreSelector } from '@app/components/Selector';
+
+interface GenreSpecificationItemProps {
+ currentValue: string;
+ isMovie: boolean;
+ comparator: string;
+}
+export default function GenreSpecificationItem({
+ currentValue,
+ isMovie,
+ comparator,
+}: GenreSpecificationItemProps) {
+ const comparatorOptions = [
+ { label: 'is', value: 'is' },
+ { label: 'is not', value: 'isnot' },
+ { label: 'contains', value: 'contains' },
+ { label: 'does not contain', value: 'containsnot' },
+ ];
+ const comparatorItems = comparatorOptions.map((item) => (
+
+ ));
+ return (
+ <>
+
+
+
+
+ {
+ return;
+ }}
+ />
+
+ >
+ );
+}
diff --git a/src/components/Settings/SettingsAutoApproval/RuleModal/Specifications/UserSpecification.tsx b/src/components/Settings/SettingsAutoApproval/RuleModal/Specifications/UserSpecification.tsx
new file mode 100644
index 000000000..841136d6f
--- /dev/null
+++ b/src/components/Settings/SettingsAutoApproval/RuleModal/Specifications/UserSpecification.tsx
@@ -0,0 +1,42 @@
+import Table from '@app/components/Common/Table';
+import { UserSelector } from '@app/components/Selector';
+
+interface UserSpecificationItemProps {
+ currentValue: number;
+ comparator: string;
+}
+
+export default function UserSpecificationItem({
+ currentValue,
+ comparator,
+}: UserSpecificationItemProps) {
+ const comparatorItems = [
+ { label: 'is', value: 'is' },
+ { label: 'is not', value: 'isnot' },
+ { label: 'contains', value: 'contains' },
+ { label: 'does not contain', value: 'containsnot' },
+ ].map((item) => );
+ return (
+ <>
+
+
+
+
+ {
+ return;
+ }}
+ />
+
+ >
+ );
+}
diff --git a/src/components/Settings/SettingsAutoApproval/RuleModal/index.tsx b/src/components/Settings/SettingsAutoApproval/RuleModal/index.tsx
new file mode 100644
index 000000000..f386a006a
--- /dev/null
+++ b/src/components/Settings/SettingsAutoApproval/RuleModal/index.tsx
@@ -0,0 +1,145 @@
+import Button from '@app/components/Common/Button';
+import Modal from '@app/components/Common/Modal';
+import Table from '@app/components/Common/Table';
+import { Transition } from '@headlessui/react';
+import { PlusIcon } from '@heroicons/react/24/solid';
+import type AutoApprovalSpecificationBase from '@server/entity/AutoApproval/AutoApprovalSpecificationBase';
+import type { AutoApprovalRule } from '@server/lib/autoapproval';
+import { useState } from 'react';
+import GenreSpecificationItem from './Specifications/GenreSpecificationItem';
+import UserSpecificationItem from './Specifications/UserSpecification';
+
+interface RuleModalProps {
+ approvalRule: AutoApprovalRule | null;
+ onClose: () => void;
+ onSave: () => void;
+}
+
+const ReleaseYearCondition = (comparison = 'equals') => {
+ const options = [
+ { value: 'equals', text: '=' },
+ { value: 'greaterthan', text: '>' },
+ { value: 'lessthan', text: '<' },
+ ];
+ const optionsContent = options.map((option) => (
+
+ ));
+ return (
+
+ );
+};
+
+const ConditionItem = (condition: AutoApprovalSpecificationBase) => {
+ const [implementation, setImplementation] = useState(
+ condition.implementationName
+ );
+ return (
+
+
+
+
+
+ {
+ {
+ genre: (
+
+ ),
+ user: (
+
+ ),
+ 'release-year': ReleaseYearCondition('is'),
+ }[implementation]
+ }
+
+
+ );
+};
+
+const RuleModal = ({ onClose, approvalRule }: RuleModalProps) => {
+ const conditionsList = approvalRule?.conditions.map((condition) =>
+ ConditionItem(condition)
+ );
+
+ return (
+
+
+
+
This is a modal
+
+
+
+
+ Condition
+
+
+
+
+ {conditionsList}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default RuleModal;
diff --git a/src/components/Settings/SettingsAutoApproval/SettingsAutoApproval/index.tsx b/src/components/Settings/SettingsAutoApproval/SettingsAutoApproval/index.tsx
new file mode 100644
index 000000000..cfd081fe0
--- /dev/null
+++ b/src/components/Settings/SettingsAutoApproval/SettingsAutoApproval/index.tsx
@@ -0,0 +1,202 @@
+import Badge from '@app/components/Common/Badge';
+import Button from '@app/components/Common/Button';
+import Header from '@app/components/Common/Header';
+import RuleModal from '@app/components/Settings/SettingsAutoApproval/RuleModal';
+import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
+import type { AutoApprovalRule } from '@server/lib/autoapproval';
+import { useState } from 'react';
+import { defineMessages } from 'react-intl';
+
+export const messages = defineMessages('components.ApprovalRuleList', {
+ autoapprovalrules: 'Auto Approval Rules',
+ addRule: 'Add Approval Rule',
+});
+
+interface ApprovalRuleInstanceProps {
+ name: string;
+ currentRule: AutoApprovalRule;
+ onEdit: () => void;
+}
+
+const ApprovalRuleInstance = ({
+ name,
+ currentRule,
+ onEdit,
+}: ApprovalRuleInstanceProps) => {
+ const comparisonNames = new Map([
+ ['equals', '='],
+ ['greaterthan', '>'],
+ ['lessthan', '<'],
+ ['is', 'is'],
+ ['isnot', 'is not'],
+ ['contains', 'contains'],
+ ['does not contain'],
+ ]);
+ const valueNames: string[] = ['Action', 'Comedy', 'Documentary', 'Romance'];
+ const conditionBadges = currentRule.conditions.map((condition) => (
+
+ {condition.implementation} {comparisonNames.get(condition.comparisonType)}{' '}
+ {condition.value > 1000 ? condition.value : valueNames[condition.value]}
+
+ ));
+
+ return (
+
+
+
+
+
+ {conditionBadges}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const SettingsAutoApproval = () => {
+ const movieRuleData = [
+ {
+ name: 'Test Rule',
+ currentRule: {
+ name: 'Test Rule',
+ conditions: [
+ { implementation: 'genre', comparisonType: 'is', value: 0 },
+ {
+ implementation: 'release-year',
+ comparisonType: 'lessthan',
+ value: 2020,
+ },
+ { implementation: 'genre', comparisonType: 'isnot', value: 2 },
+ {
+ implementation: 'genre',
+ comparisonType: 'contains',
+ value: [1, 4],
+ },
+ ],
+ },
+ },
+ ];
+ const [editRuleModal, setEditRuleModal] = useState<{
+ open: boolean;
+ approvalRule: AutoApprovalRule | null;
+ }>({
+ open: false,
+ approvalRule: null,
+ });
+ return (
+
+
+
+
+
+ {editRuleModal.open && (
+
+ setEditRuleModal({ open: false, approvalRule: null })
+ }
+ onSave={() => {
+ setEditRuleModal({ open: false, approvalRule: null });
+ }}
+ />
+ )}
+ Movie Auto-approval rules
+
+
+ {movieRuleData.map((rule) => (
+
+ setEditRuleModal({
+ open: true,
+ approvalRule: rule.currentRule,
+ })
+ }
+ currentRule={{
+ name: rule.name,
+ conditions: rule.currentRule.conditions,
+ }}
+ />
+ ))}
+ -
+
+
+
+
+
+
+ Series Auto-approval rules
+
+
+
+ );
+};
+
+export default SettingsAutoApproval;
diff --git a/src/components/Settings/SettingsAutoApproval/index.tsx b/src/components/Settings/SettingsAutoApproval/index.tsx
new file mode 100644
index 000000000..bb7cafcd1
--- /dev/null
+++ b/src/components/Settings/SettingsAutoApproval/index.tsx
@@ -0,0 +1,193 @@
+import Badge from '@app/components/Common/Badge';
+import Button from '@app/components/Common/Button';
+import Header from '@app/components/Common/Header';
+import RuleModal from '@app/components/Settings/SettingsAutoApproval/RuleModal';
+import defineMessages from '@app/utils/defineMessages';
+import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
+import type { AutoApprovalRule } from '@server/lib/autoapproval';
+import { useState } from 'react';
+import useSWR from 'swr';
+
+export const messages = defineMessages('components.ApprovalRuleList', {
+ autoapprovalrules: 'Auto Approval Rules',
+ addRule: 'Add Approval Rule',
+});
+
+interface ApprovalRuleInstanceProps {
+ name: string;
+ currentRule: AutoApprovalRule;
+ onEdit: () => void;
+}
+
+const ApprovalRuleInstance = ({
+ name,
+ currentRule,
+ onEdit,
+}: ApprovalRuleInstanceProps) => {
+ const comparisonNames = new Map([
+ ['equals', '='],
+ ['greaterthan', '>'],
+ ['lessthan', '<'],
+ ['is', 'is'],
+ ['isnot', 'is not'],
+ ['contains', 'contains'],
+ ['does not contain'],
+ ]);
+ const conditionBadges = currentRule.conditions.map((condition) => (
+
+ <>
+ {condition.implementationName}{' '}
+ {comparisonNames.get(condition.comparator)} {condition.value}
+ >
+
+ ));
+
+ return (
+
+
+
+
+
+ {conditionBadges}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const SettingsAutoApproval = () => {
+ const { data: mockData } = useSWR(
+ '/api/v1/autoApprovalRule'
+ );
+ const movieRuleData = mockData;
+ const [editRuleModal, setEditRuleModal] = useState<{
+ open: boolean;
+ approvalRule: AutoApprovalRule | null;
+ }>({
+ open: false,
+ approvalRule: null,
+ });
+ return (
+
+
+
+
+
+ {editRuleModal.open && (
+
+ setEditRuleModal({ open: false, approvalRule: null })
+ }
+ onSave={() => {
+ setEditRuleModal({ open: false, approvalRule: null });
+ }}
+ />
+ )}
+ Movie Auto-approval rules
+
+
+ {movieRuleData &&
+ movieRuleData.map((rule) => (
+
+ setEditRuleModal({
+ open: true,
+ approvalRule: rule,
+ })
+ }
+ currentRule={{
+ name: rule.name,
+ conditions: rule.conditions,
+ }}
+ />
+ ))}
+ -
+
+
+
+
+
+
+ Series Auto-approval rules
+
+
+
+ );
+};
+
+export default SettingsAutoApproval;
diff --git a/src/components/Settings/SettingsLayout.tsx b/src/components/Settings/SettingsLayout.tsx
index 6336bad01..82bf45247 100644
--- a/src/components/Settings/SettingsLayout.tsx
+++ b/src/components/Settings/SettingsLayout.tsx
@@ -14,6 +14,7 @@ const messages = defineMessages('components.Settings', {
menuJellyfinSettings: '{mediaServerName}',
menuServices: 'Services',
menuNotifications: 'Notifications',
+ menuAutoApproval: 'Auto Approval',
menuLogs: 'Logs',
menuJobs: 'Jobs & Cache',
menuAbout: 'About',
@@ -58,6 +59,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
route: '/settings/notifications/email',
regex: /^\/settings\/notifications/,
},
+ {
+ text: intl.formatMessage(messages.menuAutoApproval),
+ route: '/settings/autoapproval',
+ regex: /^\/settings\/autoapproval/,
+ },
{
text: intl.formatMessage(messages.menuLogs),
route: '/settings/logs',
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json
index 3fce7abd7..ad60d235a 100644
--- a/src/i18n/locale/en.json
+++ b/src/i18n/locale/en.json
@@ -1064,6 +1064,7 @@
"components.Settings.menuJobs": "Jobs & Cache",
"components.Settings.menuLogs": "Logs",
"components.Settings.menuNotifications": "Notifications",
+ "components.Settings.menuAutoApproval": "Auto Approval",
"components.Settings.menuPlexSettings": "Plex",
"components.Settings.menuServices": "Services",
"components.Settings.menuUsers": "Users",
diff --git a/src/pages/settings/autoapproval.tsx b/src/pages/settings/autoapproval.tsx
new file mode 100644
index 000000000..6e1f5e461
--- /dev/null
+++ b/src/pages/settings/autoapproval.tsx
@@ -0,0 +1,16 @@
+import SettingsAutoApproval from '@app/components/Settings/SettingsAutoApproval';
+import SettingsLayout from '@app/components/Settings/SettingsLayout';
+import useRouteGuard from '@app/hooks/useRouteGuard';
+import { Permission } from '@app/hooks/useUser';
+import type { NextPage } from 'next';
+
+const SettingsAutoApprovalPage: NextPage = () => {
+ useRouteGuard(Permission.ADMIN);
+ return (
+
+
+
+ );
+};
+
+export default SettingsAutoApprovalPage;