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",