Files
channels-seerr/server/routes/settings/routingRule.ts

371 lines
11 KiB
TypeScript

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<string, unknown>): 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 MAX_RULE_IDS = 1000;
if (!Array.isArray(ruleIds)) {
return next({ status: 400, message: 'ruleIds must be an array.' });
}
if (ruleIds.length > MAX_RULE_IDS) {
return next({
status: 400,
message: `Too many ruleIds provided. Maximum allowed is ${MAX_RULE_IDS}.`,
});
}
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;