diff --git a/docs/rulesets.md b/docs/rulesets.md index 5c4fc28..91f2a73 100644 --- a/docs/rulesets.md +++ b/docs/rulesets.md @@ -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: diff --git a/schema/schema.json b/schema/schema.json index 2afeafe..ee21b86 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -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" }} + ] + } + } +} diff --git a/src/api/overseerr/index.ts b/src/api/overseerr/index.ts index 3080e79..94b8019 100644 --- a/src/api/overseerr/index.ts +++ b/src/api/overseerr/index.ts @@ -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>(`/movie/${idMovie}/recommendations`)).results; }; - public requestMovie = async (idMovie: number) => { + public requestMovie = async (idMovie: number, options: Partial = {}) => { try { - return await this.post(`/request`, { + const request = { mediaType: 'movie', mediaId: idMovie, - }); + } as Partial; + + 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 => { + if (!this.radarrCache) { + this.radarrCache = new NodeCache(); + logger.info('Fetching list of Radarr services...'); + const servers = await this.get>(`/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> = await Promise.allSettled( + servers.map((s) => this.get(`/service/radarr/${s.id}`)) + ); + services + .filter(isFulfilled) + .map((r) => r.value) + .forEach(registerServer); + } + return this.radarrCache; + }; + + public getRadarr = async (serverName: string): Promise => { + const cache = await this.getRadarrCache(); + const fromCache = cache.get(`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 => { + 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; diff --git a/src/api/overseerr/interfaces.ts b/src/api/overseerr/interfaces.ts index 401a461..ae62055 100644 --- a/src/api/overseerr/interfaces.ts +++ b/src/api/overseerr/interfaces.ts @@ -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; +} diff --git a/src/lib/rules/index.ts b/src/lib/rules/index.ts index b3067f7..3361f8f 100644 --- a/src/lib/rules/index.ts +++ b/src/lib/rules/index.ts @@ -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 { diff --git a/src/lib/rules/interfaces.ts b/src/lib/rules/interfaces.ts index 24d360c..83b899b 100644 --- a/src/lib/rules/interfaces.ts +++ b/src/lib/rules/interfaces.ts @@ -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; +}; diff --git a/src/lib/ruleset.ts b/src/lib/ruleset.ts index d0bcd4a..47bc038 100644 --- a/src/lib/ruleset.ts +++ b/src/lib/ruleset.ts @@ -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 { diff --git a/src/routes/index.ts b/src/routes/index.ts index 9a47e70..889401e 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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), diff --git a/src/service/index.ts b/src/service/index.ts index c5f00e8..01b6dec 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -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 ?? '')}"`);