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 8f52efae86..d58f210a87 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -260,11 +260,68 @@ mediaRoutes.delete( if (isMovie) { await (service as RadarrAPI).removeMovie(media.tmdbId); } 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).removeSeries(tvdbId); } 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 5c1b9bb022..d75cf299ad 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -375,13 +375,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 () => { @@ -681,12 +683,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 c088143a50..d9a567be57 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -790,6 +790,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", @@ -799,6 +806,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",