From 87dddbb879d4c79dee5339352cd431f2661f2055 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:14:02 +0800 Subject: [PATCH] feat: replace override rules with routing rules system Replaces the override rule system with a new priority-based routing rules engine. Routing rules are evaluated top-to-bottom with first-match-wins semantics, supporting conditions on users, genres, languages, and keywords. Quality profiles, root folders, minimum availability, series type, and tags move from instance-level settings to routing rules with support for instance switching, with fallback rules acting as catch-all defaults. Includes a migration to convert existing instance defaults and override rules into the new system, a routing resolver used at request time, updated OpenAPI spec, and a new UI with drag-and-drop reordering, filter tabs, and inline rule expansion. fix #232, fix #1560, fix #2058 --- seerr-api.yml | 291 +++++-- server/entity/MediaRequest.ts | 161 +--- server/entity/RoutingRule.ts | 69 ++ server/lib/routingResolver.ts | 136 +++ .../0009_migrate_to_routing_rules.ts | 187 ++++ server/routes/index.ts | 6 +- server/routes/overrideRule.ts | 136 --- server/routes/settings/radarr.ts | 16 + server/routes/settings/routingRule.ts | 357 ++++++++ .../OverrideRule/OverrideRuleModal.tsx | 544 ------------ .../OverrideRule/OverrideRuleTiles.tsx | 309 ------- src/components/Settings/RadarrModal/index.tsx | 241 +----- .../Settings/RoutingRule/RoutingRuleList.tsx | 599 +++++++++++++ .../Settings/RoutingRule/RoutingRuleModal.tsx | 797 ++++++++++++++++++ .../Settings/RoutingRule/RoutingRuleRow.tsx | 555 ++++++++++++ src/components/Settings/RoutingRule/types.ts | 23 + src/components/Settings/SettingsServices.tsx | 490 +++++------ src/components/Settings/SonarrModal/index.tsx | 552 +----------- 18 files changed, 3241 insertions(+), 2228 deletions(-) create mode 100644 server/entity/RoutingRule.ts create mode 100644 server/lib/routingResolver.ts create mode 100644 server/lib/settings/migrations/0009_migrate_to_routing_rules.ts delete mode 100644 server/routes/overrideRule.ts create mode 100644 server/routes/settings/routingRule.ts delete mode 100644 src/components/Settings/OverrideRule/OverrideRuleModal.tsx delete mode 100644 src/components/Settings/OverrideRule/OverrideRuleTiles.tsx create mode 100644 src/components/Settings/RoutingRule/RoutingRuleList.tsx create mode 100644 src/components/Settings/RoutingRule/RoutingRuleModal.tsx create mode 100644 src/components/Settings/RoutingRule/RoutingRuleRow.tsx create mode 100644 src/components/Settings/RoutingRule/types.ts diff --git a/seerr-api.yml b/seerr-api.yml index aaaf30bf..96ea65a1 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -577,21 +577,9 @@ components: example: false baseUrl: type: string - activeProfileId: - type: number - example: 1 - activeProfileName: - type: string - example: 720p/1080p - activeDirectory: - type: string - example: '/movies' is4k: type: boolean example: false - minimumAvailability: - type: string - example: 'In Cinema' isDefault: type: boolean example: false @@ -610,11 +598,7 @@ components: - port - apiKey - useSsl - - activeProfileId - - activeProfileName - - activeDirectory - is4k - - minimumAvailability - isDefault SonarrSettings: type: object @@ -640,31 +624,6 @@ components: example: false baseUrl: type: string - activeProfileId: - type: number - example: 1 - activeProfileName: - type: string - example: 720p/1080p - activeDirectory: - type: string - example: '/tv/' - activeLanguageProfileId: - type: number - example: 1 - activeAnimeProfileId: - type: number - nullable: true - activeAnimeLanguageProfileId: - type: number - nullable: true - activeAnimeProfileName: - type: string - example: 720p/1080p - nullable: true - activeAnimeDirectory: - type: string - nullable: true is4k: type: boolean example: false @@ -689,9 +648,6 @@ components: - port - apiKey - useSsl - - activeProfileId - - activeProfileName - - activeDirectory - is4k - enableSeasonFolders - isDefault @@ -2083,11 +2039,138 @@ components: type: string native_name: type: string - OverrideRule: + RoutingRule: type: object properties: id: + type: number + readOnly: true + name: type: string + example: 'Anime Content' + serviceType: + type: string + enum: + - radarr + - sonarr + is4k: + type: boolean + priority: + type: number + users: + type: string + nullable: true + description: Comma-separated user IDs + genres: + type: string + nullable: true + description: Comma-separated genre IDs + languages: + type: string + nullable: true + description: Pipe-separated language codes (e.g. "ja|ko") + keywords: + type: string + nullable: true + description: Comma-separated keyword IDs + targetServiceId: + type: number + description: ID of the target Radarr/Sonarr instance + activeProfileId: + type: number + nullable: true + rootFolder: + type: string + nullable: true + minimumAvailability: + type: string + nullable: true + enum: + - announced + - inCinemas + - released + - null + seriesType: + type: string + nullable: true + enum: + - standard + - daily + - anime + - null + tags: + type: string + nullable: true + description: Comma-separated tag IDs + isFallback: + type: boolean + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + RoutingRuleRequest: + type: object + required: + - name + - serviceType + - targetServiceId + properties: + name: + type: string + serviceType: + type: string + enum: + - radarr + - sonarr + is4k: + type: boolean + priority: + type: number + users: + type: string + nullable: true + genres: + type: string + nullable: true + languages: + type: string + nullable: true + keywords: + type: string + nullable: true + targetServiceId: + type: number + activeProfileId: + type: number + nullable: true + minimumAvailability: + type: string + nullable: true + enum: + - announced + - inCinemas + - released + - null + rootFolder: + type: string + nullable: true + seriesType: + type: string + nullable: true + enum: + - standard + - daily + - anime + - null + tags: + type: string + nullable: true + isFallback: + type: boolean Certification: type: object properties: @@ -7807,41 +7890,72 @@ paths: message: type: string example: Unable to retrieve TV certifications. - /overrideRule: + /routingRule: get: - summary: Get override rules - description: Returns a list of all override rules with their conditions and settings + summary: Get all routing rules + description: Returns all routing rules ordered by priority (highest first). tags: - - overriderule + - settings responses: '200': - description: Override rules returned + description: Routing rules returned content: application/json: schema: type: array items: - $ref: '#/components/schemas/OverrideRule' + $ref: '#/components/schemas/RoutingRule' post: - summary: Create override rule - description: Creates a new Override Rule from the request body. + summary: Create a new routing rule + description: Creates a new routing rule. Priority is auto-assigned (highest existing + 10). tags: - - overriderule + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RoutingRuleRequest' responses: - '200': - description: 'Values were successfully created' + '201': + description: Routing rule created content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/OverrideRule' - /overrideRule/{ruleId}: + $ref: '#/components/schemas/RoutingRule' + + /routingRule/{ruleId}: put: - summary: Update override rule - description: Updates an Override Rule from the request body. + summary: Update a routing rule + description: Updates an existing routing rule by ID. tags: - - overriderule + - settings + parameters: + - in: path + name: ruleId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RoutingRuleRequest' + responses: + '200': + description: Routing rule updated + content: + application/json: + schema: + $ref: '#/components/schemas/RoutingRule' + '404': + description: Routing rule not found + delete: + summary: Delete a routing rule + description: Deletes a routing rule by ID. + tags: + - settings parameters: - in: path name: ruleId @@ -7850,31 +7964,42 @@ paths: type: number responses: '200': - description: 'Values were successfully updated' + description: Routing rule deleted + content: + application/json: + schema: + $ref: '#/components/schemas/RoutingRule' + '404': + description: Routing rule not found + + /routingRule/reorder: + post: + summary: Reorder routing rules + description: Bulk update priorities by providing an ordered list of rule IDs (highest priority first). + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - ruleIds + properties: + ruleIds: + type: array + items: + type: number + responses: + '200': + description: Rules reordered 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' + $ref: '#/components/schemas/RoutingRule' security: - cookieAuth: [] - apiKey: [] diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index e5524d99..30da58ac 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,5 +1,4 @@ import TheMovieDb from '@server/api/themoviedb'; -import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import { MediaRequestStatus, @@ -7,10 +6,10 @@ 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'; +import { resolveRoute } from '@server/lib/routingResolver'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; @@ -202,133 +201,41 @@ export class MediaRequest { } } - // Apply overrides if the user is not an admin or has the "advanced request" permission - const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], { + // apply routing rules to determine request settings (server/profile/folder/tags) + let tmdbKeywords: number[] = []; + if ('keywords' in tmdbMedia.keywords) { + tmdbKeywords = tmdbMedia.keywords.keywords.map((k: TmdbKeyword) => k.id); + } else if ('results' in tmdbMedia.keywords) { + tmdbKeywords = tmdbMedia.keywords.results.map((k: TmdbKeyword) => k.id); + } + + const isAdmin = user.hasPermission([Permission.MANAGE_REQUESTS], { type: 'or', }); - let rootFolder = requestBody.rootFolder; - let profileId = requestBody.profileId; - let tags = requestBody.tags; + const route = await resolveRoute({ + serviceType: + requestBody.mediaType === MediaType.MOVIE ? 'radarr' : 'sonarr', + is4k: requestBody.is4k ?? false, + userId: requestUser.id, + genres: tmdbMedia.genres.map((g) => g.id), + language: tmdbMedia.original_language, + keywords: tmdbKeywords, + }); - if (useOverrides) { - const defaultRadarrId = requestBody.is4k - ? settings.radarr.findIndex((r) => r.is4k && r.isDefault) - : settings.radarr.findIndex((r) => !r.is4k && r.isDefault); - const defaultSonarrId = requestBody.is4k - ? settings.sonarr.findIndex((s) => s.is4k && s.isDefault) - : settings.sonarr.findIndex((s) => !s.is4k && s.isDefault); - - const overrideRuleRepository = getRepository(OverrideRule); - const overrideRules = await overrideRuleRepository.find({ - where: - requestBody.mediaType === MediaType.MOVIE - ? { radarrServiceId: defaultRadarrId } - : { sonarrServiceId: defaultSonarrId }, - }); - - const appliedOverrideRules = overrideRules.filter((rule) => { - const hasAnimeKeyword = - 'results' in tmdbMedia.keywords && - tmdbMedia.keywords.results.some( - (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID - ); - - // Skip override rules if the media is an anime TV show as anime TV - // is handled by default and override rules do not explicitly include - // the anime keyword - if ( - requestBody.mediaType === MediaType.TV && - hasAnimeKeyword && - (!rule.keywords || - !rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID)) - ) { - return false; - } - - if ( - rule.users && - !rule.users - .split(',') - .some((userId) => Number(userId) === requestUser.id) - ) { - return false; - } - if ( - rule.genre && - !rule.genre - .split(',') - .some((genreId) => - tmdbMedia.genres - .map((genre) => genre.id) - .includes(Number(genreId)) - ) - ) { - return false; - } - if ( - rule.language && - !rule.language - .split('|') - .some((languageId) => languageId === tmdbMedia.original_language) - ) { - return false; - } - if ( - rule.keywords && - !rule.keywords.split(',').some((keywordId) => { - let keywordList: TmdbKeyword[] = []; - - if ('keywords' in tmdbMedia.keywords) { - keywordList = tmdbMedia.keywords.keywords; - } else if ('results' in tmdbMedia.keywords) { - keywordList = tmdbMedia.keywords.results; - } - - return keywordList - .map((keyword: TmdbKeyword) => keyword.id) - .includes(Number(keywordId)); - }) - ) { - return false; - } - return true; - }); - - // hacky way to prioritize rules - // TODO: make this better - const prioritizedRule = appliedOverrideRules.sort((a, b) => { - const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords']; - - const aSpecificity = keys.filter((key) => a[key] !== null).length; - const bSpecificity = keys.filter((key) => b[key] !== null).length; - - // Take the rule with the most specific condition first - return bSpecificity - aSpecificity; - })[0]; - - if (prioritizedRule) { - if (prioritizedRule.rootFolder) { - rootFolder = prioritizedRule.rootFolder; - } - if (prioritizedRule.profileId) { - profileId = prioritizedRule.profileId; - } - if (prioritizedRule.tags) { - tags = [ - ...new Set([ - ...(tags || []), - ...prioritizedRule.tags.split(',').map((tag) => Number(tag)), - ]), - ]; - } - - logger.debug('Override rule applied.', { - label: 'Media Request', - overrides: prioritizedRule, - }); - } - } + const serverId = + isAdmin && requestBody.serverId != null + ? requestBody.serverId + : route.serviceId; + const profileId = + isAdmin && requestBody.profileId != null + ? requestBody.profileId + : route.profileId; + const rootFolder = + isAdmin && requestBody.rootFolder + ? requestBody.rootFolder + : route.rootFolder; + const tags = isAdmin && requestBody.tags ? requestBody.tags : route.tags; if (requestBody.mediaType === MediaType.MOVIE) { await mediaRepository.save(media); @@ -367,7 +274,7 @@ export class MediaRequest { ? user : undefined, is4k: requestBody.is4k, - serverId: requestBody.serverId, + serverId: serverId, profileId: profileId, rootFolder: rootFolder, tags: tags, @@ -477,7 +384,7 @@ export class MediaRequest { ? user : undefined, is4k: requestBody.is4k, - serverId: requestBody.serverId, + serverId: serverId, profileId: profileId, rootFolder: rootFolder, languageProfileId: requestBody.languageProfileId, diff --git a/server/entity/RoutingRule.ts b/server/entity/RoutingRule.ts new file mode 100644 index 00000000..ecf5b451 --- /dev/null +++ b/server/entity/RoutingRule.ts @@ -0,0 +1,69 @@ +import { DbAwareColumn } from '@server/utils/DbColumnHelper'; +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +class RoutingRule { + @PrimaryGeneratedColumn() + public id: number; + + @Column({ type: 'varchar' }) + public name: string; + + @Column({ type: 'varchar' }) + public serviceType: 'radarr' | 'sonarr'; + + @Column({ default: false }) + public is4k: boolean; + + @Column({ type: 'int', default: 0 }) + public priority: number; + + @Column({ nullable: true }) + public users?: string; + + @Column({ nullable: true }) + public genres?: string; + + @Column({ nullable: true }) + public languages?: string; + + @Column({ nullable: true }) + public keywords?: string; + + @Column({ type: 'int' }) + public targetServiceId: number; + + @Column({ type: 'int', nullable: true }) + public activeProfileId?: number; + + @Column({ nullable: true }) + public rootFolder?: string; + + @Column({ nullable: true }) + public seriesType?: string; + + @Column({ nullable: true }) + public tags?: string; + + @Column({ type: 'varchar', nullable: true }) + public minimumAvailability?: 'announced' | 'inCinemas' | 'released'; + + @Column({ default: false }) + public isFallback: boolean; + + @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + public createdAt: Date; + + @DbAwareColumn({ + type: 'datetime', + default: () => 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }) + public updatedAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} + +export default RoutingRule; diff --git a/server/lib/routingResolver.ts b/server/lib/routingResolver.ts new file mode 100644 index 00000000..fcf8bfd9 --- /dev/null +++ b/server/lib/routingResolver.ts @@ -0,0 +1,136 @@ +import { getRepository } from '@server/datasource'; +import RoutingRule from '@server/entity/RoutingRule'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; + +export interface ResolvedRoute { + serviceId: number; + profileId?: number; + rootFolder?: string; + seriesType?: string; + tags?: number[]; + minimumAvailability?: string; +} + +interface RouteParams { + serviceType: 'radarr' | 'sonarr'; + is4k: boolean; + userId: number; + genres: number[]; + language: string; + keywords: number[]; +} + +/** + * Evaluates routing rules top-to-bottom (by priority DESC). + * First match wins. Falls back to the default instance if no rules match. + */ +export async function resolveRoute( + params: RouteParams +): Promise { + const routingRuleRepository = getRepository(RoutingRule); + const settings = getSettings(); + + const rules = await routingRuleRepository.find({ + where: { + serviceType: params.serviceType, + is4k: params.is4k, + }, + order: { priority: 'DESC' }, + }); + + for (const rule of rules) { + if (matchesAllConditions(rule, params)) { + logger.debug('Routing rule matched', { + label: 'Routing', + ruleId: rule.id, + ruleName: rule.name, + targetServiceId: rule.targetServiceId, + }); + + return { + serviceId: rule.targetServiceId, + profileId: rule.activeProfileId ?? undefined, + rootFolder: rule.rootFolder ?? undefined, + seriesType: rule.seriesType ?? undefined, + tags: rule.tags ? rule.tags.split(',').map(Number) : undefined, + minimumAvailability: rule.minimumAvailability ?? undefined, + }; + } + } + + logger.warn( + 'No routing rules matched (including fallback rules). Falling back to settings default.', + { + label: 'Routing', + serviceType: params.serviceType, + is4k: params.is4k, + } + ); + + const services = + params.serviceType === 'radarr' ? settings.radarr : settings.sonarr; + const defaultServiceIdx = services.findIndex( + (s) => (params.is4k ? s.is4k : !s.is4k) && s.isDefault + ); + + if (defaultServiceIdx === -1) { + throw new Error( + `No default ${params.serviceType} instance configured for ${ + params.is4k ? '4K' : 'non-4K' + } content.` + ); + } + + return { serviceId: services[defaultServiceIdx].id }; +} + +/** + * Check if a rule's conditions all match the request parameters. + * + * - No conditions (fallback) = always matches + * - AND between condition types (all populated conditions must pass) + * - OR within a condition type (any value can match) + */ +function matchesAllConditions(rule: RoutingRule, params: RouteParams): boolean { + if (rule.isFallback) { + return true; + } + + const hasConditions = + rule.users || rule.genres || rule.languages || rule.keywords; + + if (!hasConditions) { + return true; + } + + if (rule.users) { + const ruleUserIds = rule.users.split(',').map(Number); + if (!ruleUserIds.includes(params.userId)) { + return false; + } + } + + if (rule.genres) { + const ruleGenreIds = rule.genres.split(',').map(Number); + if (!ruleGenreIds.some((g) => params.genres.includes(g))) { + return false; + } + } + + if (rule.languages) { + const ruleLangs = rule.languages.split('|'); + if (!ruleLangs.includes(params.language)) { + return false; + } + } + + if (rule.keywords) { + const ruleKeywordIds = rule.keywords.split(',').map(Number); + if (!ruleKeywordIds.some((k) => params.keywords.includes(k))) { + return false; + } + } + + return true; +} diff --git a/server/lib/settings/migrations/0009_migrate_to_routing_rules.ts b/server/lib/settings/migrations/0009_migrate_to_routing_rules.ts new file mode 100644 index 00000000..659ed8fc --- /dev/null +++ b/server/lib/settings/migrations/0009_migrate_to_routing_rules.ts @@ -0,0 +1,187 @@ +import { getRepository } from '@server/datasource'; +import OverrideRule from '@server/entity/OverrideRule'; +import RoutingRule from '@server/entity/RoutingRule'; +import type { AllSettings } from '@server/lib/settings'; + +const ANIME_KEYWORD_ID = '210024'; + +const migrateToRoutingRules = async (settings: any): Promise => { + if ( + Array.isArray(settings.migrations) && + settings.migrations.includes('0009_migrate_to_routing_rules') + ) { + return settings; + } + + const routingRuleRepo = getRepository(RoutingRule); + let errorOccurred = false; + + for (const radarr of settings.radarr || []) { + if (!radarr.isDefault) continue; + + try { + await routingRuleRepo.save( + new RoutingRule({ + name: `${radarr.name} Default Route`, + serviceType: 'radarr', + targetServiceId: radarr.id, + is4k: radarr.is4k, + isFallback: true, + priority: 0, + activeProfileId: radarr.activeProfileId || undefined, + rootFolder: radarr.activeDirectory || undefined, + minimumAvailability: radarr.minimumAvailability || 'released', + tags: + radarr.tags && radarr.tags.length > 0 + ? radarr.tags.join(',') + : undefined, + }) + ); + } catch (error) { + console.error( + `Failed to create Radarr fallback routing rule for "${radarr.name}".`, + error.message + ); + errorOccurred = true; + } + } + + for (const sonarr of settings.sonarr || []) { + if (!sonarr.isDefault) continue; + + try { + await routingRuleRepo.save( + new RoutingRule({ + name: `${sonarr.name} Default Route`, + serviceType: 'sonarr', + targetServiceId: sonarr.id, + is4k: sonarr.is4k, + isFallback: true, + priority: 0, + activeProfileId: sonarr.activeProfileId || undefined, + rootFolder: sonarr.activeDirectory || undefined, + seriesType: sonarr.seriesType || 'standard', + tags: + sonarr.tags && sonarr.tags.length > 0 + ? sonarr.tags.join(',') + : undefined, + }) + ); + } catch (error) { + console.error( + `Failed to create Sonarr fallback routing rule for "${sonarr.name}".`, + error.message + ); + errorOccurred = true; + } + + const hasAnimeOverrides = + sonarr.activeAnimeProfileId || + sonarr.activeAnimeDirectory || + (sonarr.animeTags && sonarr.animeTags.length > 0); + + if (hasAnimeOverrides) { + try { + await routingRuleRepo.save( + new RoutingRule({ + name: 'Anime', + serviceType: 'sonarr', + targetServiceId: sonarr.id, + is4k: sonarr.is4k, + isFallback: false, + priority: 10, + keywords: ANIME_KEYWORD_ID, + activeProfileId: + sonarr.activeAnimeProfileId || + sonarr.activeProfileId || + undefined, + rootFolder: + sonarr.activeAnimeDirectory || + sonarr.activeDirectory || + undefined, + seriesType: sonarr.animeSeriesType || 'anime', + tags: + sonarr.animeTags && sonarr.animeTags.length > 0 + ? sonarr.animeTags.join(',') + : undefined, + }) + ); + } catch (error) { + console.error( + `Failed to create Sonarr anime routing rule for "${sonarr.name}".`, + error.message + ); + errorOccurred = true; + } + } + } + + let overrideRules: OverrideRule[] = []; + try { + const overrideRuleRepo = getRepository(OverrideRule); + overrideRules = await overrideRuleRepo.find(); + } catch { + // If the OverrideRule table doesn't exist or can't be queried, we can skip this step. + } + + let priority = 20; + + for (const rule of overrideRules) { + const isRadarr = rule.radarrServiceId != null; + const serviceType: 'radarr' | 'sonarr' = isRadarr ? 'radarr' : 'sonarr'; + + const serviceIndex = isRadarr + ? rule.radarrServiceId! + : rule.sonarrServiceId!; + const services = + serviceType === 'radarr' ? settings.radarr || [] : settings.sonarr || []; + const targetService = services[serviceIndex]; + + if (!targetService) { + console.error( + `Skipping override rule #${rule.id}: ${serviceType} instance at index ${serviceIndex} not found in settings.` + ); + errorOccurred = true; + continue; + } + + try { + await routingRuleRepo.save( + new RoutingRule({ + name: `Migrated Rule #${rule.id}`, + serviceType, + targetServiceId: targetService.id, + is4k: targetService.is4k, + isFallback: false, + priority, + users: rule.users || undefined, + genres: rule.genre || undefined, + languages: rule.language || undefined, + keywords: rule.keywords || undefined, + activeProfileId: rule.profileId || undefined, + rootFolder: rule.rootFolder || undefined, + tags: rule.tags || undefined, + }) + ); + + priority += 10; + } catch (error) { + console.error( + `Failed to migrate override rule #${rule.id} to routing rule.`, + error.message + ); + errorOccurred = true; + } + } + + if (!errorOccurred) { + if (!Array.isArray(settings.migrations)) { + settings.migrations = []; + } + settings.migrations.push('0009_migrate_to_routing_rules'); + } + + return settings; +}; + +export default migrateToRoutingRules; diff --git a/server/routes/index.ts b/server/routes/index.ts index f701acf9..3f8eafb8 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -16,8 +16,8 @@ import deprecatedRoute from '@server/middleware/deprecation'; import { mapProductionCompany } from '@server/models/Movie'; import { mapNetwork } from '@server/models/Tv'; import { mapWatchProviderDetails } from '@server/models/common'; -import overrideRuleRoutes from '@server/routes/overrideRule'; import settingsRoutes from '@server/routes/settings'; +import routingRuleRoutes from '@server/routes/settings/routingRule'; import watchlistRoutes from '@server/routes/watchlist'; import { appDataPath, @@ -173,9 +173,9 @@ router.use('/issue', isAuthenticated(), issueRoutes); router.use('/issueComment', isAuthenticated(), issueCommentRoutes); router.use('/auth', authRoutes); router.use( - '/overrideRule', + '/routingRule', isAuthenticated(Permission.ADMIN), - overrideRuleRoutes + routingRuleRoutes ); router.get('/regions', isAuthenticated(), async (req, res, next) => { diff --git a/server/routes/overrideRule.ts b/server/routes/overrideRule.ts deleted file mode 100644 index 912a68aa..00000000 --- a/server/routes/overrideRule.ts +++ /dev/null @@ -1,136 +0,0 @@ -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/settings/radarr.ts b/server/routes/settings/radarr.ts index efa58665..6095c227 100644 --- a/server/routes/settings/radarr.ts +++ b/server/routes/settings/radarr.ts @@ -1,4 +1,6 @@ import RadarrAPI from '@server/api/servarr/radarr'; +import { getRepository } from '@server/datasource'; +import RoutingRule from '@server/entity/RoutingRule'; import type { RadarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -136,6 +138,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => { radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => { const settings = getSettings(); + const routingRuleRepository = getRepository(RoutingRule); const radarrIndex = settings.radarr.findIndex( (r) => r.id === Number(req.params.id) @@ -145,6 +148,19 @@ radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => { return next({ status: '404', message: 'Settings instance not found' }); } + const instanceId = Number(req.params.id); + + const rulesToDelete = await routingRuleRepository.find({ + where: { + serviceType: 'radarr', + targetServiceId: instanceId, + }, + }); + + if (rulesToDelete.length > 0) { + await routingRuleRepository.remove(rulesToDelete); + } + const removed = settings.radarr.splice(radarrIndex, 1); await settings.save(); diff --git a/server/routes/settings/routingRule.ts b/server/routes/settings/routingRule.ts new file mode 100644 index 00000000..0cf84dff --- /dev/null +++ b/server/routes/settings/routingRule.ts @@ -0,0 +1,357 @@ +import { getRepository } from '@server/datasource'; +import RoutingRule from '@server/entity/RoutingRule'; +import { Permission } from '@server/lib/permissions'; +import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; +import { In, Not } from 'typeorm'; + +const routingRuleRoutes = Router(); + +type ServiceType = 'radarr' | 'sonarr'; + +function resolveTargetService( + serviceType: ServiceType, + targetServiceId: number +): RadarrSettings | SonarrSettings | undefined { + const settings = getSettings(); + const services = serviceType === 'radarr' ? settings.radarr : settings.sonarr; + return services.find((s) => s.id === targetServiceId); +} + +function hasAnyCondition(body: Record): boolean { + return !!(body.users || body.genres || body.languages || body.keywords); +} + +function parseActiveProfileId( + raw: string | number | null | undefined +): number | null { + if (raw === '' || raw == null) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; +} + +routingRuleRoutes.get( + '/', + isAuthenticated(Permission.ADMIN), + async (_req, res, next) => { + const routingRuleRepository = getRepository(RoutingRule); + try { + const rules = await routingRuleRepository.find({ + order: { isFallback: 'ASC', priority: 'DESC' }, + }); + return res.status(200).json(rules); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +routingRuleRoutes.post( + '/', + isAuthenticated(Permission.ADMIN), + async (req, res, next) => { + const routingRuleRepository = getRepository(RoutingRule); + try { + const serviceType = req.body.serviceType as ServiceType; + const targetServiceId = Number(req.body.targetServiceId); + + if (!serviceType || !['radarr', 'sonarr'].includes(serviceType)) { + return next({ status: 400, message: 'Invalid serviceType.' }); + } + if (!Number.isFinite(targetServiceId) || targetServiceId < 0) { + return next({ status: 400, message: 'Invalid targetServiceId.' }); + } + + const target = resolveTargetService(serviceType, targetServiceId); + if (!target) { + return next({ status: 400, message: 'Target instance not found.' }); + } + + const derivedIs4k = !!target.is4k; + const isFallback = !!req.body.isFallback; + + if (isFallback) { + const existing = await routingRuleRepository.findOne({ + where: { serviceType, is4k: derivedIs4k, isFallback: true }, + }); + + if (existing) { + return next({ + status: 409, + message: 'Fallback already exists for this serviceType/is4k.', + }); + } + + if (!target.isDefault) { + return next({ + status: 400, + message: 'Fallback rules must target a default instance.', + }); + } + } + + if (!isFallback && !hasAnyCondition(req.body)) { + return next({ + status: 400, + message: 'Non-fallback rules must have at least one condition.', + }); + } + + const activeProfileId = parseActiveProfileId(req.body.activeProfileId); + + if (isFallback) { + if (!req.body.rootFolder) { + return next({ + status: 400, + message: 'Fallback requires rootFolder.', + }); + } + + if (activeProfileId == null) { + return next({ + status: 400, + message: 'Fallback requires activeProfileId.', + }); + } + + if (serviceType === 'radarr' && !req.body.minimumAvailability) { + return next({ + status: 400, + message: 'Fallback requires minimumAvailability for radarr.', + }); + } + } + + let priority = 0; + if (!isFallback) { + const highestRule = await routingRuleRepository.findOne({ + where: { serviceType, is4k: derivedIs4k, isFallback: false }, + order: { priority: 'DESC' }, + }); + priority = (highestRule?.priority ?? 0) + 10; + } + + const rule = new RoutingRule({ + name: req.body.name, + serviceType, + targetServiceId, + is4k: derivedIs4k, + isFallback, + priority, + users: isFallback ? null : req.body.users, + genres: isFallback ? null : req.body.genres, + languages: isFallback ? null : req.body.languages, + keywords: isFallback ? null : req.body.keywords, + activeProfileId: activeProfileId ?? undefined, + rootFolder: req.body.rootFolder, + seriesType: req.body.seriesType, + tags: req.body.tags, + minimumAvailability: req.body.minimumAvailability ?? null, + }); + + const newRule = await routingRuleRepository.save(rule); + return res.status(201).json(newRule); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +routingRuleRoutes.put<{ ruleId: string }>( + '/:ruleId', + isAuthenticated(Permission.ADMIN), + async (req, res, next) => { + const routingRuleRepository = getRepository(RoutingRule); + try { + const rule = await routingRuleRepository.findOne({ + where: { id: Number(req.params.ruleId) }, + }); + + if (!rule) { + return next({ status: 404, message: 'Routing rule not found.' }); + } + + const nextServiceType = (req.body.serviceType ?? + rule.serviceType) as ServiceType; + const nextTargetServiceId = Number( + req.body.targetServiceId ?? rule.targetServiceId + ); + + const target = resolveTargetService(nextServiceType, nextTargetServiceId); + if (!target) { + return next({ status: 400, message: 'Target instance not found.' }); + } + + const derivedIs4k = !!target.is4k; + const derivedIsDefault = !!target.isDefault; + const nextIsFallback = !!(req.body.isFallback ?? rule.isFallback); + + if (nextIsFallback) { + const existing = await routingRuleRepository.findOne({ + where: { + serviceType: nextServiceType, + is4k: derivedIs4k, + isFallback: true, + id: Not(rule.id), + }, + }); + + if (existing) { + return next({ + status: 409, + message: 'Fallback already exists for this serviceType/is4k.', + }); + } + } + + const mergedForConditionCheck = { ...rule, ...req.body }; + if (!nextIsFallback && !hasAnyCondition(mergedForConditionCheck)) { + return next({ + status: 400, + message: 'Non-fallback rules must have at least one condition.', + }); + } + + if (nextIsFallback && !derivedIsDefault) { + return next({ + status: 400, + message: 'Fallback rules must target a default instance.', + }); + } + + const nextActiveProfileId = parseActiveProfileId( + req.body.activeProfileId ?? rule.activeProfileId + ); + + const nextRootFolder = (req.body.rootFolder ?? rule.rootFolder) as + | string + | undefined; + + const nextMinimumAvailability = + nextServiceType === 'radarr' + ? (req.body.minimumAvailability ?? rule.minimumAvailability) + : null; + + if (nextIsFallback) { + if (!nextRootFolder) { + return next({ + status: 400, + message: 'Fallback requires rootFolder.', + }); + } + if (nextActiveProfileId == null) { + return next({ + status: 400, + message: 'Fallback requires activeProfileId.', + }); + } + if (nextServiceType === 'radarr' && !nextMinimumAvailability) { + return next({ + status: 400, + message: 'Fallback requires minimumAvailability for radarr.', + }); + } + } + + if (nextIsFallback) { + rule.priority = 0; + } else if (typeof req.body.priority === 'number') { + rule.priority = req.body.priority; + } else { + const groupChanged = + rule.serviceType !== nextServiceType || + rule.is4k !== derivedIs4k || + rule.isFallback; + + if (groupChanged) { + const highestRule = await routingRuleRepository.findOne({ + where: { + serviceType: nextServiceType, + is4k: derivedIs4k, + isFallback: false, + }, + order: { priority: 'DESC' }, + }); + rule.priority = (highestRule?.priority ?? 0) + 10; + } + } + + rule.name = req.body.name ?? rule.name; + rule.serviceType = nextServiceType; + rule.targetServiceId = nextTargetServiceId; + rule.is4k = derivedIs4k; + rule.isFallback = nextIsFallback; + rule.users = nextIsFallback ? null : req.body.users; + rule.genres = nextIsFallback ? null : req.body.genres; + rule.languages = nextIsFallback ? null : req.body.languages; + rule.keywords = nextIsFallback ? null : req.body.keywords; + rule.activeProfileId = nextActiveProfileId ?? undefined; + rule.rootFolder = nextRootFolder; + rule.minimumAvailability = nextMinimumAvailability; + rule.tags = req.body.tags; + + const updatedRule = await routingRuleRepository.save(rule); + return res.status(200).json(updatedRule); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +routingRuleRoutes.delete<{ ruleId: string }>( + '/:ruleId', + isAuthenticated(Permission.ADMIN), + async (req, res, next) => { + const routingRuleRepository = getRepository(RoutingRule); + try { + const rule = await routingRuleRepository.findOne({ + where: { id: Number(req.params.ruleId) }, + }); + + if (!rule) { + return next({ status: 404, message: 'Routing rule not found.' }); + } + + await routingRuleRepository.remove(rule); + return res.status(200).json(rule); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +routingRuleRoutes.post( + '/reorder', + isAuthenticated(Permission.ADMIN), + async (req, res, next) => { + const routingRuleRepository = getRepository(RoutingRule); + + try { + const { ruleIds } = req.body as { ruleIds: number[] }; + + const rules = await routingRuleRepository.findBy({ id: In(ruleIds) }); + const fallbackIds = new Set( + rules.filter((r) => r.isFallback).map((r) => r.id) + ); + const orderedIds = ruleIds.filter((id) => !fallbackIds.has(id)); + + for (let i = 0; i < orderedIds.length; i++) { + await routingRuleRepository.update(orderedIds[i], { + priority: (orderedIds.length - i) * 10, + }); + } + + const refreshed = await routingRuleRepository.find({ + order: { isFallback: 'ASC', priority: 'DESC' }, + }); + + return res.status(200).json(refreshed); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +export default routingRuleRoutes; diff --git a/src/components/Settings/OverrideRule/OverrideRuleModal.tsx b/src/components/Settings/OverrideRule/OverrideRuleModal.tsx deleted file mode 100644 index cc1accd2..00000000 --- a/src/components/Settings/OverrideRule/OverrideRuleModal.tsx +++ /dev/null @@ -1,544 +0,0 @@ -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 type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; -import axios from 'axios'; -import { Field, Formik } from 'formik'; -import { useCallback, useEffect, useState } from 'react'; -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', - service: 'Service', - serviceDescription: 'Apply this rule to the selected service.', - selectService: 'Select service', - 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; - radarrServices: RadarrSettings[]; - sonarrServices: SonarrSettings[]; -} - -const OverrideRuleModal = ({ - onClose, - rule, - radarrServices, - sonarrServices, -}: OverrideRuleModalProps) => { - const intl = useIntl(); - const { addToast } = useToasts(); - const { currentSettings } = useSettings(); - const [isValidated, setIsValidated] = useState(rule ? true : false); - const [isTesting, setIsTesting] = useState(false); - const [testResponse, setTestResponse] = useState({ - profiles: [], - rootFolders: [], - tags: [], - }); - - const getServiceInfos = useCallback( - async ( - { - hostname, - port, - apiKey, - baseUrl, - useSsl = false, - }: { - hostname: string; - port: number; - apiKey: string; - baseUrl?: string; - useSsl?: boolean; - }, - type: 'radarr' | 'sonarr' - ) => { - setIsTesting(true); - try { - const response = await axios.post( - `/api/v1/settings/${type}/test`, - { - hostname, - apiKey, - port: Number(port), - baseUrl, - useSsl, - } - ); - - setIsValidated(true); - setTestResponse(response.data); - } catch (e) { - setIsValidated(false); - } finally { - setIsTesting(false); - } - }, - [] - ); - - useEffect(() => { - if ( - rule?.radarrServiceId !== null && - rule?.radarrServiceId !== undefined && - radarrServices[rule?.radarrServiceId] - ) { - getServiceInfos(radarrServices[rule?.radarrServiceId], 'radarr'); - } - if ( - rule?.sonarrServiceId !== null && - rule?.sonarrServiceId !== undefined && - sonarrServices[rule?.sonarrServiceId] - ) { - getServiceInfos(sonarrServices[rule?.sonarrServiceId], 'sonarr'); - } - }, [ - getServiceInfos, - radarrServices, - rule?.radarrServiceId, - rule?.sonarrServiceId, - sonarrServices, - ]); - - 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: values.radarrServiceId, - sonarrServiceId: values.sonarrServiceId, - }; - if (!rule) { - await axios.post('/api/v1/overrideRule', submission); - addToast(intl.formatMessage(messages.ruleCreated), { - appearance: 'success', - autoDismiss: true, - }); - } else { - await axios.put(`/api/v1/overrideRule/${rule.id}`, submission); - 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.service)} -

