diff --git a/overseerr-api.yml b/overseerr-api.yml index 265217ccb..6a387a6b6 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1939,6 +1939,11 @@ components: type: string native_name: type: string + OverrideRule: + type: object + properties: + id: + type: string securitySchemes: cookieAuth: type: apiKey @@ -3762,6 +3767,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 @@ -6970,6 +6980,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/MediaRequest.ts b/server/entity/MediaRequest.ts index 49ceb7746..61b82c0e4 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -13,6 +13,7 @@ import { MediaType, } from '@server/constants/media'; import { getRepository } from '@server/datasource'; +import OverrideRule from '@server/entity/OverrideRule'; import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; @@ -711,21 +712,106 @@ export class MediaRequest { return; } + const tmdb = new TheMovieDb(); + const radarr = new RadarrAPI({ + apiKey: radarrSettings.apiKey, + url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), + }); + const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); + + const media = await mediaRepository.findOne({ + where: { id: this.media.id }, + }); + + if (!media) { + logger.error('Media data not found', { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + }); + return; + } + let rootFolder = radarrSettings.activeDirectory; let qualityProfile = radarrSettings.activeProfileId; let tags = radarrSettings.tags ? [...radarrSettings.tags] : []; + const overrideRuleRepository = getRepository(OverrideRule); + const overrideRules = await overrideRuleRepository.find({ + 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 + .split(',') + .some((genreId) => + movie.genres.map((genre) => genre.id).includes(Number(genreId)) + ) + ) { + return false; + } + if ( + rule.language && + !rule.language + .split('|') + .some((languageId) => languageId === movie.original_language) + ) { + return false; + } + if ( + rule.keywords && + !rule.keywords + .split(',') + .some((keywordId) => + movie.keywords.keywords + .map((keyword) => keyword.id) + .includes(Number(keywordId)) + ) + ) { + return false; + } + return true; + }); + if ( this.rootFolder && this.rootFolder !== '' && this.rootFolder !== radarrSettings.activeDirectory ) { rootFolder = this.rootFolder; - logger.info(`Request has an override root folder: ${rootFolder}`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); + logger.info( + `Request has a manually overriden root folder: ${rootFolder}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } else { + const overrideRootFolder = appliedOverrideRules.find( + (rule) => rule.rootFolder + )?.rootFolder; + if (overrideRootFolder) { + rootFolder = overrideRootFolder; + this.rootFolder = rootFolder; + logger.info( + `Request has an override root folder from override rules: ${rootFolder}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } } if ( @@ -734,44 +820,62 @@ export class MediaRequest { ) { qualityProfile = this.profileId; logger.info( - `Request has an override quality profile ID: ${qualityProfile}`, + `Request has a manually overriden quality profile ID: ${qualityProfile}`, { label: 'Media Request', requestId: this.id, mediaId: this.media.id, } ); + } else { + const overrideProfileId = appliedOverrideRules.find( + (rule) => rule.profileId + )?.profileId; + if (overrideProfileId) { + qualityProfile = overrideProfileId; + this.profileId = qualityProfile; + logger.info( + `Request has an override quality profile ID from override rules: ${qualityProfile}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } } if (this.tags && !isEqual(this.tags, radarrSettings.tags)) { tags = this.tags; - logger.info(`Request has override tags`, { + logger.info(`Request has manually overriden tags`, { label: 'Media Request', requestId: this.id, mediaId: this.media.id, tagIds: tags, }); + } else { + const overrideTags = appliedOverrideRules.find( + (rule) => rule.tags + )?.tags; + if (overrideTags) { + tags = [ + ...new Set([ + ...tags, + ...overrideTags.split(',').map((tag) => Number(tag)), + ]), + ]; + this.tags = tags; + logger.info(`Request has override tags from override rules`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + tagIds: tags, + }); + } } - const tmdb = new TheMovieDb(); - const radarr = new RadarrAPI({ - apiKey: radarrSettings.apiKey, - url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), - }); - const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); - - const media = await mediaRepository.findOne({ - where: { id: this.media.id }, - }); - - if (!media) { - logger.error('Media data not found', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - return; - } + const requestRepository = getRepository(MediaRequest); + requestRepository.save(this); if (radarrSettings.tagRequests) { let userTag = (await radarr.getTags()).find((v) => @@ -814,7 +918,6 @@ export class MediaRequest { mediaId: this.media.id, }); - const requestRepository = getRepository(MediaRequest); this.status = MediaRequestStatus.APPROVED; await requestRepository.save(this); return; @@ -854,8 +957,6 @@ export class MediaRequest { await mediaRepository.save(media); }) .catch(async () => { - const requestRepository = getRepository(MediaRequest); - this.status = MediaRequestStatus.FAILED; await requestRepository.save(this); @@ -955,6 +1056,7 @@ export class MediaRequest { throw new Error('Media data not found'); } + const requestRepository = getRepository(MediaRequest); if ( media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ) { @@ -964,7 +1066,6 @@ export class MediaRequest { mediaId: this.media.id, }); - const requestRepository = getRepository(MediaRequest); this.status = MediaRequestStatus.APPROVED; await requestRepository.save(this); return; @@ -979,7 +1080,6 @@ export class MediaRequest { const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; if (!tvdbId) { - const requestRepository = getRepository(MediaRequest); await mediaRepository.remove(media); await requestRepository.remove(this); throw new Error('TVDB ID not found'); @@ -1017,29 +1117,110 @@ export class MediaRequest { ? [...sonarrSettings.tags] : []; + const overrideRuleRepository = getRepository(OverrideRule); + const overrideRules = await overrideRuleRepository.find({ + where: { sonarrServiceId: 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 + .split(',') + .some((genreId) => + series.genres.map((genre) => genre.id).includes(Number(genreId)) + ) + ) { + return false; + } + if ( + rule.language && + !rule.language + .split('|') + .some((languageId) => languageId === series.original_language) + ) { + return false; + } + if ( + rule.keywords && + !rule.keywords + .split(',') + .some((keywordId) => + series.keywords.results + .map((keyword) => keyword.id) + .includes(Number(keywordId)) + ) + ) { + return false; + } + return true; + }); + if ( this.rootFolder && this.rootFolder !== '' && this.rootFolder !== rootFolder ) { rootFolder = this.rootFolder; - logger.info(`Request has an override root folder: ${rootFolder}`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); + logger.info( + `Request has a manually overriden root folder: ${rootFolder}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } else { + const overrideRootFolder = appliedOverrideRules.find( + (rule) => rule.rootFolder + )?.rootFolder; + if (overrideRootFolder) { + rootFolder = overrideRootFolder; + this.rootFolder = rootFolder; + logger.info( + `Request has an override root folder from override rules: ${rootFolder}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } } if (this.profileId && this.profileId !== qualityProfile) { qualityProfile = this.profileId; logger.info( - `Request has an override quality profile ID: ${qualityProfile}`, + `Request has a manually overriden quality profile ID: ${qualityProfile}`, { label: 'Media Request', requestId: this.id, mediaId: this.media.id, } ); + } else { + const overrideProfileId = appliedOverrideRules.find( + (rule) => rule.profileId + )?.profileId; + if (overrideProfileId) { + qualityProfile = overrideProfileId; + this.profileId = qualityProfile; + logger.info( + `Request has an override quality profile ID from override rules: ${qualityProfile}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } } if ( @@ -1059,12 +1240,31 @@ export class MediaRequest { if (this.tags && !isEqual(this.tags, tags)) { tags = this.tags; - logger.info(`Request has override tags`, { + logger.info(`Request has manually overriden tags`, { label: 'Media Request', requestId: this.id, mediaId: this.media.id, tagIds: tags, }); + } else { + const overrideTags = appliedOverrideRules.find( + (rule) => rule.tags + )?.tags; + if (overrideTags) { + tags = [ + ...new Set([ + ...tags, + ...overrideTags.split(',').map((tag) => Number(tag)), + ]), + ]; + this.tags = tags; + logger.info(`Request has override tags from override rules`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + tagIds: tags, + }); + } } if (sonarrSettings.tagRequests) { @@ -1099,6 +1299,8 @@ export class MediaRequest { } } + requestRepository.save(this); + const sonarrSeriesOptions: AddSeriesOptions = { profileId: qualityProfile, languageProfileId: languageProfile, @@ -1136,8 +1338,6 @@ export class MediaRequest { await mediaRepository.save(media); }) .catch(async () => { - const requestRepository = getRepository(MediaRequest); - this.status = MediaRequestStatus.FAILED; await requestRepository.save(this); diff --git a/server/entity/OverrideRule.ts b/server/entity/OverrideRule.ts new file mode 100644 index 000000000..bf1373438 --- /dev/null +++ b/server/entity/OverrideRule.ts @@ -0,0 +1,52 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@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 users?: string; + + @Column({ nullable: true }) + public genre?: string; + + @Column({ nullable: true }) + public language?: string; + + @Column({ nullable: true }) + public keywords?: string; + + @Column({ type: 'int', nullable: true }) + public profileId?: number; + + @Column({ nullable: true }) + public rootFolder?: string; + + @Column({ nullable: true }) + public tags?: string; + + @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 f1c730223..4613486f9 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/migration/postgres/1734805738349-AddOverrideRules.ts b/server/migration/postgres/1734805738349-AddOverrideRules.ts new file mode 100644 index 000000000..b9cc4721f --- /dev/null +++ b/server/migration/postgres/1734805738349-AddOverrideRules.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOverrideRules1734805738349 implements MigrationInterface { + name = 'AddOverrideRules1734805738349'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "override_rule" ("id" SERIAL NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" character varying, "genre" character varying, "language" character varying, "keywords" character varying, "profileId" integer, "rootFolder" character varying, "tags" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_657f810c7b20a4fce45aee8f182" PRIMARY KEY ("id"))` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "override_rule"`); + } +} diff --git a/server/migration/sqlite/1734805733535-AddOverrideRules.ts b/server/migration/sqlite/1734805733535-AddOverrideRules.ts new file mode 100644 index 000000000..692dc8751 --- /dev/null +++ b/server/migration/sqlite/1734805733535-AddOverrideRules.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOverrideRules1734805733535 implements MigrationInterface { + name = 'AddOverrideRules1734805733535'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "override_rule"`); + } +} 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..912a68aae --- /dev/null +++ b/server/routes/overrideRule.ts @@ -0,0 +1,136 @@ +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, + { + users?: string; + genre?: string; + language?: string; + keywords?: string; + profileId?: number; + rootFolder?: string; + tags?: string; + radarrServiceId?: number; + sonarrServiceId?: number; + } +>('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => { + const overrideRuleRepository = getRepository(OverrideRule); + + try { + const rule = new OverrideRule({ + users: req.body.users, + genre: req.body.genre, + language: req.body.language, + keywords: req.body.keywords, + profileId: req.body.profileId, + rootFolder: req.body.rootFolder, + tags: req.body.tags, + radarrServiceId: req.body.radarrServiceId, + sonarrServiceId: req.body.sonarrServiceId, + }); + + const newRule = await overrideRuleRepository.save(rule); + + return res.status(200).json(newRule); + } catch (e) { + next({ status: 404, message: e.message }); + } +}); + +overrideRuleRoutes.put< + { ruleId: string }, + OverrideRule, + { + users?: string; + genre?: string; + language?: string; + keywords?: string; + profileId?: number; + rootFolder?: string; + tags?: string; + 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.users = req.body.users; + rule.genre = req.body.genre; + rule.language = req.body.language; + rule.keywords = req.body.keywords; + 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 newRule = await overrideRuleRepository.save(rule); + + return res.status(200).json(newRule); + } catch (e) { + next({ status: 404, message: e.message }); + } +}); + +overrideRuleRoutes.delete<{ ruleId: string }, OverrideRule>( + '/: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.' }); + } + + await overrideRuleRepository.remove(rule); + + return res.status(200).json(rule); + } catch (e) { + next({ status: 404, message: e.message }); + } + } +); + +export default overrideRuleRoutes; diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 7a63853fb..6c6f7515b 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/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/Selector/index.tsx b/src/components/Selector/index.tsx index 716f95c39..6c8319090 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', @@ -546,3 +548,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 new file mode 100644 index 000000000..becb1ee97 --- /dev/null +++ b/src/components/Settings/OverrideRule/OverrideRuleModal.tsx @@ -0,0 +1,391 @@ +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 { 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.OverrideRuleModal', { + createrule: 'New Override Rule', + editrule: 'Edit Override Rule', + create: 'Create rule', + 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; + 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 = { + 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: 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) + } + > +
+

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

+

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

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

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

+

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

+
+ +
+
+ + + {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 + ?.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) + } + /> +
+
+
+
+ ); + }} +
+
+ ); +}; + +export default OverrideRuleModal; diff --git a/src/components/Settings/OverrideRule/OverrideRuleTile.tsx b/src/components/Settings/OverrideRule/OverrideRuleTile.tsx new file mode 100644 index 000000000..c5c0451a7 --- /dev/null +++ b/src/components/Settings/OverrideRule/OverrideRuleTile.tsx @@ -0,0 +1,267 @@ +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 { + Language, + RadarrSettings, + SonarrSettings, +} from '@server/lib/settings'; +import type { Keyword } from '@server/models/common'; +import { 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 OverrideRuleTileProps { + rules: OverrideRule[]; + setOverrideRuleModal: ({ + open, + rule, + testResponse, + }: { + open: boolean; + rule: OverrideRule | null; + testResponse: DVRTestResponse; + }) => void; + testResponse: DVRTestResponse; + radarr?: RadarrSettings | null; + sonarr?: SonarrSettings | null; + revalidate: () => void; +} + +const OverrideRuleTile = ({ + rules, + setOverrideRuleModal, + testResponse, + radarr, + sonarr, + 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'); + + useEffect(() => { + (async () => { + const keywords = await Promise.all( + rules + .map((rule) => rule.keywords?.split(',')) + .flat() + .filter((keywordId) => keywordId) + .map(async (keywordId) => { + const res = await fetch(`/api/v1/keyword/${keywordId}`); + if (!res.ok) throw new Error(); + const keyword: Keyword = await res.json(); + return keyword; + }) + ); + 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]); + + return ( + <> + {rules + .filter( + (rule) => + (rule.radarrServiceId !== null && + rule.radarrServiceId === radarr?.id) || + (rule.sonarrServiceId !== null && + rule.sonarrServiceId === sonarr?.id) + ) + .map((rule) => ( +
  • +
    +
    + + {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 && ( +

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

    + {rule.genre.split(',').map((genreId) => ( + + {genres?.find((g) => g.id === Number(genreId))?.name} + + ))} +
    +

    + )} + {rule.language && ( +

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

    + {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 {languageName}; + })} +
    +

    + )} + {rule.keywords && ( +

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

    + {rule.keywords.split(',').map((keywordId) => { + return ( + + { + keywords?.find( + (keyword) => keyword.id === Number(keywordId) + )?.name + } + + ); + })} +
    +

    + )} + + {intl.formatMessage(messages.settings)} + + {rule.profileId && ( +

    + + {intl.formatMessage(messages.qualityprofile)} + + { + testResponse.profiles.find( + (profile) => rule.profileId === profile.id + )?.name + } +

    + )} + {rule.rootFolder && ( +

    + + {intl.formatMessage(messages.rootfolder)} + + {rule.rootFolder} +

    + )} + {rule.tags && rule.tags.length > 0 && ( +

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

    + {rule.tags.split(',').map((tag) => ( + + { + testResponse.tags?.find((t) => t.id === Number(tag)) + ?.label + } + + ))} +
    +

    + )} +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
  • + ))} + + ); +}; + +export default OverrideRuleTile; diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index 51c74ba89..1bd1b7dd9 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -1,14 +1,24 @@ +import Button from '@app/components/Common/Button'; import Modal from '@app/components/Common/Modal'; import SensitiveInput from '@app/components/Common/SensitiveInput'; +import OverrideRuleTile from '@app/components/Settings/OverrideRule/OverrideRuleTile'; +import type { + DVRTestResponse, + RadarrTestResponse, +} from '@app/components/Settings/SettingsServices'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; +import { PlusIcon } from '@heroicons/react/24/solid'; +import type OverrideRule from '@server/entity/OverrideRule'; +import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces'; import type { 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 +79,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; 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 +235,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 +773,38 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
    +

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

    +
      + {rules && ( + + )} +
    • +
      + +
      +
    • +
    ); }} diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index 63cd463f7..5e3871fcd 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/OverrideRule/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,33 @@ interface ServerInstanceProps { onDelete: () => void; } +export interface DVRTestResponse { + profiles: { + id: number; + name: string; + }[]; + rootFolders: { + id: number; + path: string; + }[]; + tags: { + id: number; + label: string; + }[]; + urlBase?: string; +} + +export type RadarrTestResponse = DVRTestResponse; + +export type SonarrTestResponse = DVRTestResponse & { + languageProfiles: + | { + id: number; + name: string; + }[] + | null; +}; + const ServerInstance = ({ name, hostname, @@ -193,6 +222,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,26 +265,51 @@ 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 && ( setEditSonarrModal({ open: false, sonarr: null })} + onClose={() => { + if (!overrideRuleModal.open) + setEditSonarrModal({ open: false, sonarr: null }); + }} onSave={() => { revalidateSonarr(); mutate('/api/v1/settings/public'); setEditSonarrModal({ open: false, sonarr: null }); }} + overrideRuleModal={overrideRuleModal} + setOverrideRuleModal={setOverrideRuleModal} /> )} void; onSave: () => void; + overrideRuleModal: { open: boolean; rule: OverrideRule | null }; + setOverrideRuleModal: ({ + open, + rule, + testResponse, + }: { + open: boolean; + rule: OverrideRule | null; + testResponse: DVRTestResponse; + }) => void; } -const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { +const SonarrModal = ({ + onClose, + sonarr, + onSave, + overrideRuleModal, + setOverrideRuleModal, +}: SonarrModalProps) => { const intl = useIntl(); + const { data: rules, mutate: revalidate } = + useSWR('/api/v1/overrideRule'); const initialLoad = useRef(false); 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, tags: [], }); + const SonarrSettingsSchema = Yup.object().shape({ name: Yup.string().required( intl.formatMessage(messages.validationNameRequired) @@ -197,7 +206,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); @@ -235,6 +244,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { } }, [sonarr, testConnection]); + useEffect(() => { + revalidate(); + }, [overrideRuleModal, revalidate]); + return ( { values.is4k ? messages.edit4ksonarr : messages.editsonarr ) } + backgroundClickable={!overrideRuleModal.open} >
    @@ -1056,6 +1070,38 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
    +

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

    +
      + {rules && ( + + )} +
    • +
      + +
      +
    • +
    ); }} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 447d31678..9453d39ed 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.", @@ -735,7 +736,37 @@ "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.OverrideRuleModal.conditions": "Conditions", + "components.Settings.OverrideRuleModal.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.OverrideRuleModal.create": "Create rule", + "components.Settings.OverrideRuleModal.createrule": "New Override Rule", + "components.Settings.OverrideRuleModal.editrule": "Edit Override Rule", + "components.Settings.OverrideRuleModal.genres": "Genres", + "components.Settings.OverrideRuleModal.keywords": "Keywords", + "components.Settings.OverrideRuleModal.languages": "Languages", + "components.Settings.OverrideRuleModal.notagoptions": "No tags.", + "components.Settings.OverrideRuleModal.qualityprofile": "Quality Profile", + "components.Settings.OverrideRuleModal.rootfolder": "Root Folder", + "components.Settings.OverrideRuleModal.ruleCreated": "Override rule created successfully!", + "components.Settings.OverrideRuleModal.ruleUpdated": "Override rule updated successfully!", + "components.Settings.OverrideRuleModal.selectQualityProfile": "Select quality profile", + "components.Settings.OverrideRuleModal.selectRootFolder": "Select root folder", + "components.Settings.OverrideRuleModal.selecttags": "Select tags", + "components.Settings.OverrideRuleModal.settings": "Settings", + "components.Settings.OverrideRuleModal.settingsDescription": "Specifies which settings will be changed when the above conditions are met.", + "components.Settings.OverrideRuleModal.tags": "Tags", + "components.Settings.OverrideRuleModal.users": "Users", + "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", @@ -754,6 +785,7 @@ "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", @@ -929,6 +961,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", @@ -951,6 +984,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",