feat: feature to specify Radarr server/profile in rule #34

This commit is contained in:
psyko-gh
2024-09-28 21:43:56 +02:00
parent 96ac8a958b
commit e33c72739a
9 changed files with 406 additions and 257 deletions

View File

@@ -58,6 +58,23 @@ When matching:
---
## Rule request options
It is possible to specify additional options when a movie is accepted using the `with` entry.
```yaml title="settings.yaml"
- name: The rule name
whenMatch: ...
action: accept
# Optional - request parameters. This must only be defined for rules with accept action
with:
radarr:
server: radarr # Optional - Specify the target radarr to use in a multi radarr setup (uses default radarr when omitted)
profile: 4K # Optional - Specify the target profile to use (uses default profile when omitted)
```
---
## Ruleset extension
Using the `extends` property of a ruleset, you can add rules to an existing ruleset.
@@ -71,10 +88,11 @@ config:
- name: exclude-netflix
rules:
- name: Exclude netflix movies
watchProviders:
- region: us
- names:
- Netflix
whenMatch:
- watchProviders:
region: us
names:
- Netflix
action: reject
- name: filter-movies
extends: exclude-netflix # movie will be evaluated against exclude-netflix rules first
@@ -86,6 +104,8 @@ config:
action: accept
```
---
## Ruleset example
The following ruleset will:

View File

@@ -1,242 +1,263 @@
{
"title": "Overcrawlrr configuration",
"type": "object",
"properties": {
"config": {
"type": "object",
"required": ["overseerr", "discovery", "rulesets"],
"properties": {
"overseerr": { "$ref": "#/definitions/overseer" },
"plex": { "$ref": "#/definitions/plex" },
"discovery": { "$ref": "#/definitions/discovery"},
"smartRecommendations": { "$ref": "#/definitions/smartRecommendations"},
"rulesets": { "$ref": "#/definitions/rulesets"}
}
}
},
"definitions": {
"overseer": {
"$id": "#/definitions/overseer",
"type": "object",
"required": ["apiUrl", "user", "password"],
"properties": {
"apiUrl": { "type": "string" },
"user": { "type": "string" },
"password": { "type": "string" },
"dryRunUrl": { "type": "boolean" }
}
},
"plex": {
"$id": "#/definitions/plex",
"type": "object",
"required": ["apiUrl", "plexToken"],
"properties": {
"apiUrl": { "type": "string" },
"plexToken": { "type": "string" }
}
},
"discovery": {
"$id": "#/definitions/discovery",
"type": "object",
"required": ["cron", "streams", "ruleset"],
"properties": {
"cron": { "type": "string" },
"ruleset": { "type": "string" },
"streams": { "type": "array", "items": { "type": "string" }}
}
},
"smartRecommendations": {
"$id": "#/definitions/smartRecommendations",
"type": "object",
"required": ["cron", "plexLibrary", "ruleset", "minimumRating"],
"properties": {
"cron": { "type": "string" },
"plexLibrary": { "type": "string" },
"ruleset": { "type": "string" },
"minimumRating": { "type": "number"}
}
},
"rulesets": {
"$id": "#/definitions/rulesets",
"type": "array",
"items": { "$ref": "#/definitions/ruleset" }
},
"ruleset": {
"$id": "#/definitions/ruleset",
"type": "object",
"required": ["name", "rules"],
"properties": {
"name": { "type": "string" },
"extends": { "type": "string" },
"rules": { "type": "array", "items": { "$ref": "#/definitions/rule" }}
}
},
"rule": {
"$id": "#/definitions/rule",
"type": "object",
"required": ["name", "whenMatch", "action"],
"properties": {
"name": { "type": "string" },
"whenMatch": { "type": "array", "items": { "$ref": "#/definitions/predicate" }},
"action": { "type": "string", "enum": ["accept", "reject", "skip"]}
}
},
"predicate": {
"$id": "#/definitions/predicate",
"type": "object",
"properties": {
"or": { "$ref": "#/definitions/orPredicate" },
"and": { "$ref": "#/definitions/andPredicate" },
"not": { "$ref": "#/definitions/notPredicate" },
"adult": { "$ref": "#/definitions/adultPredicate" },
"age": { "$ref": "#/definitions/agePredicate" },
"score": { "$ref": "#/definitions/scorePredicate" },
"genre": { "$ref": "#/definitions/genrePredicate" },
"keyword": { "$ref": "#/definitions/keywordPredicate" },
"watchProviders": { "$ref": "#/definitions/watchProvidersPredicate" },
"voteCount": { "$ref": "#/definitions/voteCountPredicate" },
"cast": { "$ref": "#/definitions/castPredicate" },
"crew": { "$ref": "#/definitions/crewPredicate" },
"released": { "$ref": "#/definitions/releasedPredicate" },
"runtime": { "$ref": "#/definitions/runtimePredicate" },
"productionCompany": { "$ref": "#/definitions/productionCompanyPredicate" },
"originalLanguage": { "$ref": "#/definitions/originalLanguagePredicate" },
"status": { "$ref": "#/definitions/statusPredicate" }
},
"additionalProperties": false
},
"orPredicate": {
"$id": "#/definitions/orPredicate",
"type": "array",
"items": { "$ref": "#/definitions/predicate" }
},
"andPredicate": {
"$id": "#/definitions/andPredicate",
"type": "array",
"items": { "$ref": "#/definitions/predicate" }
},
"notPredicate": {
"$id": "#/definitions/notPredicate",
"type": "array",
"items": { "$ref": "#/definitions/predicate" }
},
"adultPredicate": {
"$id": "#/definitions/adultPredicate",
"type": "string",
"enum": ["yes", "no"]
},
"agePredicate": {
"$id": "#/definitions/agePredicate",
"type": "string"
},
"releasedPredicate": {
"$id": "#/definitions/releasedPredicate",
"type": "string",
"enum": ["yes", "no"]
},
"scorePredicate": {
"$id": "#/definitions/scorePredicate",
"type": "string"
},
"voteCountPredicate": {
"$id": "#/definitions/voteCountPredicate",
"type": "string"
},
"genrePredicate": {
"$id": "#/definitions/genrePredicate",
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" }}
]
},
"keywordPredicate": {
"$id": "#/definitions/keywordPredicate",
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" }}
]
},
"watchProvidersPredicate": {
"$id": "#/definitions/watchProvidersPredicate",
"type": "object",
"properties": {
"region": { "type": "string"},
"names": { "type": "array", "items": { "type": "string" }}
}
},
"castPredicate": {
"$id": "#/definitions/castPredicate",
"oneOf": [
{ "type": "array", "items": { "type": "string" }},
{
"type": "object",
"properties": {
"voice": { "type": "string", "enum": ["include", "exclude"]},
"names": { "type": "array", "items": {"type": "string" }}
}
}
]
},
"crewPredicate": {
"$id": "#/definitions/crewPredicate",
"oneOf": [
{ "type": "array", "items": { "type": "string" }},
{
"type": "object",
"properties": {
"job": { "type": "string"},
"names": { "type": "array", "items": {"type": "string" }}
}
}
]
},
"productionCompanyPredicate": {
"$id": "#/definitions/productionCompanyPredicate",
"type": "array",
"items": {"type": "string" }
},
"runtimePredicate": {
"$id": "#/definitions/runtimePredicate",
"type": "string"
},
"originalLanguagePredicate": {
"$id": "#/definitions/originalLanguagePredicate",
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" }}
]
},
"statusPredicate": {
"$id": "#/definitions/statusPredicate",
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" }}
]
}
}
}
{
"title": "Overcrawlrr configuration",
"type": "object",
"properties": {
"config": {
"type": "object",
"required": ["overseerr", "discovery", "rulesets"],
"properties": {
"overseerr": { "$ref": "#/definitions/overseer" },
"plex": { "$ref": "#/definitions/plex" },
"discovery": { "$ref": "#/definitions/discovery"},
"smartRecommendations": { "$ref": "#/definitions/smartRecommendations"},
"rulesets": { "$ref": "#/definitions/rulesets"}
}
}
},
"definitions": {
"overseer": {
"$id": "#/definitions/overseer",
"type": "object",
"required": ["apiUrl", "user", "password"],
"properties": {
"apiUrl": { "type": "string" },
"user": { "type": "string" },
"password": { "type": "string" },
"dryRun": { "type": "boolean" }
}
},
"plex": {
"$id": "#/definitions/plex",
"type": "object",
"required": ["apiUrl", "plexToken"],
"properties": {
"apiUrl": { "type": "string" },
"plexToken": { "type": "string" }
}
},
"discovery": {
"$id": "#/definitions/discovery",
"type": "object",
"required": ["cron", "streams", "ruleset"],
"properties": {
"cron": { "type": "string" },
"ruleset": { "type": "string" },
"streams": { "type": "array", "items": { "type": "string" }}
}
},
"smartRecommendations": {
"$id": "#/definitions/smartRecommendations",
"type": "object",
"required": ["cron", "plexLibrary", "ruleset", "minimumRating"],
"properties": {
"cron": { "type": "string" },
"plexLibrary": { "type": "string" },
"ruleset": { "type": "string" },
"minimumRating": { "type": "number"}
}
},
"rulesets": {
"$id": "#/definitions/rulesets",
"type": "array",
"items": { "$ref": "#/definitions/ruleset" }
},
"ruleset": {
"$id": "#/definitions/ruleset",
"type": "object",
"required": ["name", "rules"],
"properties": {
"name": { "type": "string" },
"extends": { "type": "string" },
"rules": { "type": "array", "items": { "$ref": "#/definitions/rule" }}
}
},
"rule": {
"$id": "#/definitions/rule",
"type": "object",
"required": ["name", "whenMatch", "action"],
"properties": {
"name": { "type": "string" },
"whenMatch": { "type": "array", "items": { "$ref": "#/definitions/predicate" }},
"action": { "type": "string", "enum": ["accept", "reject", "skip"]},
"with": { "$ref": "#/definitions/ruleParameters" }
},
"additionalProperties": false
},
"ruleParameters": {
"$id": "#/definitions/ruleParameters",
"type": "object",
"properties": {
"radarr": { "$ref": "#/definitions/ruleServiceParameters"}
},
"additionalProperties": false
},
"ruleServiceParameters": {
"$id": "#/definitions/ruleServiceParameters",
"type": "object",
"properties": {
"server": { "type": "string"},
"profile": { "type": "string"}
},
"additionalProperties": false
},
"predicate": {
"$id": "#/definitions/predicate",
"type": "object",
"properties": {
"or": { "$ref": "#/definitions/orPredicate" },
"and": { "$ref": "#/definitions/andPredicate" },
"not": { "$ref": "#/definitions/notPredicate" },
"adult": { "$ref": "#/definitions/adultPredicate" },
"age": { "$ref": "#/definitions/agePredicate" },
"score": { "$ref": "#/definitions/scorePredicate" },
"genre": { "$ref": "#/definitions/genrePredicate" },
"keyword": { "$ref": "#/definitions/keywordPredicate" },
"watchProviders": { "$ref": "#/definitions/watchProvidersPredicate" },
"voteCount": { "$ref": "#/definitions/voteCountPredicate" },
"cast": { "$ref": "#/definitions/castPredicate" },
"crew": { "$ref": "#/definitions/crewPredicate" },
"released": { "$ref": "#/definitions/releasedPredicate" },
"runtime": { "$ref": "#/definitions/runtimePredicate" },
"productionCompany": { "$ref": "#/definitions/productionCompanyPredicate" },
"originalLanguage": { "$ref": "#/definitions/originalLanguagePredicate" },
"status": { "$ref": "#/definitions/statusPredicate" }
},
"additionalProperties": false
},
"orPredicate": {
"$id": "#/definitions/orPredicate",
"type": "array",
"items": { "$ref": "#/definitions/predicate" }
},
"andPredicate": {
"$id": "#/definitions/andPredicate",
"type": "array",
"items": { "$ref": "#/definitions/predicate" }
},
"notPredicate": {
"$id": "#/definitions/notPredicate",
"type": "array",
"items": { "$ref": "#/definitions/predicate" }
},
"adultPredicate": {
"$id": "#/definitions/adultPredicate",
"type": "string",
"enum": ["yes", "no"]
},
"agePredicate": {
"$id": "#/definitions/agePredicate",
"type": "string"
},
"releasedPredicate": {
"$id": "#/definitions/releasedPredicate",
"type": "string",
"enum": ["yes", "no"]
},
"scorePredicate": {
"$id": "#/definitions/scorePredicate",
"type": "string"
},
"voteCountPredicate": {
"$id": "#/definitions/voteCountPredicate",
"type": "string"
},
"genrePredicate": {
"$id": "#/definitions/genrePredicate",
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" }}
]
},
"keywordPredicate": {
"$id": "#/definitions/keywordPredicate",
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" }}
]
},
"watchProvidersPredicate": {
"$id": "#/definitions/watchProvidersPredicate",
"type": "object",
"properties": {
"region": { "type": "string"},
"names": { "type": "array", "items": { "type": "string" }}
}
},
"castPredicate": {
"$id": "#/definitions/castPredicate",
"oneOf": [
{ "type": "array", "items": { "type": "string" }},
{
"type": "object",
"properties": {
"voice": { "type": "string", "enum": ["include", "exclude"]},
"names": { "type": "array", "items": {"type": "string" }}
}
}
]
},
"crewPredicate": {
"$id": "#/definitions/crewPredicate",
"oneOf": [
{ "type": "array", "items": { "type": "string" }},
{
"type": "object",
"properties": {
"job": { "type": "string"},
"names": { "type": "array", "items": {"type": "string" }}
}
}
]
},
"productionCompanyPredicate": {
"$id": "#/definitions/productionCompanyPredicate",
"type": "array",
"items": {"type": "string" }
},
"runtimePredicate": {
"$id": "#/definitions/runtimePredicate",
"type": "string"
},
"originalLanguagePredicate": {
"$id": "#/definitions/originalLanguagePredicate",
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" }}
]
},
"statusPredicate": {
"$id": "#/definitions/statusPredicate",
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" }}
]
}
}
}

View File

@@ -1,12 +1,25 @@
import HttpApi from '@core/api/httpApi';
import { getSettings } from '@core/lib/settings';
import logger from '@core/log';
import { AuthResponse, MediaRequest, MovieDetails, MovieResult, OverseerrRequestsResult, OverseerrResult } from '@core/api/overseerr/interfaces';
import { getErrorMessage, success } from '@core/lib/utils';
import {
AuthResponse,
MediaRequest,
MovieDetails,
MovieResult,
OverseerrRequestsResult,
OverseerrResult,
RadarrProfile,
RadarrService,
RadarrServiceDetails,
RequestOption,
} from '@core/api/overseerr/interfaces';
import { getErrorMessage, isFulfilled, success } from '@core/lib/utils';
import NodeCache from 'node-cache';
class OverseerrApi extends HttpApi {
private overseerrUser?: string;
private overseerrPassword?: string;
private radarrCache?: NodeCache;
constructor() {
super(getSettings().overseerr.apiUrl, {});
@@ -76,16 +89,73 @@ class OverseerrApi extends HttpApi {
return (await this.get<OverseerrResult<MovieResult>>(`/movie/${idMovie}/recommendations`)).results;
};
public requestMovie = async (idMovie: number) => {
public requestMovie = async (idMovie: number, options: Partial<RequestOption> = {}) => {
try {
return await this.post(`/request`, {
const request = {
mediaType: 'movie',
mediaId: idMovie,
});
} as Partial<MediaRequest>;
if (options.server) {
const radarrServer = await this.getRadarr(options.server);
request.serverId = radarrServer.server.id;
}
if (options.profile) {
const radarrProfile = await this.getRadarrProfile(options.server ?? `default${options.is4k ? '4k' : ''}`, options.profile);
request.profileId = radarrProfile.id;
}
return await this.post(`/request`, request);
} catch (e) {
logger.error(e);
}
};
private getRadarrCache = async (): Promise<NodeCache> => {
if (!this.radarrCache) {
this.radarrCache = new NodeCache();
logger.info('Fetching list of Radarr services...');
const servers = await this.get<Array<RadarrService>>(`/service/radarr`);
const registerServer = (service: RadarrServiceDetails) => {
this.radarrCache!.set(`server:${service.server.name}`, service);
if (service.server.isDefault) {
if (service.server.is4k) {
this.radarrCache!.set(`server:default4k`, service);
} else {
this.radarrCache!.set(`server:default`, service);
}
}
logger.info(`Found Radarr server ${service.server.name} with profiles ${service.profiles.map((s) => s.name).join(', ')}`);
};
const services: Array<PromiseSettledResult<RadarrServiceDetails>> = await Promise.allSettled(
servers.map((s) => this.get<RadarrServiceDetails>(`/service/radarr/${s.id}`))
);
services
.filter(isFulfilled)
.map((r) => r.value)
.forEach(registerServer);
}
return this.radarrCache;
};
public getRadarr = async (serverName: string): Promise<RadarrServiceDetails> => {
const cache = await this.getRadarrCache();
const fromCache = cache.get<RadarrServiceDetails>(`server:${serverName}`);
if (fromCache) {
return fromCache;
}
throw new Error(`Radarr server ${serverName} not found. Check the spelling, and make sure it exists in Overseerr.`);
};
public getRadarrProfile = async (serverName: string, profileName: string): Promise<RadarrProfile> => {
const radarr = await this.getRadarr(serverName);
const profile = radarr.profiles.find((p) => p.name === profileName);
if (profile) {
return profile;
}
throw new Error(`Radarr profile ${profileName} not found for server ${serverName}`);
};
}
export default OverseerrApi;

View File

@@ -182,3 +182,26 @@ export interface Genre {
id: number;
name: string;
}
export interface RadarrService {
id: number;
name: string;
is4k: boolean;
isDefault: boolean;
}
export interface RadarrServiceDetails {
server: RadarrService;
profiles: RadarrProfile[];
}
export interface RequestOption {
server: string;
profile: string;
is4k: boolean;
}
export interface RadarrProfile {
id: number;
name: string;
}

View File

@@ -1,5 +1,5 @@
import { MovieDetails } from '@core/api/overseerr/interfaces';
import { PredicateOption, RuleAction } from '@core/lib/rules/interfaces';
import { PredicateOption, RuleAction, RuleParameters } from '@core/lib/rules/interfaces';
import { PredicateFactoryClass } from '@core/lib/rules/factory';
export abstract class Predicate {
@@ -24,11 +24,13 @@ export class Rule {
public name: string;
public filter: Predicate[];
public action: RuleAction;
public with?: RuleParameters;
constructor(name: string, filter: Predicate[], action: RuleAction) {
constructor(name: string, filter: Predicate[], action: RuleAction, options?: RuleParameters) {
this.name = name;
this.filter = filter;
this.action = action;
this.with = options;
}
public matches(movie: MovieDetails): boolean {

View File

@@ -10,6 +10,7 @@ export interface RuleOptions {
name: string;
whenMatch: PredicateOption[];
action: RuleAction;
with?: RuleParameters;
}
export type PredicateOption =
@@ -113,3 +114,12 @@ export type RuntimeOptions = {
export type StatusOptions = {
status: string | string[];
};
export type RuleParameters = {
radarr: RuleServiceParameters;
};
export type RuleServiceParameters = {
server?: string;
profile?: string;
};

View File

@@ -39,7 +39,7 @@ class Ruleset {
}
private buildRules(): void {
this._rules = this._rulesetOptions.rules.map((o) => new Rule(o.name, PredicateFactory.buildPredicates(o.whenMatch), o.action));
this._rules = this._rulesetOptions.rules.map((o) => new Rule(o.name, PredicateFactory.buildPredicates(o.whenMatch), o.action, o.with));
}
public evaluateRules(movie: MovieDetails): RuleEvaluation {

View File

@@ -82,7 +82,6 @@ apiRouter.use('/smartRecommendations', async (req, res) => {
message: 'Smart recommendations complete !',
});
} catch (e: unknown) {
console.log(e);
res.status(500).json({
success: false,
message: getErrorMessage(e),

View File

@@ -1,7 +1,7 @@
import logger from '@core/log';
import { getSettings } from '@core/lib/settings';
import OverseerrApi from '@core/api/overseerr';
import { MediaStatus, MovieResult } from '@core/api/overseerr/interfaces';
import { MediaStatus, MovieResult, RequestOption } from '@core/api/overseerr/interfaces';
import Ruleset, { getRuleset } from '@core/lib/ruleset';
import { color, distinctMovies, isFulfilled, isMovie, success } from '@core/lib/utils';
import PlexApi from '@core/api/plex';
@@ -25,7 +25,11 @@ const processMovieResult = async (movies: MovieResult[], overseerr: OverseerrApi
if (dryRun) {
logger.info(`Dry run - A request would have been sent to Overseerr to request movie ${movie.title}`);
} else {
await overseerr.requestMovie(movie.id);
const options = {
...(ruleResult.rule?.with?.radarr ?? {}),
} as RequestOption;
await overseerr.requestMovie(movie.id, options);
}
} else if (ruleResult.result === 'reject') {
logger.info(`${color.red(' Rejecting')} - "${ruleResult.movie.title}" because of rule "${color.blue(ruleResult.rule?.name ?? '')}"`);