diff --git a/overseerr-api.yml b/overseerr-api.yml
index 4e04b678..7f894a21 100644
--- a/overseerr-api.yml
+++ b/overseerr-api.yml
@@ -3760,6 +3760,11 @@ paths:
type: string
enum: [created, updated, requests, displayname]
default: created
+ - in: query
+ name: q
+ required: false
+ schema:
+ type: string
responses:
'200':
description: A JSON array of all users
diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts
index ac4cfc7c..bb852506 100644
--- a/server/entity/MediaRequest.ts
+++ b/server/entity/MediaRequest.ts
@@ -743,6 +743,14 @@ export class MediaRequest {
where: { radarrServiceId: radarrSettings.id },
});
const appliedOverrideRules = overrideRules.filter((rule) => {
+ if (
+ rule.users &&
+ !rule.users
+ .split(',')
+ .some((userId) => Number(userId) === this.requestedBy.id)
+ ) {
+ return false;
+ }
if (
rule.genre &&
!rule.genre
@@ -1114,6 +1122,14 @@ export class MediaRequest {
where: { radarrServiceId: sonarrSettings.id },
});
const appliedOverrideRules = overrideRules.filter((rule) => {
+ if (
+ rule.users &&
+ !rule.users
+ .split(',')
+ .some((userId) => Number(userId) === this.requestedBy.id)
+ ) {
+ return false;
+ }
if (
rule.genre &&
!rule.genre
diff --git a/server/entity/OverrideRule.ts b/server/entity/OverrideRule.ts
index ab576891..bf137343 100644
--- a/server/entity/OverrideRule.ts
+++ b/server/entity/OverrideRule.ts
@@ -17,6 +17,9 @@ class OverrideRule {
@Column({ type: 'int', nullable: true })
public sonarrServiceId?: number;
+ @Column({ nullable: true })
+ public users?: string;
+
@Column({ nullable: true })
public genre?: string;
diff --git a/server/routes/overrideRule.ts b/server/routes/overrideRule.ts
index 3fdf06c6..912a68aa 100644
--- a/server/routes/overrideRule.ts
+++ b/server/routes/overrideRule.ts
@@ -27,6 +27,7 @@ overrideRuleRoutes.post<
Record,
OverrideRule,
{
+ users?: string;
genre?: string;
language?: string;
keywords?: string;
@@ -41,6 +42,7 @@ overrideRuleRoutes.post<
try {
const rule = new OverrideRule({
+ users: req.body.users,
genre: req.body.genre,
language: req.body.language,
keywords: req.body.keywords,
@@ -63,6 +65,7 @@ overrideRuleRoutes.put<
{ ruleId: string },
OverrideRule,
{
+ users?: string;
genre?: string;
language?: string;
keywords?: string;
@@ -86,6 +89,7 @@ overrideRuleRoutes.put<
return next({ status: 404, message: 'Override Rule not found.' });
}
+ rule.users = req.body.users;
rule.genre = req.body.genre;
rule.language = req.body.language;
rule.keywords = req.body.keywords;
diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts
index 2a29c037..1c3b682b 100644
--- a/server/routes/user/index.ts
+++ b/server/routes/user/index.ts
@@ -34,8 +34,16 @@ router.get('/', async (req, res, next) => {
try {
const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0;
+ const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
let query = getRepository(User).createQueryBuilder('user');
+ if (q) {
+ query = query.where(
+ 'LOWER(user.username) LIKE :q OR LOWER(user.email) LIKE :q OR LOWER(user.plexUsername) LIKE :q OR LOWER(user.jellyfinUsername) LIKE :q',
+ { q: `%${q}%` }
+ );
+ }
+
switch (req.query.sort) {
case 'updated':
query = query.orderBy('user.updatedAt', 'DESC');
diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx
index 5595804a..9d714049 100644
--- a/src/components/Selector/index.tsx
+++ b/src/components/Selector/index.tsx
@@ -13,6 +13,7 @@ import type {
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
+import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import type {
Keyword,
ProductionCompany,
@@ -29,6 +30,7 @@ const messages = defineMessages('components.Selector', {
searchKeywords: 'Search keywords…',
searchGenres: 'Select genres…',
searchStudios: 'Search studios…',
+ searchUsers: 'Select users…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
showmore: 'Show More',
@@ -542,3 +544,77 @@ export const WatchProviderSelector = ({
>
);
};
+
+export const UserSelector = ({
+ isMulti,
+ defaultValue,
+ onChange,
+}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
+ const intl = useIntl();
+ const [defaultDataValue, setDefaultDataValue] = useState<
+ { label: string; value: number }[] | null
+ >(null);
+
+ useEffect(() => {
+ const loadUsers = async (): Promise => {
+ if (!defaultValue) {
+ return;
+ }
+
+ const users = defaultValue.split(',');
+
+ const res = await fetch(`/api/v1/user`);
+ if (!res.ok) {
+ throw new Error('Network response was not ok');
+ }
+ const response: UserResultsResponse = await res.json();
+
+ const genreData = users
+ .filter((u) => response.results.find((user) => user.id === Number(u)))
+ .map((u) => response.results.find((user) => user.id === Number(u)))
+ .map((u) => ({
+ label: u?.displayName ?? '',
+ value: u?.id ?? 0,
+ }));
+
+ setDefaultDataValue(genreData);
+ };
+
+ loadUsers();
+ }, [defaultValue]);
+
+ const loadUserOptions = async (inputValue: string) => {
+ const res = await fetch(
+ `/api/v1/user${inputValue ? `?q=${encodeURIComponent(inputValue)}` : ''}`
+ );
+ if (!res.ok) throw new Error();
+ const results: UserResultsResponse = await res.json();
+
+ return results.results
+ .map((result) => ({
+ label: result.displayName,
+ value: result.id,
+ }))
+ .filter(({ label }) =>
+ label.toLowerCase().includes(inputValue.toLowerCase())
+ );
+ };
+
+ return (
+ {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ onChange(value as any);
+ }}
+ />
+ );
+};
diff --git a/src/components/Settings/OverrideRule/OverrideRuleModal.tsx b/src/components/Settings/OverrideRule/OverrideRuleModal.tsx
index e436a846..afc4a7e0 100644
--- a/src/components/Settings/OverrideRule/OverrideRuleModal.tsx
+++ b/src/components/Settings/OverrideRule/OverrideRuleModal.tsx
@@ -1,6 +1,10 @@
import Modal from '@app/components/Common/Modal';
import LanguageSelector from '@app/components/LanguageSelector';
-import { GenreSelector, KeywordSelector } from '@app/components/Selector';
+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';
@@ -22,6 +26,7 @@ const messages = defineMessages('components.Settings.RadarrModal', {
settings: 'Settings',
settingsDescription:
'Specifies which settings will be changed when the above conditions are met.',
+ users: 'Users',
genres: 'Genres',
languages: 'Languages',
keywords: 'Keywords',
@@ -74,6 +79,7 @@ const OverrideRuleModal = ({
>
{
try {
const submission = {
+ users: values.users || null,
genre: values.genre || null,
language: values.language || null,
keywords: values.keywords || null,
@@ -149,7 +156,10 @@ const OverrideRuleModal = ({
okDisabled={
isSubmitting ||
!isValid ||
- (!values.genre && !values.language) ||
+ (!values.users &&
+ !values.genre &&
+ !values.language &&
+ !values.keywords) ||
(!values.rootFolder && !values.profileId && !values.tags)
}
onOk={() => handleSubmit()}
@@ -166,6 +176,30 @@ const OverrideRuleModal = ({
{intl.formatMessage(messages.conditionsDescription)}
+
+
+
+
+ {
+ setFieldValue(
+ 'users',
+ users?.map((v) => v.value).join(',')
+ );
+ }}
+ />
+
+ {errors.users &&
+ touched.users &&
+ typeof errors.users === 'string' && (
+
{errors.users}
+ )}
+
+
diff --git a/src/components/Settings/OverrideRule/OverrideRuleTile.tsx b/src/components/Settings/OverrideRule/OverrideRuleTile.tsx
index 7c6f0e86..47470813 100644
--- a/src/components/Settings/OverrideRule/OverrideRuleTile.tsx
+++ b/src/components/Settings/OverrideRule/OverrideRuleTile.tsx
@@ -4,6 +4,7 @@ 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 {
Language,
RadarrSettings,
@@ -18,6 +19,7 @@ const messages = defineMessages('components.Settings.OverrideRuleTile', {
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
tags: 'Tags',
+ users: 'Users',
genre: 'Genre',
language: 'Language',
keywords: 'Keywords',
@@ -51,6 +53,7 @@ const OverrideRuleTile = ({
revalidate,
}: OverrideRuleTileProps) => {
const intl = useIntl();
+ const [users, setUsers] = useState(null);
const [keywords, setKeywords] = useState(null);
const { data: languages } = useSWR('/api/v1/languages');
const { data: genres } = useSWR('/api/v1/genres/movie');
@@ -70,6 +73,19 @@ const OverrideRuleTile = ({
})
);
setKeywords(keywords);
+ const users = await Promise.all(
+ rules
+ .map((rule) => rule.users?.split(','))
+ .flat()
+ .filter((userId) => userId)
+ .map(async (userId) => {
+ const res = await fetch(`/api/v1/user/${userId}`);
+ if (!res.ok) throw new Error();
+ const user: User = await res.json();
+ return user;
+ })
+ );
+ setUsers(users);
})();
}, [rules]);
@@ -87,6 +103,25 @@ const OverrideRuleTile = ({
{intl.formatMessage(messages.conditions)}
+ {rule.users && (
+
+
+ {intl.formatMessage(messages.users)}
+
+
+ {rule.users.split(',').map((userId) => {
+ return (
+
+ {
+ users?.find((user) => user.id === Number(userId))
+ ?.displayName
+ }
+
+ );
+ })}
+
+
+ )}
{rule.genre && (
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json
index 7086acfc..d6eac484 100644
--- a/src/i18n/locale/en.json
+++ b/src/i18n/locale/en.json
@@ -589,6 +589,7 @@
"components.Selector.searchKeywords": "Search keywords…",
"components.Selector.searchStatus": "Select status...",
"components.Selector.searchStudios": "Search studios…",
+ "components.Selector.searchUsers": "Select users…",
"components.Selector.showless": "Show Less",
"components.Selector.showmore": "Show More",
"components.Selector.starttyping": "Starting typing to search.",
@@ -732,35 +733,58 @@
"components.Settings.Notifications.webhookRoleIdTip": "The role ID to mention in the webhook message. Leave empty to disable mentions",
"components.Settings.Notifications.webhookUrl": "Webhook URL",
"components.Settings.Notifications.webhookUrlTip": "Create a webhook integration in your server",
+ "components.Settings.OverrideRuleTile.conditions": "Conditions",
+ "components.Settings.OverrideRuleTile.genre": "Genre",
+ "components.Settings.OverrideRuleTile.keywords": "Keywords",
+ "components.Settings.OverrideRuleTile.language": "Language",
+ "components.Settings.OverrideRuleTile.qualityprofile": "Quality Profile",
+ "components.Settings.OverrideRuleTile.rootfolder": "Root Folder",
+ "components.Settings.OverrideRuleTile.settings": "Settings",
+ "components.Settings.OverrideRuleTile.tags": "Tags",
+ "components.Settings.OverrideRuleTile.users": "Users",
"components.Settings.RadarrModal.add": "Add Server",
+ "components.Settings.RadarrModal.addrule": "New Override Rule",
"components.Settings.RadarrModal.announced": "Announced",
"components.Settings.RadarrModal.apiKey": "API Key",
"components.Settings.RadarrModal.baseUrl": "URL Base",
+ "components.Settings.RadarrModal.conditions": "Conditions",
+ "components.Settings.RadarrModal.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).",
+ "components.Settings.RadarrModal.create": "Create rule",
"components.Settings.RadarrModal.create4kradarr": "Add New 4K Radarr Server",
"components.Settings.RadarrModal.createradarr": "Add New Radarr Server",
+ "components.Settings.RadarrModal.createrule": "New Override Rule",
"components.Settings.RadarrModal.default4kserver": "Default 4K Server",
"components.Settings.RadarrModal.defaultserver": "Default Server",
"components.Settings.RadarrModal.edit4kradarr": "Edit 4K Radarr Server",
"components.Settings.RadarrModal.editradarr": "Edit Radarr Server",
+ "components.Settings.RadarrModal.editrule": "Edit Override Rule",
"components.Settings.RadarrModal.enableSearch": "Enable Automatic Search",
"components.Settings.RadarrModal.externalUrl": "External URL",
+ "components.Settings.RadarrModal.genres": "Genres",
"components.Settings.RadarrModal.hostname": "Hostname or IP Address",
"components.Settings.RadarrModal.inCinemas": "In Cinemas",
+ "components.Settings.RadarrModal.keywords": "Keywords",
+ "components.Settings.RadarrModal.languages": "Languages",
"components.Settings.RadarrModal.loadingTags": "Loading tags…",
"components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
"components.Settings.RadarrModal.notagoptions": "No tags.",
+ "components.Settings.RadarrModal.overrideRules": "Override Rules",
"components.Settings.RadarrModal.port": "Port",
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
"components.Settings.RadarrModal.released": "Released",
"components.Settings.RadarrModal.rootfolder": "Root Folder",
+ "components.Settings.RadarrModal.ruleCreated": "Override rule created successfully!",
+ "components.Settings.RadarrModal.ruleUpdated": "Override rule updated successfully!",
"components.Settings.RadarrModal.selectMinimumAvailability": "Select minimum availability",
"components.Settings.RadarrModal.selectQualityProfile": "Select quality profile",
"components.Settings.RadarrModal.selectRootFolder": "Select root folder",
"components.Settings.RadarrModal.selecttags": "Select tags",
"components.Settings.RadarrModal.server4k": "4K Server",
"components.Settings.RadarrModal.servername": "Server Name",
+ "components.Settings.RadarrModal.settings": "Settings",
+ "components.Settings.RadarrModal.settingsDescription": "Specifies which settings will be changed when the above conditions are met.",
"components.Settings.RadarrModal.ssl": "Use SSL",
"components.Settings.RadarrModal.syncEnabled": "Enable Scan",
"components.Settings.RadarrModal.tagRequests": "Tag Requests",
@@ -771,6 +795,7 @@
"components.Settings.RadarrModal.testFirstTags": "Test connection to load tags",
"components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr.",
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established successfully!",
+ "components.Settings.RadarrModal.users": "Users",
"components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key",
"components.Settings.RadarrModal.validationApplicationUrl": "You must provide a valid URL",
"components.Settings.RadarrModal.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
@@ -923,6 +948,7 @@
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
"components.Settings.SettingsUsers.users": "Users",
"components.Settings.SonarrModal.add": "Add Server",
+ "components.Settings.SonarrModal.addrule": "New Override Rule",
"components.Settings.SonarrModal.animeSeriesType": "Anime Series Type",
"components.Settings.SonarrModal.animeTags": "Anime Tags",
"components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile",
@@ -945,6 +971,7 @@
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.SonarrModal.notagoptions": "No tags.",
+ "components.Settings.SonarrModal.overrideRules": "Override Rules",
"components.Settings.SonarrModal.port": "Port",
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
"components.Settings.SonarrModal.rootfolder": "Root Folder",