-

- {intl.formatMessage(messages.serviceDescription)} -

-
- -
-
- -
- {errors.rootFolder && - touched.rootFolder && - typeof errors.rootFolder === 'string' && ( -
{errors.rootFolder}
- )} -
-
-

- {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); - }} - isDisabled={!isValidated || isTesting} - /> -
- {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 - isDisabled={!isValidated || isTesting} - 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/OverrideRuleTiles.tsx b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx deleted file mode 100644 index d4c7e61b..00000000 --- a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx +++ /dev/null @@ -1,309 +0,0 @@ -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 { - DVRSettings, - Language, - RadarrSettings, - SonarrSettings, -} from '@server/lib/settings'; -import type { Keyword } from '@server/models/common'; -import axios from 'axios'; -import { useCallback, 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 OverrideRuleTilesProps { - rules: OverrideRule[]; - setOverrideRuleModal: ({ - open, - rule, - }: { - open: boolean; - rule: OverrideRule | null; - }) => void; - revalidate: () => void; - radarrServices: RadarrSettings[]; - sonarrServices: SonarrSettings[]; -} - -const OverrideRuleTiles = ({ - rules, - setOverrideRuleModal, - revalidate, - radarrServices, - sonarrServices, -}: OverrideRuleTilesProps) => { - 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'); - const [testResponses, setTestResponses] = useState< - (DVRTestResponse & { type: string; id: number })[] - >([]); - - const getServiceInfos = useCallback(async () => { - const results: (DVRTestResponse & { type: string; id: number })[] = []; - const services: DVRSettings[] = [...radarrServices, ...sonarrServices]; - for (const service of services) { - const { hostname, port, apiKey, baseUrl, useSsl = false } = service; - try { - const response = await axios.post( - `/api/v1/settings/${ - radarrServices.includes(service as RadarrSettings) - ? 'radarr' - : 'sonarr' - }/test`, - { - hostname, - apiKey, - port: Number(port), - baseUrl, - useSsl, - } - ); - results.push({ - type: radarrServices.includes(service as RadarrSettings) - ? 'radarr' - : 'sonarr', - id: service.id, - ...response.data, - }); - } catch { - results.push({ - type: radarrServices.includes(service as RadarrSettings) - ? 'radarr' - : 'sonarr', - id: service.id, - profiles: [], - rootFolders: [], - tags: [], - }); - } - } - setTestResponses(results); - }, [radarrServices, sonarrServices]); - - useEffect(() => { - getServiceInfos(); - }, [getServiceInfos]); - - useEffect(() => { - (async () => { - const keywords = await Promise.all( - rules - .map((rule) => rule.keywords?.split(',')) - .flat() - .filter((keywordId) => keywordId) - .map(async (keywordId) => { - const response = await axios.get( - `/api/v1/keyword/${keywordId}` - ); - return response.data; - }) - ); - const validKeywords: Keyword[] = keywords.filter( - (keyword): keyword is Keyword => keyword !== null - ); - setKeywords(validKeywords); - const allUsersFromRules = rules - .map((rule) => rule.users) - .filter((users) => users) - .join(','); - if (allUsersFromRules) { - const response = await axios.get( - `/api/v1/user?includeIds=${encodeURIComponent(allUsersFromRules)}` - ); - const users: User[] = response.data.results; - setUsers(users); - } - })(); - }, [rules, users]); - - return ( - <> - {rules.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)} - - {testResponses - .find( - (r) => - (r.id === rule.radarrServiceId && - r.type === 'radarr') || - (r.id === rule.sonarrServiceId && r.type === 'sonarr') - ) - ?.profiles.find((profile) => rule.profileId === profile.id) - ?.name || rule.profileId} -

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

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

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

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

    - {rule.tags.split(',').map((tag) => ( - - {testResponses - .find( - (r) => - (r.id === rule.radarrServiceId && - r.type === 'radarr') || - (r.id === rule.sonarrServiceId && - r.type === 'sonarr') - ) - ?.tags?.find((t) => t.id === Number(tag))?.label || - tag} - - ))} -
    -

    - )} -
    -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
  • - ))} - - ); -}; - -export default OverrideRuleTiles; diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index 81c28307..ec268e80 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -10,15 +10,9 @@ import axios from 'axios'; 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 * as Yup from 'yup'; -type OptionType = { - value: number; - label: string; -}; - const messages = defineMessages('components.Settings.RadarrModal', { createradarr: 'Add New Radarr Server', create4kradarr: 'Add New 4K Radarr Server', @@ -28,10 +22,6 @@ const messages = defineMessages('components.Settings.RadarrModal', { validationHostnameRequired: 'You must provide a valid hostname or IP address', validationPortRequired: 'You must provide a valid port number', validationApiKeyRequired: 'You must provide an API key', - validationRootFolderRequired: 'You must select a root folder', - validationProfileRequired: 'You must select a quality profile', - validationMinimumAvailabilityRequired: - 'You must select a minimum availability', toastRadarrTestSuccess: 'Radarr connection established successfully!', toastRadarrTestFailure: 'Failed to connect to Radarr.', add: 'Add Server', @@ -43,22 +33,9 @@ const messages = defineMessages('components.Settings.RadarrModal', { ssl: 'Use SSL', apiKey: 'API Key', baseUrl: 'URL Base', + server4k: '4K Server', syncEnabled: 'Enable Scan', externalUrl: 'External URL', - qualityprofile: 'Quality Profile', - rootfolder: 'Root Folder', - minimumAvailability: 'Minimum Availability', - server4k: '4K Server', - selectQualityProfile: 'Select quality profile', - selectRootFolder: 'Select root folder', - selectMinimumAvailability: 'Select minimum availability', - loadingprofiles: 'Loading quality profiles…', - testFirstQualityProfiles: 'Test connection to load quality profiles', - loadingrootfolders: 'Loading root folders…', - testFirstRootFolders: 'Test connection to load root folders', - loadingTags: 'Loading tags…', - testFirstTags: 'Test connection to load tags', - tags: 'Tags', enableSearch: 'Enable Automatic Search', tagRequests: 'Tag Requests', tagRequestsInfo: @@ -67,17 +44,12 @@ const messages = defineMessages('components.Settings.RadarrModal', { validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationBaseUrlLeadingSlash: 'URL base must have a leading slash', validationBaseUrlTrailingSlash: 'URL base must not end in a trailing slash', - notagoptions: 'No tags.', - selecttags: 'Select tags', - announced: 'Announced', - inCinemas: 'In Cinemas', - released: 'Released', }); interface RadarrModalProps { radarr: RadarrSettings | null; onClose: () => void; - onSave: () => void; + onSave: (savedInstance: RadarrSettings) => Promise; } const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { @@ -105,15 +77,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { apiKey: Yup.string().required( intl.formatMessage(messages.validationApiKeyRequired) ), - rootFolder: Yup.string().required( - intl.formatMessage(messages.validationRootFolderRequired) - ), - activeProfileId: Yup.string().required( - intl.formatMessage(messages.validationProfileRequired) - ), - minimumAvailability: Yup.string().required( - intl.formatMessage(messages.validationMinimumAvailabilityRequired) - ), externalUrl: Yup.string() .test( 'valid-url', @@ -221,10 +184,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { ssl: radarr?.useSsl ?? false, apiKey: radarr?.apiKey, baseUrl: radarr?.baseUrl, - activeProfileId: radarr?.activeProfileId, - rootFolder: radarr?.activeDirectory, - minimumAvailability: radarr?.minimumAvailability ?? 'released', - tags: radarr?.tags ?? [], isDefault: radarr?.isDefault ?? false, is4k: radarr?.is4k ?? false, externalUrl: radarr?.externalUrl, @@ -235,10 +194,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { validationSchema={RadarrSettingsSchema} onSubmit={async (values) => { try { - const profileName = testResponse.profiles.find( - (profile) => profile.id === Number(values.activeProfileId) - )?.name; - const submission = { name: values.name, hostname: values.hostname, @@ -246,30 +201,32 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { apiKey: values.apiKey, useSsl: values.ssl, baseUrl: values.baseUrl, - activeProfileId: Number(values.activeProfileId), - activeProfileName: profileName, - activeDirectory: values.rootFolder, - is4k: values.is4k, - minimumAvailability: values.minimumAvailability, - tags: values.tags, isDefault: values.isDefault, + is4k: values.is4k, externalUrl: values.externalUrl, syncEnabled: values.syncEnabled, preventSearch: !values.enableSearch, tagRequests: values.tagRequests, }; + + let savedInstance: RadarrSettings; if (!radarr) { - await axios.post('/api/v1/settings/radarr', submission); + const response = await axios.post( + '/api/v1/settings/radarr', + submission + ); + savedInstance = response.data; } else { - await axios.put( + const response = await axios.put( `/api/v1/settings/radarr/${radarr.id}`, submission ); + savedInstance = response.data; } - onSave(); + await onSave(savedInstance); } catch (e) { - // set error here + // TODO: handle error } }} > @@ -501,176 +458,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { )} -
    - -
    -
    - - - {testResponse.profiles.length > 0 && - testResponse.profiles.map((profile) => ( - - ))} - -
    - {errors.activeProfileId && - touched.activeProfileId && - typeof errors.activeProfileId === 'string' && ( -
    {errors.activeProfileId}
    - )} -
    -
    -
    - -
    -
    - - - {testResponse.rootFolders.length > 0 && - testResponse.rootFolders.map((folder) => ( - - ))} - -
    - {errors.rootFolder && - touched.rootFolder && - typeof errors.rootFolder === 'string' && ( -
    {errors.rootFolder}
    - )} -
    -
    -
    - -
    -
    - - - - - -
    - {errors.minimumAvailability && - touched.minimumAvailability && ( -
    - {errors.minimumAvailability} -
    - )} -
    -
    -
    - -
    - - options={ - isValidated - ? testResponse.tags.map((tag) => ({ - label: tag.label, - value: tag.id, - })) - : [] - } - isMulti - isDisabled={!isValidated || isTesting} - placeholder={ - !isValidated - ? intl.formatMessage(messages.testFirstTags) - : isTesting - ? intl.formatMessage(messages.loadingTags) - : intl.formatMessage(messages.selecttags) - } - className="react-select-container" - classNamePrefix="react-select" - value={ - values.tags - .map((tagId) => { - const foundTag = testResponse.tags.find( - (tag) => tag.id === tagId - ); - - if (!foundTag) { - return undefined; - } - - return { - value: foundTag.id, - label: foundTag.label, - }; - }) - .filter( - (option) => option !== undefined - ) as OptionType[] - } - onChange={(value) => { - setFieldValue( - 'tags', - value.map((option) => option.value) - ); - }} - noOptionsMessage={() => - intl.formatMessage(messages.notagoptions) - } - /> -
    -
    +
    + +
    + +
    +