From a29116518942672017d90dc14fe1fdd16e17ced0 Mon Sep 17 00:00:00 2001 From: Florian Lauer Date: Thu, 21 Aug 2025 20:09:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(override=20rules):=20years=20filter=20+=20?= =?UTF-8?q?service=20switching=20(4K=E2=86=94non-4K)=20with=20override=20r?= =?UTF-8?q?ules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added configurable years filter with ranges and service switching functionality to override rules. Years filter supports individual years and ranges (e.g. "2010-2025") with dynamic current year. Service switching provides three modes: force4k, forceStandard, and auto to automatically switch between 4K and non-4K services based on conditions. Implemented smart deletion logic that handles service switching scenarios by checking both external service ID fields and connecting to the correct Radarr/Sonarr instance. Fixed external service ID storage to use actual service type rather than original request type. Enhanced MediaRequestSubscriber to properly track external service IDs when service switching occurs. BREAKING CHANGE: New database fields, migrations and request handling. Fixes #1555 #1560 --- server/entity/MediaRequest.ts | 173 +++++++++++++++--- server/entity/OverrideRule.ts | 6 + .../1755781927232-AddYearsToOverrideRule.ts | 15 ++ ...93636110-AddServiceSwitchToOverrideRule.ts | 19 ++ .../1755781927232-AddYearsToOverrideRule.ts | 15 ++ ...93636110-AddServiceSwitchToOverrideRule.ts | 19 ++ server/routes/media.ts | 131 +++++++++++-- server/routes/overrideRule.ts | 8 + server/subscriber/MediaRequestSubscriber.ts | 15 +- .../OverrideRule/OverrideRuleModal.tsx | 82 ++++++++- .../OverrideRule/OverrideRuleTiles.tsx | 25 +++ src/i18n/locale/en.json | 12 ++ 12 files changed, 472 insertions(+), 48 deletions(-) create mode 100644 server/migration/postgres/1755781927232-AddYearsToOverrideRule.ts create mode 100644 server/migration/postgres/1755793636110-AddServiceSwitchToOverrideRule.ts create mode 100644 server/migration/sqlite/1755781927232-AddYearsToOverrideRule.ts create mode 100644 server/migration/sqlite/1755793636110-AddServiceSwitchToOverrideRule.ts diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index cdfa17c3a3..866b1cd83e 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -134,8 +134,8 @@ export class MediaRequest { media = new Media({ tmdbId: tmdbMedia.id, tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id, - status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, - status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, mediaType: requestBody.mediaType, }); } else { @@ -148,14 +148,6 @@ export class MediaRequest { throw new BlacklistedMediaError('This media is blacklisted.'); } - - if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { - media.status = MediaStatus.PENDING; - } - - if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) { - media.status4k = MediaStatus.PENDING; - } } const existing = await requestRepository @@ -211,20 +203,29 @@ export class MediaRequest { let tags = requestBody.tags; 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 }, - }); + + // Get override rules for all services (both 4K and non-4K) to allow service type switching + let overrideRules: OverrideRule[] = []; + if (requestBody.mediaType === MediaType.MOVIE) { + // Get rules for all Radarr services + const radarrServiceIds = settings.radarr.map((_, index) => index); + overrideRules = await overrideRuleRepository + .createQueryBuilder('rule') + .where('rule.radarrServiceId IN (:...serviceIds)', { + serviceIds: radarrServiceIds, + }) + .getMany(); + } else { + // Get rules for all Sonarr services + const sonarrServiceIds = settings.sonarr.map((_, index) => index); + overrideRules = await overrideRuleRepository + .createQueryBuilder('rule') + .where('rule.sonarrServiceId IN (:...serviceIds)', { + serviceIds: sonarrServiceIds, + }) + .getMany(); + } const appliedOverrideRules = overrideRules.filter((rule) => { const hasAnimeKeyword = @@ -291,13 +292,47 @@ export class MediaRequest { ) { return false; } + if (rule.years) { + let releaseYear: number; + if (requestBody.mediaType === MediaType.MOVIE) { + releaseYear = new Date( + (tmdbMedia as any).release_date + ).getFullYear(); + } else { + releaseYear = new Date( + (tmdbMedia as any).first_air_date + ).getFullYear(); + } + + const yearMatches = rule.years.split(',').some((yearRange) => { + const trimmedRange = yearRange.trim(); + if (trimmedRange.includes('-')) { + // Handle ranges like "2000-2010" + const [startYear, endYear] = trimmedRange.split('-').map(Number); + return releaseYear >= startYear && releaseYear <= endYear; + } else { + // Handle individual years + return Number(trimmedRange) === releaseYear; + } + }); + + if (!yearMatches) { + 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 keys: (keyof OverrideRule)[] = [ + 'genre', + 'language', + 'keywords', + 'years', + 'serviceSwitch', + ]; const aSpecificity = keys.filter((key) => a[key] !== null).length; const bSpecificity = keys.filter((key) => b[key] !== null).length; @@ -307,11 +342,82 @@ export class MediaRequest { })[0]; if (prioritizedRule) { - if (prioritizedRule.rootFolder) { - rootFolder = prioritizedRule.rootFolder; + let serviceSwitched = false; + + // Handle intelligent service switching based on serviceSwitch field + if (prioritizedRule.serviceSwitch) { + const originalIs4k = requestBody.is4k; + let targetIs4k = originalIs4k; + + // Determine target service type based on serviceSwitch setting + switch (prioritizedRule.serviceSwitch) { + case 'force4k': + targetIs4k = true; + break; + case 'forceStandard': + targetIs4k = false; + break; + case 'auto': + // Keep original request type + targetIs4k = originalIs4k; + break; + } + + // Apply service switching if type changed + if (targetIs4k !== originalIs4k) { + serviceSwitched = true; + requestBody.is4k = targetIs4k; + + // Find appropriate default service for the target type and use its defaults + if (requestBody.mediaType === MediaType.MOVIE) { + const targetService = targetIs4k + ? settings.radarr.find((r) => r.is4k && r.isDefault) + : settings.radarr.find((r) => !r.is4k && r.isDefault); + + if (targetService) { + requestBody.serverId = targetService.id; + // Reset to service defaults when switching to prevent conflicts + profileId = targetService.activeProfileId; + rootFolder = targetService.activeDirectory; + } + } else { + const targetService = targetIs4k + ? settings.sonarr.find((s) => s.is4k && s.isDefault) + : settings.sonarr.find((s) => !s.is4k && s.isDefault); + + if (targetService) { + requestBody.serverId = targetService.id; + // Reset to service defaults when switching to prevent conflicts + profileId = targetService.activeProfileId; + rootFolder = targetService.activeDirectory; + } + } + } + + // When service switching is enabled, ignore specific service IDs to prevent conflicts + } else { + // No service switching - apply specific service IDs from override rule + if ( + prioritizedRule.radarrServiceId !== null && + prioritizedRule.radarrServiceId !== undefined + ) { + requestBody.serverId = prioritizedRule.radarrServiceId; + } else if ( + prioritizedRule.sonarrServiceId !== null && + prioritizedRule.sonarrServiceId !== undefined + ) { + requestBody.serverId = prioritizedRule.sonarrServiceId; + } } - if (prioritizedRule.profileId) { - profileId = prioritizedRule.profileId; + + // Only apply override rule's profile and root folder if no service switching occurred + if (!serviceSwitched) { + if (prioritizedRule.rootFolder) { + rootFolder = prioritizedRule.rootFolder; + } + if (prioritizedRule.profileId) { + profileId = prioritizedRule.profileId; + } } if (prioritizedRule.tags) { tags = [ @@ -329,6 +435,17 @@ export class MediaRequest { } } + // Update media status based on final request type (after potential service switching) + if (media.status !== MediaStatus.BLACKLISTED) { + if (!requestBody.is4k && media.status === MediaStatus.UNKNOWN) { + media.status = MediaStatus.PENDING; + } + + if (requestBody.is4k && media.status4k === MediaStatus.UNKNOWN) { + media.status4k = MediaStatus.PENDING; + } + } + if (requestBody.mediaType === MediaType.MOVIE) { await mediaRepository.save(media); diff --git a/server/entity/OverrideRule.ts b/server/entity/OverrideRule.ts index aab72006f4..0505be66bc 100644 --- a/server/entity/OverrideRule.ts +++ b/server/entity/OverrideRule.ts @@ -24,6 +24,12 @@ class OverrideRule { @Column({ nullable: true }) public keywords?: string; + @Column({ nullable: true }) + public years?: string; + + @Column({ nullable: true }) + public serviceSwitch?: string; + @Column({ type: 'int', nullable: true }) public profileId?: number; diff --git a/server/migration/postgres/1755781927232-AddYearsToOverrideRule.ts b/server/migration/postgres/1755781927232-AddYearsToOverrideRule.ts new file mode 100644 index 0000000000..819e9c3817 --- /dev/null +++ b/server/migration/postgres/1755781927232-AddYearsToOverrideRule.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddYearsToOverrideRule1755781927232 implements MigrationInterface { + name = 'AddYearsToOverrideRule1755781927232'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "override_rule" ADD COLUMN "years" varchar` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "override_rule" DROP COLUMN "years"`); + } +} diff --git a/server/migration/postgres/1755793636110-AddServiceSwitchToOverrideRule.ts b/server/migration/postgres/1755793636110-AddServiceSwitchToOverrideRule.ts new file mode 100644 index 0000000000..6d3ba86100 --- /dev/null +++ b/server/migration/postgres/1755793636110-AddServiceSwitchToOverrideRule.ts @@ -0,0 +1,19 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddServiceSwitchToOverrideRule1755793636110 + implements MigrationInterface +{ + name = 'AddServiceSwitchToOverrideRule1755793636110'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "override_rule" ADD COLUMN "serviceSwitch" varchar` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "override_rule" DROP COLUMN "serviceSwitch"` + ); + } +} diff --git a/server/migration/sqlite/1755781927232-AddYearsToOverrideRule.ts b/server/migration/sqlite/1755781927232-AddYearsToOverrideRule.ts new file mode 100644 index 0000000000..819e9c3817 --- /dev/null +++ b/server/migration/sqlite/1755781927232-AddYearsToOverrideRule.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddYearsToOverrideRule1755781927232 implements MigrationInterface { + name = 'AddYearsToOverrideRule1755781927232'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "override_rule" ADD COLUMN "years" varchar` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "override_rule" DROP COLUMN "years"`); + } +} diff --git a/server/migration/sqlite/1755793636110-AddServiceSwitchToOverrideRule.ts b/server/migration/sqlite/1755793636110-AddServiceSwitchToOverrideRule.ts new file mode 100644 index 0000000000..6d3ba86100 --- /dev/null +++ b/server/migration/sqlite/1755793636110-AddServiceSwitchToOverrideRule.ts @@ -0,0 +1,19 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddServiceSwitchToOverrideRule1755793636110 + implements MigrationInterface +{ + name = 'AddServiceSwitchToOverrideRule1755793636110'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "override_rule" ADD COLUMN "serviceSwitch" varchar` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "override_rule" DROP COLUMN "serviceSwitch"` + ); + } +} diff --git a/server/routes/media.ts b/server/routes/media.ts index b9983d8bf9..ec713b7246 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -257,21 +257,126 @@ mediaRoutes.delete( } if (isMovie) { - await (service as RadarrAPI).removeMovie( - parseInt( - is4k - ? (media.externalServiceSlug4k as string) - : (media.externalServiceSlug as string) - ) - ); + // First try the requested type, then try the other type (for service switching cases) + let movieId = is4k + ? media.externalServiceId4k + : media.externalServiceId; + let actualIs4k = is4k; + + // If movie ID not found in the expected field, check the other field + if (!movieId) { + movieId = is4k ? media.externalServiceId : media.externalServiceId4k; + actualIs4k = !is4k; + + // If found in the other field, we need to use the correct service for that type + if (movieId) { + const settings = getSettings(); + // Get the specific service ID that was used for this type + const specificServiceId = actualIs4k + ? media.serviceId4k + : media.serviceId; + + let correctService; + if ( + specificServiceId !== null && + specificServiceId !== undefined && + specificServiceId >= 0 + ) { + // Use the specific service that was used + correctService = settings.radarr.find( + (radarr) => radarr.id === specificServiceId + ); + } else { + // Fallback to default service for that type + correctService = settings.radarr.find( + (radarr) => radarr.isDefault && radarr.is4k === actualIs4k + ); + } + + if (correctService) { + service = new RadarrAPI({ + apiKey: correctService.apiKey, + url: RadarrAPI.buildUrl(correctService, '/api/v3'), + }); + } + } + } + + if (!movieId) { + throw new Error('External service ID not found for movie'); + } + + // Delete directly using Radarr internal movie ID (more efficient than TMDB lookup) + await (service as RadarrAPI)['axios'].delete(`/movie/${movieId}`, { + params: { + deleteFiles: true, + addImportExclusion: false, + }, + }); } else { - const tmdb = new TheMovieDb(); - const series = await tmdb.getTvShow({ tvId: media.tmdbId }); - const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; - if (!tvdbId) { - throw new Error('TVDB ID not found'); + // TV Show deletion with smart service switching + let seriesId = is4k + ? media.externalServiceId4k + : media.externalServiceId; + let actualIs4k = is4k; + + // If series ID not found in the expected field, check the other field (for service switching cases) + if (!seriesId) { + seriesId = is4k ? media.externalServiceId : media.externalServiceId4k; + actualIs4k = !is4k; + + // If found in the other field, we need to use the correct service for that type + if (seriesId) { + const settings = getSettings(); + // Get the specific service ID that was used for this type + const specificServiceId = actualIs4k + ? media.serviceId4k + : media.serviceId; + + let correctService; + if ( + specificServiceId !== null && + specificServiceId !== undefined && + specificServiceId >= 0 + ) { + // Use the specific service that was used + correctService = settings.sonarr.find( + (sonarr) => sonarr.id === specificServiceId + ); + } else { + // Fallback to default service for that type + correctService = settings.sonarr.find( + (sonarr) => sonarr.isDefault && sonarr.is4k === actualIs4k + ); + } + + if (correctService) { + service = new SonarrAPI({ + apiKey: correctService.apiKey, + url: SonarrAPI.buildUrl(correctService, '/api/v3'), + }); + } + } + } + + if (!seriesId) { + // Fallback to TVDB lookup if no external service ID found + const tmdb = new TheMovieDb(); + const series = await tmdb.getTvShow({ tvId: media.tmdbId }); + const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; + if (!tvdbId) { + throw new Error('TVDB ID not found'); + } + await (service as SonarrAPI).removeSerie(tvdbId); + } else { + // Use Sonarr internal series ID directly (more efficient) + await (service as SonarrAPI)['axios'].delete(`/series/${seriesId}`, { + params: { + deleteFiles: true, + addImportExclusion: false, + }, + }); } - await (service as SonarrAPI).removeSerie(tvdbId); } return res.status(204).send(); diff --git a/server/routes/overrideRule.ts b/server/routes/overrideRule.ts index 912a68aae6..35278284f9 100644 --- a/server/routes/overrideRule.ts +++ b/server/routes/overrideRule.ts @@ -31,6 +31,8 @@ overrideRuleRoutes.post< genre?: string; language?: string; keywords?: string; + years?: string; + serviceSwitch?: string; profileId?: number; rootFolder?: string; tags?: string; @@ -46,6 +48,8 @@ overrideRuleRoutes.post< genre: req.body.genre, language: req.body.language, keywords: req.body.keywords, + years: req.body.years, + serviceSwitch: req.body.serviceSwitch, profileId: req.body.profileId, rootFolder: req.body.rootFolder, tags: req.body.tags, @@ -69,6 +73,8 @@ overrideRuleRoutes.put< genre?: string; language?: string; keywords?: string; + years?: string; + serviceSwitch?: string; profileId?: number; rootFolder?: string; tags?: string; @@ -93,6 +99,8 @@ overrideRuleRoutes.put< rule.genre = req.body.genre; rule.language = req.body.language; rule.keywords = req.body.keywords; + rule.years = req.body.years; + rule.serviceSwitch = req.body.serviceSwitch; rule.profileId = req.body.profileId; rule.rootFolder = req.body.rootFolder; rule.tags = req.body.tags; diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index 530b3a5a2e..06248843d2 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -365,13 +365,15 @@ export class MediaRequestSubscriber throw new Error('Media data not found'); } - media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] = + const actualIs4k = radarrSettings?.is4k ?? entity.is4k; + media[actualIs4k ? 'externalServiceId4k' : 'externalServiceId'] = radarrMovie.id; media[ - entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug' + actualIs4k ? 'externalServiceSlug4k' : 'externalServiceSlug' ] = radarrMovie.titleSlug; - media[entity.is4k ? 'serviceId4k' : 'serviceId'] = + media[actualIs4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id; + await mediaRepository.save(media); }) .catch(async () => { @@ -661,12 +663,13 @@ export class MediaRequestSubscriber throw new Error('Media data not found'); } - media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] = + const actualIs4k = sonarrSettings?.is4k ?? entity.is4k; + media[actualIs4k ? 'externalServiceId4k' : 'externalServiceId'] = sonarrSeries.id; media[ - entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug' + actualIs4k ? 'externalServiceSlug4k' : 'externalServiceSlug' ] = sonarrSeries.titleSlug; - media[entity.is4k ? 'serviceId4k' : 'serviceId'] = + media[actualIs4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id; await mediaRepository.save(media); }) diff --git a/src/components/Settings/OverrideRule/OverrideRuleModal.tsx b/src/components/Settings/OverrideRule/OverrideRuleModal.tsx index f5282323eb..7fdf3afded 100644 --- a/src/components/Settings/OverrideRule/OverrideRuleModal.tsx +++ b/src/components/Settings/OverrideRule/OverrideRuleModal.tsx @@ -40,6 +40,15 @@ const messages = defineMessages('components.Settings.OverrideRuleModal', { genres: 'Genres', languages: 'Languages', keywords: 'Keywords', + years: 'Years', + yearsDescription: + 'Filter by release/air date year. Use comma-separated values (e.g., 1980,1995) or ranges (e.g., 2000-2010)', + serviceSwitch: 'Service Switch', + serviceSwitchDescription: + 'Automatically switch between 4K and standard quality based on the rule conditions', + serviceSwitchAuto: 'Keep Original Request Type', + serviceSwitchForce4k: 'Force 4K Quality', + serviceSwitchForceStandard: 'Force Standard Quality', rootfolder: 'Root Folder', selectRootFolder: 'Select root folder', qualityprofile: 'Quality Profile', @@ -157,6 +166,8 @@ const OverrideRuleModal = ({ genre: rule?.genre, language: rule?.language, keywords: rule?.keywords, + years: rule?.years, + serviceSwitch: rule?.serviceSwitch, profileId: rule?.profileId, rootFolder: rule?.rootFolder, tags: rule?.tags, @@ -168,6 +179,8 @@ const OverrideRuleModal = ({ genre: values.genre || null, language: values.language || null, keywords: values.keywords || null, + years: values.years || null, + serviceSwitch: values.serviceSwitch || null, profileId: Number(values.profileId) || null, rootFolder: values.rootFolder || null, tags: values.tags || null, @@ -219,7 +232,9 @@ const OverrideRuleModal = ({ (!values.users && !values.genre && !values.language && - !values.keywords) || + !values.keywords && + !values.years && + !values.serviceSwitch) || (!values.rootFolder && !values.profileId && !values.tags) } onOk={() => handleSubmit()} @@ -403,12 +418,77 @@ const OverrideRuleModal = ({ )} +
+ +
+
+ +
+
+ {intl.formatMessage(messages.yearsDescription)} +
+ + Recommendations: 1980-{new Date().getFullYear()} (all), + 2000-{new Date().getFullYear()} (modern), 1980-1999 + (classic) + +
+ {errors.years && + touched.years && + typeof errors.years === 'string' && ( +
{errors.years}
+ )} +
+

