feat(overriderules): apply override rules to advanced requests (#2164)

This commit is contained in:
Gauthier
2026-09-22 02:59:56 +05:00
committed by GitHub
parent 38581ca1c0
commit 794743a45f
9 changed files with 478 additions and 133 deletions
+64
View File
@@ -8238,6 +8238,70 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/OverrideRule'
/overrideRule/advancedRequest:
post:
summary: Advanced override rule request
description: Processes an advanced override rule request.
tags:
- overriderule
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- mediaType
- tmdbId
properties:
mediaType:
type: string
tmdbId:
type: number
example: 1
is4k:
type: boolean
example: false
requestId:
type: number
nullable: true
requestUser:
type: number
nullable: true
tags:
type: array
items:
type: number
nullable: true
serviceId:
type: number
nullable: true
responses:
'200':
description: Advanced override rule request processed
content:
application/json:
schema:
type: object
properties:
rootFolder:
type: string
nullable: true
profileId:
type: number
nullable: true
tags:
type: array
items:
type: number
nullable: true
'403':
description: User does not have permission to modify the request user
'404':
description: User, media or request not found
'500':
description: Unable to evaluate override rules
security:
- cookieAuth: []
- apiKey: []
+23 -120
View File
@@ -1,15 +1,13 @@
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,
MediaStatus,
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 overrideRules from '@server/lib/overrideRules';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -256,125 +254,30 @@ export class MediaRequest {
let tags = requestBody.tags;
if (useOverrides) {
const defaultRadarrId = requestBody.is4k
? settings.radarr.find((r) => r.is4k && r.isDefault)?.id
: settings.radarr.find((r) => !r.is4k && r.isDefault)?.id;
const defaultSonarrId = requestBody.is4k
? settings.sonarr.find((s) => s.is4k && s.isDefault)?.id
: settings.sonarr.find((s) => !s.is4k && s.isDefault)?.id;
const [defaultServiceIdField, defaultServiceId] =
requestBody.mediaType === MediaType.MOVIE
? (['radarrServiceId', defaultRadarrId] as const)
: (['sonarrServiceId', defaultSonarrId] as const);
const overrideRuleRepository = getRepository(OverrideRule);
const overrideRules =
defaultServiceId === undefined
? []
: await overrideRuleRepository.find({
where: { [defaultServiceIdField]: defaultServiceId },
});
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;
const overrideRulesResult = await overrideRules({
mediaType: requestBody.mediaType,
is4k: requestBody.is4k || false,
tmdbMedia,
requestUser,
tags,
});
// 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)),
]),
];
}
if (overrideRulesResult.rootFolder) {
rootFolder = overrideRulesResult.rootFolder;
}
if (overrideRulesResult.profileId) {
profileId = overrideRulesResult.profileId;
}
if (overrideRulesResult.tags) {
tags = overrideRulesResult.tags;
}
if (
overrideRulesResult.rootFolder ||
overrideRulesResult.profileId ||
overrideRulesResult.tags
) {
logger.debug('Override rule applied.', {
label: 'Media Request',
overrides: prioritizedRule,
label: 'Override Rules',
overrides: overrideRulesResult,
});
}
}
+166
View File
@@ -0,0 +1,166 @@
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type {
TmdbKeyword,
TmdbMovieDetails,
TmdbTvDetails,
} from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { User } from '@server/entity/User';
import { getSettings } from '@server/lib/settings';
export type OverrideRulesResult = {
rootFolder: string | null;
profileId: number | null;
tags: number[] | null;
};
async function overrideRules({
mediaType,
is4k,
tmdbMedia,
requestUser,
tags = null,
serviceId,
}: {
mediaType: MediaType;
is4k: boolean;
tmdbMedia: TmdbMovieDetails | TmdbTvDetails;
requestUser: User;
tags?: number[] | null;
serviceId?: number;
}): Promise<OverrideRulesResult> {
const settings = getSettings();
let rootFolder: string | null = null;
let profileId: number | null = null;
const serviceIdField =
mediaType === MediaType.MOVIE ? 'radarrServiceId' : 'sonarrServiceId';
if (serviceId === undefined) {
const defaultRadarrId = is4k
? settings.radarr.find((r) => r.is4k && r.isDefault)?.id
: settings.radarr.find((r) => !r.is4k && r.isDefault)?.id;
const defaultSonarrId = is4k
? settings.sonarr.find((s) => s.is4k && s.isDefault)?.id
: settings.sonarr.find((s) => !s.is4k && s.isDefault)?.id;
serviceId =
mediaType === MediaType.MOVIE ? defaultRadarrId : defaultSonarrId;
}
const overrideRuleRepository = getRepository(OverrideRule);
const rules =
serviceId === undefined
? []
: await overrideRuleRepository.find({
where: { [serviceIdField]: serviceId },
});
const appliedOverrideRules = rules.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 (
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 hasSpecificOverrideValue = (
rule: OverrideRule,
key: keyof OverrideRule
) => {
const value = rule[key];
if (value == null) return false;
return typeof value !== 'string' || value.trim() !== '';
};
const prioritizedRule = appliedOverrideRules.sort((a, b) => {
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
const aSpecificity = keys.filter((key) =>
hasSpecificOverrideValue(a, key)
).length;
const bSpecificity = keys.filter((key) =>
hasSpecificOverrideValue(b, key)
).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)),
]),
];
}
}
return { rootFolder, profileId, tags };
}
export default overrideRules;
+1 -5
View File
@@ -178,11 +178,7 @@ router.use('/service', isAuthenticated(), serviceRoutes);
router.use('/issue', isAuthenticated(), issueRoutes);
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
router.use('/auth', authRoutes);
router.use(
'/overrideRule',
isAuthenticated(Permission.ADMIN),
overrideRuleRoutes
);
router.use('/overrideRule', isAuthenticated(), overrideRuleRoutes);
router.get('/regions', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb();
+133
View File
@@ -1,7 +1,19 @@
import TheMovieDb from '@server/api/themoviedb';
import type {
TmdbMovieDetails,
TmdbTvDetails,
} from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { MediaRequest } from '@server/entity/MediaRequest';
import OverrideRule from '@server/entity/OverrideRule';
import { User } from '@server/entity/User';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import overrideRules, {
type OverrideRulesResult,
} from '@server/lib/overrideRules';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
@@ -61,6 +73,127 @@ overrideRuleRoutes.post<
}
});
overrideRuleRoutes.post<
Record<string, string>,
OverrideRulesResult,
{
mediaType: MediaType;
is4k: boolean;
tmdbId: number;
requestUser: number;
requestId: number | null;
tags?: number[] | null;
serviceId?: number;
}
>(
'/advancedRequest',
isAuthenticated([Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS], {
type: 'or',
}),
async (req, res, next) => {
try {
const tmdb = new TheMovieDb();
let tmdbMedia: TmdbMovieDetails | TmdbTvDetails;
try {
tmdbMedia =
req.body.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: req.body.tmdbId })
: await tmdb.getTvShow({ tvId: req.body.tmdbId });
} catch {
return next({ status: 404, message: 'Media not found' });
}
const userRepository = getRepository(User);
const requestRepository = getRepository(MediaRequest);
let ruleUser: User | null = req.user || null;
if (req.body.requestId) {
const request = await requestRepository.findOne({
where: {
id: Number(req.body.requestId),
},
});
if (!request) {
return next({ status: 404, message: 'Request not found.' });
}
if (
(request.requestedBy.id !== req.user?.id ||
(req.body.mediaType !== MediaType.TV &&
!req.user?.hasPermission(Permission.REQUEST_ADVANCED))) &&
!req.user?.hasPermission(Permission.MANAGE_REQUESTS)
) {
return next({
status: 403,
message: 'You do not have permission to modify this request.',
});
}
ruleUser = request.requestedBy;
if (
req.body.requestUser &&
req.body.requestUser !== request.requestedBy.id &&
!req.user?.hasPermission([
Permission.MANAGE_USERS,
Permission.MANAGE_REQUESTS,
])
) {
return next({
status: 403,
message: 'You do not have permission to modify the request user.',
});
} else if (req.body.requestUser) {
ruleUser = await userRepository.findOneOrFail({
where: { id: req.body.requestUser },
});
}
} else if (req.body.requestUser !== req.user?.id) {
if (
req.body.requestUser &&
!req.user?.hasPermission([
Permission.MANAGE_USERS,
Permission.MANAGE_REQUESTS,
])
) {
return next({
status: 403,
message: 'You do not have permission to modify the request user.',
});
} else if (req.body.requestUser) {
ruleUser = await userRepository.findOneOrFail({
where: { id: req.body.requestUser },
});
}
}
if (!ruleUser) {
return next({ status: 404, message: 'User not found.' });
}
const overrideRulesResult: OverrideRulesResult = await overrideRules({
mediaType: req.body.mediaType,
is4k: req.body.is4k,
tmdbMedia,
requestUser: ruleUser,
tags: req.body.tags,
serviceId: req.body.serviceId,
});
res.status(200).json(overrideRulesResult);
} catch (e) {
logger.error('Something went wrong evaluating override rules', {
label: 'API',
errorMessage: e.message,
tmdbId: req.body.tmdbId,
});
next({ status: 500, message: 'Unable to evaluate override rules.' });
}
}
);
overrideRuleRoutes.put<
{ ruleId: string },
OverrideRule,
@@ -2,6 +2,7 @@
import CachedImage from '@app/components/Common/CachedImage';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
import useToasts from '@app/hooks/useToasts';
import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
@@ -21,7 +22,9 @@ import type {
ServiceCommonServerWithDetails,
} from '@server/interfaces/api/serviceInterfaces';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import type { OverrideRulesResult } from '@server/lib/overrideRules';
import { hasPermission } from '@server/lib/permissions';
import axios from 'axios';
import { isEqual } from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -63,24 +66,29 @@ export type RequestOverrides = {
interface AdvancedRequesterProps {
type: 'movie' | 'tv';
tmdbId?: number;
is4k: boolean;
isAnime?: boolean;
defaultOverrides?: RequestOverrides;
requestUser?: User;
requestId?: number;
quota?: { movie: { limit?: number }; tv: { limit?: number } };
onChange: (overrides: RequestOverrides) => void;
}
const AdvancedRequester = ({
type,
tmdbId,
is4k = false,
isAnime = false,
defaultOverrides,
requestUser,
requestId,
quota,
onChange,
}: AdvancedRequesterProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const { data, error } = useSWR<ServiceCommonServer[]>(
`/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`,
@@ -330,6 +338,68 @@ const AdvancedRequester = ({
isIgnoreQuotaVisible,
]);
useEffect(() => {
let cancelled = false;
(async () => {
if (tmdbId && serverData?.server.id === selectedServer) {
try {
const { data: override } = await axios.post<OverrideRulesResult>(
'/api/v1/overrideRule/advancedRequest',
{
mediaType: type,
is4k,
requestUser:
selectedUser?.id ?? requestUser?.id ?? currentUser?.id,
tmdbId,
tags: selectedTags.length > 0 ? selectedTags : undefined,
serviceId: selectedServer ?? undefined,
requestId: requestId ?? undefined,
}
);
if (cancelled) {
return;
}
if (!defaultOverrides?.folder && override.rootFolder) {
setSelectedFolder(override.rootFolder);
}
if (!defaultOverrides?.profile && override.profileId) {
setSelectedProfile(override.profileId);
}
if (
!defaultOverrides?.tags &&
override.tags &&
!isEqual(override.tags, selectedTags)
) {
setSelectedTags(override.tags);
}
} catch {
if (cancelled) {
return;
}
addToast(intl.formatMessage(globalMessages.error), {
appearance: 'error',
autoDismiss: true,
});
}
}
})();
return () => {
cancelled = true;
};
}, [
tmdbId,
type,
is4k,
serverData?.server.id,
selectedServer,
selectedUserId,
requestUser?.id,
currentUser?.id,
defaultOverrides?.folder,
defaultOverrides?.profile,
defaultOverrides?.tags,
]);
if (!data && !error) {
return (
<div className="mb-2 w-full">
@@ -517,8 +517,10 @@ const CollectionRequestModal = ({
</div>
</div>
</div>
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
{hasPermission(
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
{ type: 'or' }
) && (
<AdvancedRequester
type="movie"
is4k={is4k}
@@ -285,12 +285,16 @@ const MovieRequestModal = ({
: intl.formatMessage(messages.requestfrom, {
username: editRequest.requestedBy.displayName,
})}
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
{hasPermission(
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
{ type: 'or' }
) && (
<AdvancedRequester
type="movie"
tmdbId={tmdbId}
is4k={is4k}
requestUser={editRequest.requestedBy}
requestId={editRequest.id}
defaultOverrides={{
folder: editRequest.rootFolder,
profile: editRequest.profileId,
@@ -358,9 +362,12 @@ const MovieRequestModal = ({
}
/>
)}
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
{hasPermission(
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
{ type: 'or' }
) && (
<AdvancedRequester
tmdbId={tmdbId}
type="movie"
is4k={is4k}
quota={quota}
@@ -714,10 +714,13 @@ const TvRequestModal = ({
</div>
</div>
</div>
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
{hasPermission(
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
{ type: 'or' }
) && (
<AdvancedRequester
type="tv"
tmdbId={tmdbId}
is4k={is4k}
isAnime={data?.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
@@ -725,6 +728,7 @@ const TvRequestModal = ({
quota={quota}
onChange={(overrides) => setRequestOverrides(overrides)}
requestUser={editRequest?.requestedBy}
requestId={editRequest?.id}
defaultOverrides={
editRequest
? {