From b6130f5fb3f13a52d51a6c3aa6f7eaf85260f1d9 Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Sat, 24 Aug 2024 10:29:18 +0200 Subject: [PATCH 1/9] refactor(tests): refactor tests --- src/lib/rules/__tests__/rules.ts | 236 ++++++++++++------------------- 1 file changed, 94 insertions(+), 142 deletions(-) diff --git a/src/lib/rules/__tests__/rules.ts b/src/lib/rules/__tests__/rules.ts index 7d741d8..6e3cffa 100644 --- a/src/lib/rules/__tests__/rules.ts +++ b/src/lib/rules/__tests__/rules.ts @@ -1,4 +1,4 @@ -import { Rule } from '@core/lib/rules'; +import { Predicate, Rule } from '@core/lib/rules'; import { FalsePredicate, TruePredicate } from '@core/lib/rules/predicate'; import { GenrePredicate } from '@core/lib/rules/predicate/genre'; import { CastPredicate } from '@core/lib/rules/predicate/cast'; @@ -10,108 +10,104 @@ import { VoteCountPredicate } from '@core/lib/rules/predicate/voteCount'; import { AgePredicate } from '@core/lib/rules/predicate/age'; import * as movieJson from './movie.json'; import { MovieDetails } from '@core/api/overseerr/interfaces'; +import { AdultPredicate } from '@core/lib/rules/predicate/adult'; const movie = movieJson as MovieDetails; +const testRule = (predicate: Predicate | Predicate[]) => new Rule('test rule', Array.isArray(predicate) ? predicate : [predicate], 'accept'); + +const assertRuleResult = (rule: Rule, matchExpected: boolean, testMovie: MovieDetails = movie) => { + const evaluation = rule.matches(testMovie); + expect(evaluation).toEqual(matchExpected); +}; + +const assertRuleMatches = (rule: Rule, testMovie: MovieDetails = movie) => { + assertRuleResult(rule, true, testMovie); +}; + +const assertRuleDoesntMatch = (rule: Rule, testMovie: MovieDetails = movie) => { + assertRuleResult(rule, false, testMovie); +}; describe('truePredicate', () => { - const rule = new Rule('simple', [new TruePredicate()], 'accept'); + const rule = testRule(new TruePredicate()); it('should match', async () => { - const response = rule.matches(movie); - expect(response).toEqual(true); + assertRuleMatches(rule, movie); }); }); describe('falsePredicate', () => { - const rule = new Rule('simple', [new FalsePredicate()], 'accept'); + const rule = testRule(new FalsePredicate()); - it('should not match', async () => { - const response = rule.matches(movie); - expect(response).toEqual(false); - }); + it('should not match', async () => assertRuleDoesntMatch(rule, movie)); }); describe('defaultPredicate', () => { it('should match when all children match', async () => { - const rule = new Rule('simple', [new TruePredicate(), new TruePredicate()], 'accept'); - - const response = rule.matches(movie); - expect(response).toEqual(true); + const rule = testRule([new TruePredicate(), new TruePredicate()]); + assertRuleMatches(rule, movie); }); it('should not match when any child does not', async () => { - const rule = new Rule('simple', [new TruePredicate(), new FalsePredicate()], 'accept'); - const response = rule.matches(movie); - expect(response).toEqual(false); + const rule = testRule([new TruePredicate(), new FalsePredicate()]); + assertRuleDoesntMatch(rule, movie); }); }); describe('andPredicate', () => { it('should match when all children match', async () => { - const rule = new Rule('simple', [new AndPredicate([new TruePredicate(), new TruePredicate()])], 'accept'); - - const response = rule.matches(movie); - expect(response).toEqual(true); + const rule = testRule([new AndPredicate([new TruePredicate(), new TruePredicate()])]); + assertRuleMatches(rule, movie); }); it('should not match when any child does not', async () => { - const rule = new Rule('simple', [new AndPredicate([new TruePredicate(), new FalsePredicate()])], 'accept'); - const response = rule.matches(movie); - expect(response).toEqual(false); + const rule = testRule([new AndPredicate([new TruePredicate(), new FalsePredicate()])]); + assertRuleDoesntMatch(rule, movie); }); }); describe('orPredicate', () => { it('should match when all children match', async () => { - const rule = new Rule('simple', [new OrPredicate([new TruePredicate(), new TruePredicate()])], 'accept'); - - const response = rule.matches(movie); - expect(response).toEqual(true); + const rule = testRule([new OrPredicate([new TruePredicate(), new TruePredicate()])]); + assertRuleMatches(rule, movie); }); it('should match when at least one child matches', async () => { - const rule = new Rule('simple', [new OrPredicate([new TruePredicate(), new FalsePredicate()])], 'accept'); - const response = rule.matches(movie); - expect(response).toEqual(true); + const rule = testRule([new OrPredicate([new TruePredicate(), new FalsePredicate()])]); + assertRuleMatches(rule, movie); }); it('should not match when no child matches', async () => { - const rule = new Rule('simple', [new OrPredicate([new FalsePredicate(), new FalsePredicate()])], 'accept'); - const response = rule.matches(movie); - expect(response).toEqual(false); + const rule = testRule([new OrPredicate([new FalsePredicate(), new FalsePredicate()])]); + assertRuleDoesntMatch(rule, movie); }); it('should not match when vote count below 8554 or above 8556', async () => { { - const rule = new Rule('simple', [new VoteCountPredicate({ voteCount: 'less than 8554' })], 'accept'); - const response = rule.matches(movie); - expect(response).toEqual(false); + const rule = testRule(new VoteCountPredicate({ voteCount: 'less than 8554' })); + assertRuleDoesntMatch(rule, movie); } { - const rule = new Rule('simple', [new VoteCountPredicate({ voteCount: 'above 8556' })], 'accept'); - const response = rule.matches(movie); - expect(response).toEqual(false); + const rule = testRule(new VoteCountPredicate({ voteCount: 'above 8556' })); + assertRuleDoesntMatch(rule, movie); } }); it('should match when vote count above 8555', async () => { { - const rule = new Rule('simple', [new VoteCountPredicate({ voteCount: 'above 8554' })], 'accept'); - const response = rule.matches(movie); - expect(response).toEqual(true); + const rule = testRule(new VoteCountPredicate({ voteCount: 'above 8554' })); + assertRuleMatches(rule, movie); } { - const rule = new Rule('simple', [new VoteCountPredicate({ voteCount: 'less than 8556' })], 'accept'); - const response = rule.matches(movie); - expect(response).toEqual(true); + const rule = testRule(new VoteCountPredicate({ voteCount: 'less than 8556' })); + assertRuleMatches(rule, movie); } }); it('should match genres', async () => { { - const rule = new Rule('simple', [new GenrePredicate({ genre: 'science-fiction' })], 'accept'); - const response = rule.matches(movie); - expect(response).toEqual(true); + const rule = testRule(new GenrePredicate({ genre: 'science-fiction' })); + assertRuleMatches(rule, movie); } { const rule = new Rule( @@ -123,167 +119,123 @@ describe('orPredicate', () => { ], 'accept' ); - const response = rule.matches(movie); - expect(response).toEqual(true); + assertRuleMatches(rule, movie); } { - const rule = new Rule('simple', [new GenrePredicate({ genre: 'comédie' })], 'accept'); - const response = rule.matches(movie); - expect(response).toEqual(false); + const rule = testRule(new GenrePredicate({ genre: 'comédie' })); + assertRuleDoesntMatch(rule, movie); } }); }); describe('agePredicate', () => { it('should match', async () => { - const rule = new Rule('simple', [new AgePredicate({ age: 'more than 1 year' })], 'accept'); - - const response = rule.matches(movie); - expect(response).toEqual(true); + const rule = testRule(new AgePredicate({ age: 'more than 1 year' })); + assertRuleMatches(rule, movie); }); }); describe('castPredicate', () => { it('should match', async () => { - const rule = new Rule('simple', [new CastPredicate({ cast: ['Sigourney Weaver'] })], 'accept'); - - const response = rule.matches(movie); - expect(response).toEqual(true); + const rule = testRule(new CastPredicate({ cast: ['Sigourney Weaver'] })); + assertRuleMatches(rule, movie); }); it('should match despite different case', async () => { - const rule = new Rule('simple', [new CastPredicate({ cast: ['SiGoUrnEy weAVEr'] })], 'accept'); - - const response = rule.matches(movie); - expect(response).toEqual(true); + const rule = testRule(new CastPredicate({ cast: ['SiGoUrnEy weAVEr'] })); + assertRuleMatches(rule, movie); }); it('should match if at least one cast match', async () => { - const rule = new Rule('simple', [new CastPredicate({ cast: ['Sigourney Weaver', 'Jessica Alba'] })], 'accept'); - - const response = rule.matches(movie); - expect(response).toEqual(true); + const rule = testRule([new CastPredicate({ cast: ['Sigourney Weaver', 'Jessica Alba'] })]); + assertRuleMatches(rule, movie); }); it('should not match', async () => { - const rule = new Rule('simple', [new CastPredicate({ cast: ['Jessica Alba'] })], 'accept'); - - const response = rule.matches(movie); - expect(response).toEqual(false); + const rule = testRule(new CastPredicate({ cast: ['Jessica Alba'] })); + assertRuleDoesntMatch(rule, movie); }); }); describe('crewPredicate', () => { it('should match', async () => { - const rule = new Rule('simple', [new CrewPredicate({ crew: ['James Cameron'] })], 'accept'); - - const response = rule.matches(movie); - expect(response).toEqual(true); + const rule = testRule(new CrewPredicate({ crew: ['James Cameron'] })); + assertRuleMatches(rule, movie); }); it('should match with a job', async () => { - const rule = new Rule( - 'simple', - [ - new CrewPredicate({ - crew: { job: 'director', names: ['James Cameron'] }, - }), - ], - 'accept' + const rule = testRule( + new CrewPredicate({ + crew: { job: 'director', names: ['James Cameron'] }, + }) ); - - const response = rule.matches(movie); - expect(response).toEqual(true); + assertRuleMatches(rule, movie); }); it('should match despite different case', async () => { - const rule = new Rule('simple', [new CrewPredicate({ crew: ['JaMEs CamerOn'] })], 'accept'); + const rule = testRule(new CrewPredicate({ crew: ['JaMEs CamerOn'] })); - const response = rule.matches(movie); - expect(response).toEqual(true); + assertRuleMatches(rule, movie); }); it('should match if at least one crew match', async () => { - const rule = new Rule( - 'simple', - [ - new CrewPredicate({ - crew: { - job: 'Art Direction', - names: ['Bert Davey', 'Ken Court'], - }, - }), - ], - 'accept' + const rule = testRule( + new CrewPredicate({ + crew: { + job: 'Art Direction', + names: ['Bert Davey', 'Ken Court'], + }, + }) ); - const response = rule.matches(movie); - expect(response).toEqual(true); + assertRuleMatches(rule, movie); }); it('should not match', async () => { - const rule = new Rule('simple', [new CrewPredicate({ crew: ['Jessica Alba'] })], 'accept'); - - const response = rule.matches(movie); - expect(response).toEqual(false); + const rule = testRule(new CrewPredicate({ crew: ['Jessica Alba'] })); + assertRuleDoesntMatch(rule, movie); }); it('should not match when person found in another job', async () => { - const rule = new Rule( - 'simple', - [ - new CrewPredicate({ - crew: { job: 'director', names: ['Peter Lamont'] }, - }), - ], - 'accept' + const rule = testRule( + new CrewPredicate({ + crew: { job: 'director', names: ['Peter Lamont'] }, + }) ); - const response = rule.matches(movie); - expect(response).toEqual(false); + assertRuleDoesntMatch(rule, movie); }); }); describe('keywordPredicate', () => { it('should match string', async () => { - const rule = new Rule('simple', [new KeywordPredicate({ keyword: 'space travel' })], 'accept'); + const rule = testRule(new KeywordPredicate({ keyword: 'space travel' })); - const response = rule.matches(movie); - expect(response).toEqual(true); + assertRuleMatches(rule, movie); }); it('should match array', async () => { - const rule = new Rule('simple', [new KeywordPredicate({ keyword: ['space travel'] })], 'accept'); + const rule = testRule(new KeywordPredicate({ keyword: ['space travel'] })); - const response = rule.matches(movie); - expect(response).toEqual(true); + assertRuleMatches(rule, movie); }); it('should match despite different case', async () => { - const rule = new Rule('simple', [new KeywordPredicate({ keyword: ['SpAcE TraVel'] })], 'accept'); + const rule = testRule(new KeywordPredicate({ keyword: ['SpAcE TraVel'] })); - const response = rule.matches(movie); - expect(response).toEqual(true); + assertRuleMatches(rule, movie); }); it('should match if at least one cast match', async () => { - const rule = new Rule( - 'simple', - [ - new KeywordPredicate({ - keyword: ['space travel', 'unknown keyword'], - }), - ], - 'accept' + const rule = testRule( + new KeywordPredicate({ + keyword: ['space travel', 'unknown keyword'], + }) ); - - const response = rule.matches(movie); - expect(response).toEqual(true); + assertRuleMatches(rule, movie); }); it('should not match', async () => { - const rule = new Rule('simple', [new KeywordPredicate({ keyword: ['unknown keyword'] })], 'accept'); - - const response = rule.matches(movie); - expect(response).toEqual(false); + const rule = testRule(new KeywordPredicate({ keyword: ['unknown keyword'] })); + assertRuleDoesntMatch(rule, movie); }); }); From 8133550bd3a68e47679c0a402996c0cef88be502 Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Sat, 24 Aug 2024 10:30:58 +0200 Subject: [PATCH 2/9] feat(predicates): added adult predicate --- README.md | 17 +++++++++++------ schema/schema.json | 10 ++++++++++ src/lib/rules/__tests__/rules.ts | 18 ++++++++++++++++++ src/lib/rules/interfaces.ts | 5 +++++ src/lib/rules/predicate/adult.ts | 21 +++++++++++++++++++++ 5 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 src/lib/rules/predicate/adult.ts diff --git a/README.md b/README.md index 07015ab..9e2de0e 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,17 @@ Filters based on the production companies of the movie. Will match when one or m - Twisted Pictures ``` +--- +### `adult` + +Filters on the adult status of the movie. + +```yaml + - adult: yes + # or + - adult: no +``` + --- ### `or` @@ -305,9 +316,3 @@ Predicate that invert the result of its child predicate ``` --- - - - - - - diff --git a/schema/schema.json b/schema/schema.json index 42fbcfd..c07e4b1 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -95,6 +95,7 @@ { "$ref": "#/definitions/orPredicate" }, { "$ref": "#/definitions/andPredicate" }, { "$ref": "#/definitions/notPredicate" }, + { "$ref": "#/definitions/adultPredicate" }, { "$ref": "#/definitions/agePredicate" }, { "$ref": "#/definitions/scorePredicate" }, { "$ref": "#/definitions/genrePredicate" }, @@ -134,6 +135,15 @@ } }, + "adultPredicate": { + "$id": "#/definitions/adultPredicate", + "type": "object", + "required": ["adult"], + "properties": { + "adult": { "type": "string", "enum": ["yes", "no"]} + } + }, + "agePredicate": { "$id": "#/definitions/agePredicate", "type": "object", diff --git a/src/lib/rules/__tests__/rules.ts b/src/lib/rules/__tests__/rules.ts index 6e3cffa..a22978f 100644 --- a/src/lib/rules/__tests__/rules.ts +++ b/src/lib/rules/__tests__/rules.ts @@ -239,3 +239,21 @@ describe('keywordPredicate', () => { assertRuleDoesntMatch(rule, movie); }); }); + +describe('adult predicate', () => { + it('should match true/false', async () => { + const noRule = testRule(new AdultPredicate({ adult: 'false' })); + const yesRule = testRule(new AdultPredicate({ adult: 'true' })); + + assertRuleMatches(noRule, movie); + assertRuleDoesntMatch(yesRule, movie); + }); + + it('should match yes/no', async () => { + const noRule = testRule(new AdultPredicate({ adult: 'no' })); + const yesRule = testRule(new AdultPredicate({ adult: 'yes' })); + + assertRuleMatches(noRule, movie); + assertRuleDoesntMatch(yesRule, movie); + }); +}); diff --git a/src/lib/rules/interfaces.ts b/src/lib/rules/interfaces.ts index f21abae..5e4cdc3 100644 --- a/src/lib/rules/interfaces.ts +++ b/src/lib/rules/interfaces.ts @@ -16,6 +16,7 @@ export type PredicateOption = | AndFilterOptions | OrFilterOptions | NotFilterOptions + | AdultOptions | AgeOptions | CastOptions | CrewOptions @@ -39,6 +40,10 @@ export interface NotFilterOptions { not: PredicateOption[]; } +export type AdultOptions = { + adult: string; +}; + export type AgeOptions = { age: string; }; diff --git a/src/lib/rules/predicate/adult.ts b/src/lib/rules/predicate/adult.ts new file mode 100644 index 0000000..095fde1 --- /dev/null +++ b/src/lib/rules/predicate/adult.ts @@ -0,0 +1,21 @@ +import { PredicateBuilder } from '@core/lib/rules'; +import { MovieDetails } from '@core/api/overseerr/interfaces'; +import { BooleanPredicate } from '@core/lib/rules/predicate/boolean'; +import { AdultOptions, ReleasedOptions } from '@core/lib/rules/interfaces'; + +export class AdultPredicate extends BooleanPredicate { + constructor(options: AdultOptions) { + super({ + value: options.adult, + }); + } + + matches(movie: MovieDetails): boolean { + return movie.adult == this.targetValue; + } +} + +export const AdultPredicateBuilder: PredicateBuilder = { + key: 'adult', + build: (data: AdultOptions) => new AdultPredicate(data), +}; From b026ffad844ca1fb83e7f20fdfbe1c109ee32b4a Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Sat, 24 Aug 2024 11:05:45 +0200 Subject: [PATCH 3/9] feat(predicates): added runtime predicate --- README.md | 22 ++++++++++++++++++++++ schema/schema.json | 12 ++++++++++++ src/lib/rules/__tests__/rules.ts | 16 ++++++++++++++++ src/lib/rules/interfaces.ts | 5 +++++ src/lib/rules/predicate/adult.ts | 2 +- src/lib/rules/predicate/runtime.ts | 22 ++++++++++++++++++++++ src/lib/rules/predicate/time.ts | 5 +++-- 7 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 src/lib/rules/predicate/runtime.ts diff --git a/README.md b/README.md index 9e2de0e..66dd344 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,8 @@ Filters on the released status of the movie. Filters on the age of the movie. +See [Duration expressions](#duration-expressions) for more details + ```yaml - age: less than 2 years # or @@ -178,6 +180,19 @@ Filters on the score of the movie. - score: below 5.5 ``` +--- +### `runtime` + +Filters on the runtime _(duration)_ of the movie. + +See [Duration expressions](#duration-expressions) for more details + +```yaml + - runtime: less than 2.5 hours + # or + - runtime: more than 120 minutes +``` + --- ### `voteCount` @@ -316,3 +331,10 @@ Predicate that invert the result of its child predicate ``` --- + +### Duration expressions + +Duration expressions, like the one used in the `age` or `runtime` predicate can be expressed in the following way: +- an **operator**: `less than` or `more than` +- a integer or decimal **number**: `2` or `2.5` +- a **unit**: one of the following `year`, `month`, `week`, `day`, `hour`, `minute`. Singular or plural doesn't matter, so `hour` is the same as `hours` diff --git a/schema/schema.json b/schema/schema.json index c07e4b1..5da7463 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -104,6 +104,7 @@ { "$ref": "#/definitions/castPredicate" }, { "$ref": "#/definitions/crewPredicate" }, { "$ref": "#/definitions/releasedPredicate" }, + { "$ref": "#/definitions/runtimePredicate" }, { "$ref": "#/definitions/productionCompanyPredicate" } ] }, @@ -245,6 +246,17 @@ "properties": { "productionCompany": { "type": "array", "items": {"type": "string" }} } + }, + + "runtimePredicate": { + "$id": "#/definitions/runtimePredicate", + "type": "object", + "required": ["runtime"], + "properties": { + "runtime": { "type": "string" } + } } } + // End of definitions + } diff --git a/src/lib/rules/__tests__/rules.ts b/src/lib/rules/__tests__/rules.ts index a22978f..74e2901 100644 --- a/src/lib/rules/__tests__/rules.ts +++ b/src/lib/rules/__tests__/rules.ts @@ -11,6 +11,7 @@ import { AgePredicate } from '@core/lib/rules/predicate/age'; import * as movieJson from './movie.json'; import { MovieDetails } from '@core/api/overseerr/interfaces'; import { AdultPredicate } from '@core/lib/rules/predicate/adult'; +import { RuntimePredicate } from '@core/lib/rules/predicate/runtime'; const movie = movieJson as MovieDetails; const testRule = (predicate: Predicate | Predicate[]) => new Rule('test rule', Array.isArray(predicate) ? predicate : [predicate], 'accept'); @@ -257,3 +258,18 @@ describe('adult predicate', () => { assertRuleDoesntMatch(yesRule, movie); }); }); + +describe('runtime predicate', () => { + it('should match', async () => { + assertRuleMatches(testRule(new RuntimePredicate({ runtime: 'less than 3 hours' })), movie); + assertRuleMatches(testRule(new RuntimePredicate({ runtime: 'less than 2.5 hours' })), movie); + assertRuleMatches(testRule(new RuntimePredicate({ runtime: 'more than 2 hour' })), movie); + assertRuleMatches(testRule(new RuntimePredicate({ runtime: 'more than 2.25 hours' })), movie); + }); + + it('should not match', async () => { + assertRuleDoesntMatch(testRule(new RuntimePredicate({ runtime: 'more than 3 hours' })), movie); + assertRuleDoesntMatch(testRule(new RuntimePredicate({ runtime: 'more than 2.5 weeks' })), movie); + assertRuleDoesntMatch(testRule(new RuntimePredicate({ runtime: 'less than 2 minutes' })), movie); + }); +}); diff --git a/src/lib/rules/interfaces.ts b/src/lib/rules/interfaces.ts index 5e4cdc3..6a97870 100644 --- a/src/lib/rules/interfaces.ts +++ b/src/lib/rules/interfaces.ts @@ -24,6 +24,7 @@ export type PredicateOption = | KeywordOptions | ProductionCompanyOptions | ReleasedOptions + | RuntimeOptions | ScoreOptions | VoteCountOptions | WatchProvidersOptions; @@ -93,3 +94,7 @@ export type CrewJobNamesOptions = { job: string; names: string[]; }; + +export type RuntimeOptions = { + runtime: string; +}; diff --git a/src/lib/rules/predicate/adult.ts b/src/lib/rules/predicate/adult.ts index 095fde1..aac4bcc 100644 --- a/src/lib/rules/predicate/adult.ts +++ b/src/lib/rules/predicate/adult.ts @@ -1,7 +1,7 @@ import { PredicateBuilder } from '@core/lib/rules'; import { MovieDetails } from '@core/api/overseerr/interfaces'; import { BooleanPredicate } from '@core/lib/rules/predicate/boolean'; -import { AdultOptions, ReleasedOptions } from '@core/lib/rules/interfaces'; +import { AdultOptions } from '@core/lib/rules/interfaces'; export class AdultPredicate extends BooleanPredicate { constructor(options: AdultOptions) { diff --git a/src/lib/rules/predicate/runtime.ts b/src/lib/rules/predicate/runtime.ts new file mode 100644 index 0000000..58cc13e --- /dev/null +++ b/src/lib/rules/predicate/runtime.ts @@ -0,0 +1,22 @@ +import { MovieDetails } from '@core/api/overseerr/interfaces'; +import { PredicateBuilder } from '@core/lib/rules'; +import { fromHumanReadableDuration, TimePredicate } from '@core/lib/rules/predicate/time'; +import { RuntimeOptions } from '@core/lib/rules/interfaces'; + +export class RuntimePredicate extends TimePredicate { + constructor(options: RuntimeOptions) { + super(fromHumanReadableDuration(options.runtime), runtimeMetric); + } +} + +const runtimeMetric = (movie: MovieDetails): number => { + if (!movie.runtime) { + return 0; + } + return Number(movie.runtime) * 60; +}; + +export const RuntimePredicateBuilder: PredicateBuilder = { + key: 'runtime', + build: (data: RuntimeOptions) => new RuntimePredicate(data), +}; diff --git a/src/lib/rules/predicate/time.ts b/src/lib/rules/predicate/time.ts index 9526fb5..0555346 100644 --- a/src/lib/rules/predicate/time.ts +++ b/src/lib/rules/predicate/time.ts @@ -13,13 +13,14 @@ export class TimePredicate extends NumberPredicate { } } -export const fromHumanReadableDuration = (str: string): { threshold: number; operator: 'lt' | 'gt' } => { - const regex = /(less than|more than) (\d+(\.\d+)?) (years|year|days|day|month|months|weeks|week)/gm; +export const fromHumanReadableDuration = (str: string): NumberPredicateOptions => { + const regex = /(less than|more than) (\d+(\.\d+)?) (year|month|week|day|hour|minute)[s]?/gm; const groups = [...str.matchAll(regex)][0]; const operator = groups[1]; const threshold: number = Number(groups[2]); const units = groups[4].endsWith('s') ? groups[4] : `${groups[4]}s`; const scale: Record = { + minutes: 60, hours: 60 * 60, days: 24 * 60 * 60, weeks: 7 * 24 * 60 * 60, From b0c70a92cc6a8d75a79e445179db5b3a8e78e19d Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Sat, 24 Aug 2024 11:16:14 +0200 Subject: [PATCH 4/9] fix(predicates): added missing builder in factory --- src/lib/rules/factory.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/rules/factory.ts b/src/lib/rules/factory.ts index c12f3fa..48e8aef 100644 --- a/src/lib/rules/factory.ts +++ b/src/lib/rules/factory.ts @@ -13,6 +13,8 @@ import { KeywordPredicateBuilder } from '@core/lib/rules/predicate/keywords'; import { OrPredicateBuilder } from '@core/lib/rules/predicate/or'; import { AndPredicateBuilder } from '@core/lib/rules/predicate/and'; import { NotPredicateBuilder } from '@core/lib/rules/predicate/not'; +import { AdultPredicateBuilder } from '@core/lib/rules/predicate/adult'; +import { RuntimePredicateBuilder } from '@core/lib/rules/predicate/runtime'; export class PredicateFactoryClass { private builders: Map; @@ -57,6 +59,8 @@ const builders = [ CrewPredicateBuilder, ProductionCompanyPredicateBuilder, ReleasedPredicateBuilder, + RuntimePredicateBuilder, KeywordPredicateBuilder, + AdultPredicateBuilder, ]; builders.forEach((b) => PredicateFactory.registerBuilder(b)); From a908c180bf10c6c999db94bfcd8a296e86d571ed Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Sat, 24 Aug 2024 11:18:13 +0200 Subject: [PATCH 5/9] feat(predicates): added original language predicate --- README.md | 26 +++++++++++++++++---- schema/schema.json | 19 ++++++++++++--- src/lib/rules/__tests__/rules.ts | 13 +++++++++++ src/lib/rules/factory.ts | 2 ++ src/lib/rules/interfaces.ts | 5 ++++ src/lib/rules/predicate/originalLanguage.ts | 25 ++++++++++++++++++++ 6 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 src/lib/rules/predicate/originalLanguage.ts diff --git a/README.md b/README.md index 66dd344..a0d66e9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ CRON based bot that automatically requests movies you may like in Overseer using rules: - name: Acceptable sci-fi movie whenMatch: - - genres: + - genre: - science-fiction - score: above 5 action: accept @@ -213,7 +213,9 @@ Filters on the genre of the movie. Will match when one or more of the listed gen **Case insensitive** ```yaml - - genres: + - genre: musical + # or with an array of values + - genre: - animation - romance ``` @@ -283,6 +285,22 @@ Filters based on the production companies of the movie. Will match when one or m - Twisted Pictures ``` +--- +### `originalLanguage` + +Filters on the original language of the movie + +**Case insensitive** + +```yaml + # ISO 639-1 format of the language (de, au, us, fr...) + - originalLanguage: en + # or with an array of values + - originalLanguage: + - en + - fr +``` + --- ### `adult` @@ -315,7 +333,7 @@ Predicate that will match if all of its predicate matches # Will match if the movie is less than 2 years old AND if the movie genre is 'animation' - and: - age: less than 2 years - - genres: + - genre: - animation ``` @@ -326,7 +344,7 @@ Predicate that invert the result of its child predicate ```yaml - not: - - genres: + - genre: - animation ``` diff --git a/schema/schema.json b/schema/schema.json index 5da7463..5854bc5 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -105,7 +105,8 @@ { "$ref": "#/definitions/crewPredicate" }, { "$ref": "#/definitions/releasedPredicate" }, { "$ref": "#/definitions/runtimePredicate" }, - { "$ref": "#/definitions/productionCompanyPredicate" } + { "$ref": "#/definitions/productionCompanyPredicate" }, + { "$ref": "#/definitions/originalLanguagePredicate" } ] }, @@ -255,8 +256,20 @@ "properties": { "runtime": { "type": "string" } } + }, + + "originalLanguagePredicate": { + "$id": "#/definitions/originalLanguagePredicate", + "type": "object", + "required": ["originalLanguage"], + "properties": { + "originalLanguage": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }} + ] + } + } } } - // End of definitions - } diff --git a/src/lib/rules/__tests__/rules.ts b/src/lib/rules/__tests__/rules.ts index 74e2901..fcf1a86 100644 --- a/src/lib/rules/__tests__/rules.ts +++ b/src/lib/rules/__tests__/rules.ts @@ -12,6 +12,7 @@ import * as movieJson from './movie.json'; import { MovieDetails } from '@core/api/overseerr/interfaces'; import { AdultPredicate } from '@core/lib/rules/predicate/adult'; import { RuntimePredicate } from '@core/lib/rules/predicate/runtime'; +import { OriginalLanguagePredicate } from '@core/lib/rules/predicate/originalLanguage'; const movie = movieJson as MovieDetails; const testRule = (predicate: Predicate | Predicate[]) => new Rule('test rule', Array.isArray(predicate) ? predicate : [predicate], 'accept'); @@ -273,3 +274,15 @@ describe('runtime predicate', () => { assertRuleDoesntMatch(testRule(new RuntimePredicate({ runtime: 'less than 2 minutes' })), movie); }); }); + +describe('originalLanguage predicate', () => { + it('should match', async () => { + assertRuleMatches(testRule(new OriginalLanguagePredicate({ originalLanguage: 'en' })), movie); + assertRuleMatches(testRule(new OriginalLanguagePredicate({ originalLanguage: ['en', 'fr'] })), movie); + }); + + it('should not match', async () => { + assertRuleDoesntMatch(testRule(new OriginalLanguagePredicate({ originalLanguage: 'fr' })), movie); + assertRuleDoesntMatch(testRule(new OriginalLanguagePredicate({ originalLanguage: ['fr', 'de'] })), movie); + }); +}); diff --git a/src/lib/rules/factory.ts b/src/lib/rules/factory.ts index 48e8aef..70b95cb 100644 --- a/src/lib/rules/factory.ts +++ b/src/lib/rules/factory.ts @@ -15,6 +15,7 @@ import { AndPredicateBuilder } from '@core/lib/rules/predicate/and'; import { NotPredicateBuilder } from '@core/lib/rules/predicate/not'; import { AdultPredicateBuilder } from '@core/lib/rules/predicate/adult'; import { RuntimePredicateBuilder } from '@core/lib/rules/predicate/runtime'; +import { OriginalLanguagePredicateBuilder } from '@core/lib/rules/predicate/originalLanguage'; export class PredicateFactoryClass { private builders: Map; @@ -58,6 +59,7 @@ const builders = [ CastPredicateBuilder, CrewPredicateBuilder, ProductionCompanyPredicateBuilder, + OriginalLanguagePredicateBuilder, ReleasedPredicateBuilder, RuntimePredicateBuilder, KeywordPredicateBuilder, diff --git a/src/lib/rules/interfaces.ts b/src/lib/rules/interfaces.ts index 6a97870..be66249 100644 --- a/src/lib/rules/interfaces.ts +++ b/src/lib/rules/interfaces.ts @@ -22,6 +22,7 @@ export type PredicateOption = | CrewOptions | GenreOptions | KeywordOptions + | OriginalLanguageOptions | ProductionCompanyOptions | ReleasedOptions | RuntimeOptions @@ -57,6 +58,10 @@ export type GenreOptions = { genre: string | string[]; }; +export type OriginalLanguageOptions = { + originalLanguage: string | string[]; +}; + export type VoteCountOptions = { voteCount: string; }; diff --git a/src/lib/rules/predicate/originalLanguage.ts b/src/lib/rules/predicate/originalLanguage.ts new file mode 100644 index 0000000..32dc24a --- /dev/null +++ b/src/lib/rules/predicate/originalLanguage.ts @@ -0,0 +1,25 @@ +import { MovieDetails } from '@core/api/overseerr/interfaces'; +import { PredicateBuilder } from '@core/lib/rules'; +import TagsPredicate from '@core/lib/rules/predicate/tag'; +import { OriginalLanguageOptions } from '@core/lib/rules/interfaces'; + +export class OriginalLanguagePredicate extends TagsPredicate { + constructor(options: OriginalLanguageOptions) { + super({ + terms: Array.isArray(options.originalLanguage) ? options.originalLanguage : [options.originalLanguage], + }); + } + + getTags(movie: MovieDetails): string[] { + if (!movie.originalLanguage) { + return []; + } + + return [movie.originalLanguage]; + } +} + +export const OriginalLanguagePredicateBuilder: PredicateBuilder = { + key: 'originalLanguage', + build: (data: OriginalLanguageOptions) => new OriginalLanguagePredicate(data), +}; From 172e964defbf622881be5e2920b9cb28b1bdccce Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Sat, 24 Aug 2024 11:35:46 +0200 Subject: [PATCH 6/9] feat(predicates): added status predicate --- README.md | 19 +++++++++++++++++++ schema/schema.json | 17 ++++++++++++++++- src/lib/rules/__tests__/rules.ts | 13 +++++++++++++ src/lib/rules/factory.ts | 2 ++ src/lib/rules/interfaces.ts | 5 +++++ src/lib/rules/predicate/status.ts | 25 +++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/lib/rules/predicate/status.ts diff --git a/README.md b/README.md index a0d66e9..064a443 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,25 @@ Filters on the original language of the movie ``` --- +### `status` + +Filters on the status of the movie. + +The possible values are the one provided by [TMDB](https://www.themoviedb.org/): `rumored`, `planned`, `in production`, `post production`, `released`, `canceled` + +**Case insensitive** + +```yaml + - status: released + # or with an array of values + - status: + - released + - post production + - planned +``` + +--- + ### `adult` Filters on the adult status of the movie. diff --git a/schema/schema.json b/schema/schema.json index 5854bc5..4ba4aee 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -106,7 +106,8 @@ { "$ref": "#/definitions/releasedPredicate" }, { "$ref": "#/definitions/runtimePredicate" }, { "$ref": "#/definitions/productionCompanyPredicate" }, - { "$ref": "#/definitions/originalLanguagePredicate" } + { "$ref": "#/definitions/originalLanguagePredicate" }, + { "$ref": "#/definitions/statusPredicate" }, ] }, @@ -270,6 +271,20 @@ ] } } + }, + + "statusPredicate": { + "$id": "#/definitions/statusPredicate", + "type": "object", + "required": ["status"], + "properties": { + "status": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }} + ] + } + } } } } diff --git a/src/lib/rules/__tests__/rules.ts b/src/lib/rules/__tests__/rules.ts index fcf1a86..70b479e 100644 --- a/src/lib/rules/__tests__/rules.ts +++ b/src/lib/rules/__tests__/rules.ts @@ -13,6 +13,7 @@ import { MovieDetails } from '@core/api/overseerr/interfaces'; import { AdultPredicate } from '@core/lib/rules/predicate/adult'; import { RuntimePredicate } from '@core/lib/rules/predicate/runtime'; import { OriginalLanguagePredicate } from '@core/lib/rules/predicate/originalLanguage'; +import { StatusPredicate } from '@core/lib/rules/predicate/status'; const movie = movieJson as MovieDetails; const testRule = (predicate: Predicate | Predicate[]) => new Rule('test rule', Array.isArray(predicate) ? predicate : [predicate], 'accept'); @@ -286,3 +287,15 @@ describe('originalLanguage predicate', () => { assertRuleDoesntMatch(testRule(new OriginalLanguagePredicate({ originalLanguage: ['fr', 'de'] })), movie); }); }); + +describe('status predicate', () => { + it('should match', async () => { + assertRuleMatches(testRule(new StatusPredicate({ status: 'released' })), movie); + assertRuleMatches(testRule(new StatusPredicate({ status: ['released', 'post production'] })), movie); + }); + + it('should not match', async () => { + assertRuleDoesntMatch(testRule(new StatusPredicate({ status: 'canceled' })), movie); + assertRuleDoesntMatch(testRule(new StatusPredicate({ status: ['canceled', 'post production'] })), movie); + }); +}); diff --git a/src/lib/rules/factory.ts b/src/lib/rules/factory.ts index 70b95cb..750efe5 100644 --- a/src/lib/rules/factory.ts +++ b/src/lib/rules/factory.ts @@ -16,6 +16,7 @@ import { NotPredicateBuilder } from '@core/lib/rules/predicate/not'; import { AdultPredicateBuilder } from '@core/lib/rules/predicate/adult'; import { RuntimePredicateBuilder } from '@core/lib/rules/predicate/runtime'; import { OriginalLanguagePredicateBuilder } from '@core/lib/rules/predicate/originalLanguage'; +import { StatusPredicateBuilder } from '@core/lib/rules/predicate/status'; export class PredicateFactoryClass { private builders: Map; @@ -62,6 +63,7 @@ const builders = [ OriginalLanguagePredicateBuilder, ReleasedPredicateBuilder, RuntimePredicateBuilder, + StatusPredicateBuilder, KeywordPredicateBuilder, AdultPredicateBuilder, ]; diff --git a/src/lib/rules/interfaces.ts b/src/lib/rules/interfaces.ts index be66249..91de2d1 100644 --- a/src/lib/rules/interfaces.ts +++ b/src/lib/rules/interfaces.ts @@ -27,6 +27,7 @@ export type PredicateOption = | ReleasedOptions | RuntimeOptions | ScoreOptions + | StatusOptions | VoteCountOptions | WatchProvidersOptions; @@ -103,3 +104,7 @@ export type CrewJobNamesOptions = { export type RuntimeOptions = { runtime: string; }; + +export type StatusOptions = { + status: string | string[]; +}; diff --git a/src/lib/rules/predicate/status.ts b/src/lib/rules/predicate/status.ts new file mode 100644 index 0000000..e753a2a --- /dev/null +++ b/src/lib/rules/predicate/status.ts @@ -0,0 +1,25 @@ +import { MovieDetails } from '@core/api/overseerr/interfaces'; +import { PredicateBuilder } from '@core/lib/rules'; +import TagsPredicate from '@core/lib/rules/predicate/tag'; +import { StatusOptions } from '@core/lib/rules/interfaces'; + +export class StatusPredicate extends TagsPredicate { + constructor(options: StatusOptions) { + super({ + terms: Array.isArray(options.status) ? options.status : [options.status], + }); + } + + getTags(movie: MovieDetails): string[] { + if (!movie.status) { + return []; + } + + return [movie.status]; + } +} + +export const StatusPredicateBuilder: PredicateBuilder = { + key: 'status', + build: (data: StatusOptions) => new StatusPredicate(data), +}; From aafe85cddef93a26ca816ff5b19669c1b931606b Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Sat, 24 Aug 2024 11:40:05 +0200 Subject: [PATCH 7/9] fix: fix missing properties --- src/lib/settings.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 9f7b8a0..562aa29 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -62,6 +62,8 @@ class Settings { this._data = { overseerr: { apiUrl: '', + user: '', + password: '', }, rulesets: [], }; From 6874240ac0ca82d4b58b4dcadbc25ed0411d7abe Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Sat, 24 Aug 2024 11:51:27 +0200 Subject: [PATCH 8/9] refactor: sorted builder declaration --- src/lib/rules/factory.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/lib/rules/factory.ts b/src/lib/rules/factory.ts index 750efe5..3aa67ad 100644 --- a/src/lib/rules/factory.ts +++ b/src/lib/rules/factory.ts @@ -49,22 +49,22 @@ export class PredicateFactoryClass { export const PredicateFactory = new PredicateFactoryClass(); const builders = [ - OrPredicateBuilder, - AndPredicateBuilder, - NotPredicateBuilder, + AdultPredicateBuilder, AgePredicateBuilder, - ScorePredicateBuilder, - VoteCountPredicateBuilder, - WatchProvidersPredicateBuilder, - GenrePredicateBuilder, + AndPredicateBuilder, CastPredicateBuilder, CrewPredicateBuilder, - ProductionCompanyPredicateBuilder, + GenrePredicateBuilder, + KeywordPredicateBuilder, + NotPredicateBuilder, OriginalLanguagePredicateBuilder, + OrPredicateBuilder, + ProductionCompanyPredicateBuilder, ReleasedPredicateBuilder, RuntimePredicateBuilder, + ScorePredicateBuilder, StatusPredicateBuilder, - KeywordPredicateBuilder, - AdultPredicateBuilder, + VoteCountPredicateBuilder, + WatchProvidersPredicateBuilder, ]; builders.forEach((b) => PredicateFactory.registerBuilder(b)); From 2b92835e16406ef8eac085eeabc0f2dd8d8fa94c Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Sat, 24 Aug 2024 11:51:45 +0200 Subject: [PATCH 9/9] docs: sorted predicate in alphabetical order --- README.md | 217 +++++++++++++++++++++++++++--------------------------- 1 file changed, 109 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 064a443..6d37e12 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ docker run -d \ -v /path/to/config:/config \ ghcr.io/psyko-gh/overcrawlrr:latest ``` + # Configuration ## Configure Overseerr @@ -145,15 +146,14 @@ When matching: ## Rule predicates ---- -### `released` +### `adult` -Filters on the released status of the movie. +Filters on the adult status of the movie. ```yaml - - released: yes + - adult: yes # or - - released: no + - adult: no ``` --- @@ -170,71 +170,16 @@ See [Duration expressions](#duration-expressions) for more details ``` --- -### `score` +### `and` -Filters on the score of the movie. +Predicate that will match if all of its predicate matches ```yaml - - score: above 6.5 - # or - - score: below 5.5 -``` - ---- -### `runtime` - -Filters on the runtime _(duration)_ of the movie. - -See [Duration expressions](#duration-expressions) for more details - -```yaml - - runtime: less than 2.5 hours - # or - - runtime: more than 120 minutes -``` - ---- -### `voteCount` - -Filters on the vote count of the movie. - - -```yaml - - voteCount: above 1000 - # or - - voteCount: below 100 -``` - ---- -### `genre` - -Filters on the genre of the movie. Will match when one or more of the listed genres matches the genre of the movie. - -**Case insensitive** - -```yaml - - genre: musical - # or with an array of values - - genre: - - animation - - romance -``` - ---- -### `watchProviders` - -Filters based on the available Streaming/VOD platforms. Will match when one or more of the listed provider matches. - -**Case insensitive** - -```yaml - # This predicate will match when the movie is available in Germany on Netflix or Amazon Prime - - watchProviders: - # ISO 3166-1 alpha-2 format of the region (de, au, us, fr...) - - region: de - - names: - - Netflix - - Amazon Prime + # Will match if the movie is less than 2 years old AND if the movie genre is 'animation' + - and: + - age: less than 2 years + - genre: + - animation ``` --- @@ -271,18 +216,31 @@ It is also possible to specify the job - James Cameron - Steven Spielberg ``` ---- -### `productionCompany` -Filters based on the production companies of the movie. Will match when one or more of the listed company matches. +--- +### `genre` + +Filters on the genre of the movie. Will match when one or more of the listed genres matches the genre of the movie. **Case insensitive** ```yaml - - productionCompany: - - 20th Century Fox - - Warner Bros. Pictures - - Twisted Pictures + - genre: musical + # or with an array of values + - genre: + - animation + - romance +``` + +--- +### `not` + +Predicate that invert the result of its child predicate + +```yaml + - not: + - genre: + - animation ``` --- @@ -301,6 +259,67 @@ Filters on the original language of the movie - fr ``` +--- +### `or` + +Predicate that will match if any of its predicate matches + +```yaml + # Will match if the movie is less than 2 years old OR if the movie score is above 8 + - or: + - age: less than 2 years + - score: above 8 +``` + +--- +### `productionCompany` + +Filters based on the production companies of the movie. Will match when one or more of the listed company matches. + +**Case insensitive** + +```yaml + - productionCompany: + - 20th Century Fox + - Warner Bros. Pictures + - Twisted Pictures +``` + +--- +### `released` + +Filters on the released status of the movie. + +```yaml + - released: yes + # or + - released: no +``` + +--- +### `runtime` + +Filters on the runtime _(duration)_ of the movie. + +See [Duration expressions](#duration-expressions) for more details + +```yaml + - runtime: less than 2.5 hours + # or + - runtime: more than 120 minutes +``` + +--- +### `score` + +Filters on the score of the movie. + +```yaml + - score: above 6.5 + # or + - score: below 5.5 +``` + --- ### `status` @@ -320,51 +339,33 @@ The possible values are the one provided by [TMDB](https://www.themoviedb.org/): ``` --- +### `voteCount` -### `adult` +Filters on the vote count of the movie. -Filters on the adult status of the movie. ```yaml - - adult: yes + - voteCount: above 1000 # or - - adult: no + - voteCount: below 100 ``` --- -### `or` -Predicate that will match if any of its predicate matches +### `watchProviders` + +Filters based on the available Streaming/VOD platforms. Will match when one or more of the listed provider matches. + +**Case insensitive** ```yaml - # Will match if the movie is less than 2 years old OR if the movie score is above 8 - - or: - - age: less than 2 years - - score: above 8 -``` - ---- -### `and` - -Predicate that will match if all of its predicate matches - -```yaml - # Will match if the movie is less than 2 years old AND if the movie genre is 'animation' - - and: - - age: less than 2 years - - genre: - - animation -``` - ---- -### `not` - -Predicate that invert the result of its child predicate - -```yaml - - not: - - genre: - - animation + # This predicate will match when the movie is available in Germany on Netflix or Amazon Prime + - watchProviders: + # ISO 3166-1 alpha-2 format of the region (de, au, us, fr...) + - region: de + - names: + - Netflix + - Amazon Prime ``` ---