{intl.formatMessage(messages.settings)}

{intl.formatMessage(messages.settingsDescription)}

+
+ +
+
+ + + + + +
+
+ {intl.formatMessage(messages.serviceSwitchDescription)} +
+ {errors.serviceSwitch && + touched.serviceSwitch && + typeof errors.serviceSwitch === 'string' && ( +
{errors.serviceSwitch}
+ )} +
+

)} + {rule.years && ( +

+ + {intl.formatMessage(messages.years)} + + {rule.years} +

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

+ + {intl.formatMessage(messages.serviceSwitch)} + + {rule.serviceSwitch === 'force4k' + ? intl.formatMessage(messages.serviceSwitchForce4k) + : rule.serviceSwitch === 'forceStandard' + ? intl.formatMessage(messages.serviceSwitchForceStandard) + : intl.formatMessage(messages.serviceSwitchAuto)} +

+ )} {rule.profileId && (

diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index a39b1d923e..09cfb09cd2 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -777,6 +777,13 @@ "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.OverrideRuleModal.years": "Years", + "components.Settings.OverrideRuleModal.yearsDescription": "Filter by release/air date year. Use comma-separated values (e.g., 1980,1995) or ranges (e.g., 2000-2010)", + "components.Settings.OverrideRuleModal.serviceSwitch": "Service Switch", + "components.Settings.OverrideRuleModal.serviceSwitchDescription": "Automatically switch between 4K and standard quality based on the rule conditions", + "components.Settings.OverrideRuleModal.serviceSwitchAuto": "Keep Original Request Type", + "components.Settings.OverrideRuleModal.serviceSwitchForce4k": "Force 4K Quality", + "components.Settings.OverrideRuleModal.serviceSwitchForceStandard": "Force Standard Quality", "components.Settings.OverrideRuleTile.conditions": "Conditions", "components.Settings.OverrideRuleTile.genre": "Genre", "components.Settings.OverrideRuleTile.keywords": "Keywords", @@ -786,6 +793,11 @@ "components.Settings.OverrideRuleTile.settings": "Settings", "components.Settings.OverrideRuleTile.tags": "Tags", "components.Settings.OverrideRuleTile.users": "Users", + "components.Settings.OverrideRuleTile.years": "Years", + "components.Settings.OverrideRuleTile.serviceSwitch": "Service Switch", + "components.Settings.OverrideRuleTile.serviceSwitchForce4k": "Force 4K", + "components.Settings.OverrideRuleTile.serviceSwitchForceStandard": "Force Standard", + "components.Settings.OverrideRuleTile.serviceSwitchAuto": "Keep Original", "components.Settings.RadarrModal.add": "Add Server", "components.Settings.RadarrModal.announced": "Announced", "components.Settings.RadarrModal.apiKey": "API Key",