From b34ac9d06ff46aea742f7eabf53e527ab8b9b79a Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Thu, 29 Aug 2024 21:46:17 +0200 Subject: [PATCH 01/13] fix: display better error when testing connections --- src/api/overseerr/index.ts | 8 ++------ src/api/plex/index.ts | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/api/overseerr/index.ts b/src/api/overseerr/index.ts index 66f1ea6..841768c 100644 --- a/src/api/overseerr/index.ts +++ b/src/api/overseerr/index.ts @@ -2,7 +2,7 @@ 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 { success } from '@core/lib/utils'; +import { getErrorMessage, success } from '@core/lib/utils'; class OverseerrApi extends HttpApi { private overseerrUser?: string; @@ -20,11 +20,7 @@ class OverseerrApi extends HttpApi { await this.auth(); logger.info(success(' Overseerr connection successful')); } catch (e: unknown) { - if (e instanceof Error) { - logger.error(`${e.message}`); - return false; - } - logger.error('Error while testing Overseerr connection. Verify URL and credentials: ', e); + logger.error(`Error while testing Overseerr connection. Verify URL and credentials: ${getErrorMessage(e.message)}`); } }; diff --git a/src/api/plex/index.ts b/src/api/plex/index.ts index 6cef2e0..76b864b 100644 --- a/src/api/plex/index.ts +++ b/src/api/plex/index.ts @@ -4,7 +4,7 @@ import logger from '@core/log'; import { Parser } from 'xml2js'; import type { AxiosRequestConfig } from 'axios'; import { PlexSectionDirectory, PlexSectionsResponse, PlexVideo, PlexVideosResponse } from '@core/api/plex/interfaces'; -import { isFulfilled, success } from '@core/lib/utils'; +import { getErrorMessage, isFulfilled, success } from '@core/lib/utils'; class PlexApi extends HttpApi { private xmlParser; @@ -53,11 +53,7 @@ class PlexApi extends HttpApi { await this.get('/'); logger.info(success(' Plex connection successful')); } catch (e: unknown) { - if (e instanceof Error) { - logger.error(`${e.message}`); - return false; - } - logger.error('Error while testing Plex connection. Verify URL and credentials: ', e); + logger.error(`Error while testing Plex connection. Verify URL and credentials: ${getErrorMessage(e.message)}`); } }; From b3b69ec384e9be0201a126202d62eb25724a665b Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Fri, 30 Aug 2024 19:26:10 +0200 Subject: [PATCH 02/13] fix: fixed score predicate not loading values correctly from settings --- .../rules/__tests__/testFromHumanReadable.ts | 85 +++++++++++++++++++ src/lib/rules/predicate/score.ts | 13 ++- 2 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 src/lib/rules/__tests__/testFromHumanReadable.ts diff --git a/src/lib/rules/__tests__/testFromHumanReadable.ts b/src/lib/rules/__tests__/testFromHumanReadable.ts new file mode 100644 index 0000000..ab94b07 --- /dev/null +++ b/src/lib/rules/__tests__/testFromHumanReadable.ts @@ -0,0 +1,85 @@ +import { fromHumanReadableScore } from '@core/lib/rules/predicate/score'; +import { fromHumanReadableNumber } from '@core/lib/rules/predicate/number'; +import { fromHumanReadableDuration } from '@core/lib/rules/predicate/time'; + +describe('fromHumanReadableNumber', () => { + it('should match', async () => { + expect(fromHumanReadableNumber('above 7')).toEqual({ threshold: 7, operator: 'gt' }); + expect(fromHumanReadableNumber('above 7.0')).toEqual({ threshold: 7.0, operator: 'gt' }); + + expect(fromHumanReadableNumber('greater than 47')).toEqual({ threshold: 47, operator: 'gt' }); + expect(fromHumanReadableNumber('greater than 5768.5')).toEqual({ threshold: 5768.5, operator: 'gt' }); + + expect(fromHumanReadableNumber('below 123')).toEqual({ threshold: 123, operator: 'lt' }); + expect(fromHumanReadableNumber('below 32.25')).toEqual({ threshold: 32.25, operator: 'lt' }); + + expect(fromHumanReadableNumber('under 47')).toEqual({ threshold: 47, operator: 'lt' }); + expect(fromHumanReadableNumber('under 78.9')).toEqual({ threshold: 78.9, operator: 'lt' }); + + expect(fromHumanReadableNumber('less than 1234')).toEqual({ threshold: 1234, operator: 'lt' }); + expect(fromHumanReadableNumber('less than 45.86')).toEqual({ threshold: 45.86, operator: 'lt' }); + }); +}); + +describe('fromHumanReadableScore', () => { + it('should match', async () => { + expect(fromHumanReadableScore('above 7')).toEqual({ threshold: 7, operator: 'gt' }); + expect(fromHumanReadableScore('above 7.0')).toEqual({ threshold: 7, operator: 'gt' }); + expect(fromHumanReadableScore('above 7.05')).toEqual({ threshold: 7.05, operator: 'gt' }); + expect(fromHumanReadableScore('above 7/10')).toEqual({ threshold: 7, operator: 'gt' }); + expect(fromHumanReadableScore('above 7/100')).toEqual({ threshold: 0.7, operator: 'gt' }); + expect(fromHumanReadableScore('above 77.0')).toEqual({ threshold: 77, operator: 'gt' }); + + expect(fromHumanReadableScore('greater than 7')).toEqual({ threshold: 7, operator: 'gt' }); + expect(fromHumanReadableScore('greater than 7.0')).toEqual({ threshold: 7, operator: 'gt' }); + expect(fromHumanReadableScore('greater than 7.05')).toEqual({ threshold: 7.05, operator: 'gt' }); + expect(fromHumanReadableScore('greater than 7/10')).toEqual({ threshold: 7, operator: 'gt' }); + expect(fromHumanReadableScore('greater than 77.0')).toEqual({ threshold: 77, operator: 'gt' }); + + expect(fromHumanReadableScore('below 4')).toEqual({ threshold: 4, operator: 'lt' }); + expect(fromHumanReadableScore('below 5.0')).toEqual({ threshold: 5.0, operator: 'lt' }); + expect(fromHumanReadableScore('below 5.3')).toEqual({ threshold: 5.3, operator: 'lt' }); + expect(fromHumanReadableScore('below 85/100')).toEqual({ threshold: 8.5, operator: 'lt' }); + expect(fromHumanReadableScore('below 77.0')).toEqual({ threshold: 77, operator: 'lt' }); + + // Invalid format. Using numerator + expect(fromHumanReadableScore('below 85/10')).toEqual({ threshold: 85, operator: 'lt' }); + }); +}); + +describe('fromHumanReadableDuration', () => { + it('should match', async () => { + expect(fromHumanReadableDuration('less than 1 minute')).toEqual({ threshold: 60, operator: 'lt' }); + expect(fromHumanReadableDuration('less than 1 minutes')).toEqual({ threshold: 60, operator: 'lt' }); + expect(fromHumanReadableDuration('less than 100 minute')).toEqual({ threshold: 100 * 60, operator: 'lt' }); + + expect(fromHumanReadableDuration('more than 1 minute')).toEqual({ threshold: 60, operator: 'gt' }); + expect(fromHumanReadableDuration('more than 1 minutes')).toEqual({ threshold: 60, operator: 'gt' }); + expect(fromHumanReadableDuration('more than 100 minute')).toEqual({ threshold: 100 * 60, operator: 'gt' }); + + expect(fromHumanReadableDuration('less than 1 hour')).toEqual({ threshold: 60 * 60, operator: 'lt' }); + expect(fromHumanReadableDuration('less than 1 hours')).toEqual({ threshold: 60 * 60, operator: 'lt' }); + expect(fromHumanReadableDuration('more than 2.5 hours')).toEqual({ threshold: 150 * 60, operator: 'gt' }); + expect(fromHumanReadableDuration('less than 2.5 hour')).toEqual({ threshold: 150 * 60, operator: 'lt' }); + + expect(fromHumanReadableDuration('less than 1 day')).toEqual({ threshold: 60 * 60 * 24, operator: 'lt' }); + expect(fromHumanReadableDuration('less than 1 days')).toEqual({ threshold: 60 * 60 * 24, operator: 'lt' }); + expect(fromHumanReadableDuration('more than 2.5 days')).toEqual({ threshold: 60 * 60 * 60, operator: 'gt' }); + expect(fromHumanReadableDuration('less than 2.5 day')).toEqual({ threshold: 60 * 60 * 60, operator: 'lt' }); + + expect(fromHumanReadableDuration('less than 1 week')).toEqual({ threshold: 60 * 60 * 24 * 7, operator: 'lt' }); + expect(fromHumanReadableDuration('less than 1 weeks')).toEqual({ threshold: 60 * 60 * 24 * 7, operator: 'lt' }); + expect(fromHumanReadableDuration('more than 2.5 weeks')).toEqual({ threshold: 60 * 60 * 24 * (7 * 2.5), operator: 'gt' }); + expect(fromHumanReadableDuration('less than 2.5 week')).toEqual({ threshold: 60 * 60 * 24 * (7 * 2.5), operator: 'lt' }); + + expect(fromHumanReadableDuration('less than 1 month')).toEqual({ threshold: 60 * 60 * 24 * 30, operator: 'lt' }); + expect(fromHumanReadableDuration('less than 1 months')).toEqual({ threshold: 60 * 60 * 24 * 30, operator: 'lt' }); + expect(fromHumanReadableDuration('more than 2.5 months')).toEqual({ threshold: 60 * 60 * 24 * 75, operator: 'gt' }); + expect(fromHumanReadableDuration('less than 2.5 month')).toEqual({ threshold: 60 * 60 * 24 * 75, operator: 'lt' }); + + expect(fromHumanReadableDuration('less than 1 year')).toEqual({ threshold: 60 * 60 * 24 * 365, operator: 'lt' }); + expect(fromHumanReadableDuration('less than 1 years')).toEqual({ threshold: 60 * 60 * 24 * 365, operator: 'lt' }); + expect(fromHumanReadableDuration('more than 2.5 years')).toEqual({ threshold: 60 * 60 * 24 * (365 * 2.5), operator: 'gt' }); + expect(fromHumanReadableDuration('less than 2.5 year')).toEqual({ threshold: 60 * 60 * 24 * (365 * 2.5), operator: 'lt' }); + }); +}); diff --git a/src/lib/rules/predicate/score.ts b/src/lib/rules/predicate/score.ts index 4e81317..58d3aa1 100644 --- a/src/lib/rules/predicate/score.ts +++ b/src/lib/rules/predicate/score.ts @@ -11,22 +11,27 @@ export class ScorePredicate extends NumberPredicate { } export const fromHumanReadableScore = (str: string): { threshold: number; operator: 'lt' | 'gt' } => { - const regex = /(below|under|less than|above|greater than) ((?:\d+(\.\d+)|\d+\/\d+)?)/gm; - const ratioRegex = /\d+\/\d+/gm; + const regex = /(below|under|less than|above|greater than) ((?:\d+(\.\d+)?|\d+\/\d+)?)$/gm; + const ratioRegex = /(\d+)\/(\d+)/gm; const groups = [...str.matchAll(regex)][0]; if (!groups) { throw new Error(`Unparsable score string '${str}' !`); } const operator = groups[1]; const thresholdString = groups[2]; - let threshold = 0; + let threshold = Number(thresholdString); const ratioGroups = [...thresholdString.matchAll(ratioRegex)][0]; if (ratioGroups) { const numerator = Number(ratioGroups[1]); const denominator = Number(ratioGroups[2]); + if (numerator > denominator) { + logger.warn( + `Invalid expression: ${str}. In score expression, denominator should be greater than numerator. Using numerator value ${numerator} as threshold` + ); + } if (denominator === 0) { - logger.warning(`In score expression, denominator should not be 0. Using numerator value ${numerator} as threshold`); + logger.warn(`In score expression, denominator should not be 0. Using numerator value ${numerator} as threshold`); threshold = numerator; } else { // Bring the value back in the range of 0..10 with 2 decimals From 840fab73886af74639708f1801cdb8dc349fd5aa Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Fri, 30 Aug 2024 19:26:36 +0200 Subject: [PATCH 03/13] chore: refactor plex & overseerr error logging --- src/api/overseerr/index.ts | 2 +- src/api/plex/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/overseerr/index.ts b/src/api/overseerr/index.ts index 841768c..2545f2d 100644 --- a/src/api/overseerr/index.ts +++ b/src/api/overseerr/index.ts @@ -20,7 +20,7 @@ class OverseerrApi extends HttpApi { await this.auth(); logger.info(success(' Overseerr connection successful')); } catch (e: unknown) { - logger.error(`Error while testing Overseerr connection. Verify URL and credentials: ${getErrorMessage(e.message)}`); + logger.error(`Error while testing Overseerr connection. Verify URL and credentials: ${getErrorMessage(e)}`); } }; diff --git a/src/api/plex/index.ts b/src/api/plex/index.ts index 76b864b..5b35d13 100644 --- a/src/api/plex/index.ts +++ b/src/api/plex/index.ts @@ -53,7 +53,7 @@ class PlexApi extends HttpApi { await this.get('/'); logger.info(success(' Plex connection successful')); } catch (e: unknown) { - logger.error(`Error while testing Plex connection. Verify URL and credentials: ${getErrorMessage(e.message)}`); + logger.error(`Error while testing Plex connection. Verify URL and credentials: ${getErrorMessage(e)}`); } }; From aab3df120db4581d1bfbf97f1bcef40eb49f24b9 Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Fri, 30 Aug 2024 19:51:28 +0200 Subject: [PATCH 04/13] fix: make boolean predicate support boolean true/false --- src/lib/rules/predicate/boolean.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/rules/predicate/boolean.ts b/src/lib/rules/predicate/boolean.ts index 26108ff..6143759 100644 --- a/src/lib/rules/predicate/boolean.ts +++ b/src/lib/rules/predicate/boolean.ts @@ -1,7 +1,7 @@ import { Predicate } from '@core/lib/rules'; export interface BooleanPredicateOptions { - value: string; + value: string | boolean; } export abstract class BooleanPredicate extends Predicate { @@ -9,11 +9,11 @@ export abstract class BooleanPredicate extends Predicate { protected constructor(options: BooleanPredicateOptions) { super(); - this.targetValue = fromHumanReadableBoolean(options.value); + this.targetValue = typeof options.value === 'boolean' ? options.value : fromHumanReadableBoolean(options.value); } } -const fromHumanReadableBoolean = (str: string): boolean => { +export const fromHumanReadableBoolean = (str: string): boolean => { const acceptableTrueBoolean = ['yes', 'true', '1']; return acceptableTrueBoolean.includes(str); }; From 4ce02abb8c4b5976db16ece2cc5881a4148ad952 Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Fri, 30 Aug 2024 19:52:12 +0200 Subject: [PATCH 05/13] test: added human readable parsing units tests --- src/lib/rules/__tests__/testFromHumanReadable.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/lib/rules/__tests__/testFromHumanReadable.ts b/src/lib/rules/__tests__/testFromHumanReadable.ts index ab94b07..e176773 100644 --- a/src/lib/rules/__tests__/testFromHumanReadable.ts +++ b/src/lib/rules/__tests__/testFromHumanReadable.ts @@ -1,6 +1,7 @@ import { fromHumanReadableScore } from '@core/lib/rules/predicate/score'; import { fromHumanReadableNumber } from '@core/lib/rules/predicate/number'; import { fromHumanReadableDuration } from '@core/lib/rules/predicate/time'; +import { fromHumanReadableBoolean } from '@core/lib/rules/predicate/boolean'; describe('fromHumanReadableNumber', () => { it('should match', async () => { @@ -83,3 +84,16 @@ describe('fromHumanReadableDuration', () => { expect(fromHumanReadableDuration('less than 2.5 year')).toEqual({ threshold: 60 * 60 * 24 * (365 * 2.5), operator: 'lt' }); }); }); + +describe('fromHumanReadableBoolean', () => { + it('should match', async () => { + expect(fromHumanReadableBoolean('yes')).toEqual(true); + expect(fromHumanReadableBoolean('true')).toEqual(true); + expect(fromHumanReadableBoolean('1')).toEqual(true); + + expect(fromHumanReadableBoolean('no')).toEqual(false); + expect(fromHumanReadableBoolean('false')).toEqual(false); + expect(fromHumanReadableBoolean('0')).toEqual(false); + expect(fromHumanReadableBoolean('abc')).toEqual(false); + }); +}); From 55e464abeb34282c1fb1d1c81981cda488ea6593 Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Fri, 30 Aug 2024 19:52:26 +0200 Subject: [PATCH 06/13] test: refactor rules units tests --- src/lib/rules/__tests__/{rules.ts => testRules.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/lib/rules/__tests__/{rules.ts => testRules.ts} (100%) diff --git a/src/lib/rules/__tests__/rules.ts b/src/lib/rules/__tests__/testRules.ts similarity index 100% rename from src/lib/rules/__tests__/rules.ts rename to src/lib/rules/__tests__/testRules.ts From cb86e8da207dc88193fe607c2a31e2d622932c29 Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Fri, 30 Aug 2024 19:56:13 +0200 Subject: [PATCH 07/13] chore: refactor JSON schema --- schema/schema.json | 223 +++++++++++++++------------------------------ 1 file changed, 74 insertions(+), 149 deletions(-) diff --git a/schema/schema.json b/schema/schema.json index fdfcee5..2afeafe 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -92,226 +92,151 @@ "predicate": { "$id": "#/definitions/predicate", "type": "object", - "oneOf": [ - { "$ref": "#/definitions/orPredicate" }, - { "$ref": "#/definitions/andPredicate" }, - { "$ref": "#/definitions/notPredicate" }, - { "$ref": "#/definitions/adultPredicate" }, - { "$ref": "#/definitions/agePredicate" }, - { "$ref": "#/definitions/scorePredicate" }, - { "$ref": "#/definitions/genrePredicate" }, - { "$ref": "#/definitions/keywordPredicate" }, - { "$ref": "#/definitions/watchProviderPredicate" }, - { "$ref": "#/definitions/voteCountPredicate" }, - { "$ref": "#/definitions/castPredicate" }, - { "$ref": "#/definitions/crewPredicate" }, - { "$ref": "#/definitions/releasedPredicate" }, - { "$ref": "#/definitions/runtimePredicate" }, - { "$ref": "#/definitions/productionCompanyPredicate" }, - { "$ref": "#/definitions/originalLanguagePredicate" }, - { "$ref": "#/definitions/statusPredicate" } - ] + "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": "object", - "required": ["or"], - "properties": { - "or": { "type": "array", "items": { "$ref": "#/definitions/predicate" }} - } + "type": "array", + "items": { "$ref": "#/definitions/predicate" } }, "andPredicate": { "$id": "#/definitions/andPredicate", - "type": "object", - "required": ["and"], - "properties": { - "and": { "type": "array", "items": { "$ref": "#/definitions/predicate" }} - } + "type": "array", + "items": { "$ref": "#/definitions/predicate" } }, "notPredicate": { "$id": "#/definitions/notPredicate", - "type": "object", - "required": ["not"], - "properties": { - "not": { "type": "array", "items": { "$ref": "#/definitions/predicate" }} - } + "type": "array", + "items": { "$ref": "#/definitions/predicate" } }, "adultPredicate": { "$id": "#/definitions/adultPredicate", - "type": "object", - "required": ["adult"], - "properties": { - "adult": { "type": "string", "enum": ["yes", "no"]} - } + "type": "string", + "enum": ["yes", "no"] }, "agePredicate": { "$id": "#/definitions/agePredicate", - "type": "object", - "required": ["age"], - "properties": { - "age": { "type": "string" } - } + "type": "string" }, "releasedPredicate": { "$id": "#/definitions/releasedPredicate", - "type": "object", - "required": ["released"], - "properties": { - "released": { "type": "string", "enum": ["yes", "no"]} - } + "type": "string", + "enum": ["yes", "no"] }, "scorePredicate": { "$id": "#/definitions/scorePredicate", - "type": "object", - "required": ["score"], - "properties": { - "score": { "type": "string" } - } + "type": "string" }, "voteCountPredicate": { "$id": "#/definitions/voteCountPredicate", - "type": "object", - "required": ["voteCount"], - "properties": { - "voteCount": { "type": "string" } - } + "type": "string" }, "genrePredicate": { "$id": "#/definitions/genrePredicate", - "type": "object", - "required": ["genre"], - "properties": { - "genre": { - "oneOf": [ - { "type": "string" }, - { "type": "array", "items": { "type": "string" }} - ] - } - } + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }} + ] }, "keywordPredicate": { "$id": "#/definitions/keywordPredicate", - "type": "object", - "required": ["keyword"], - "properties": { - "keyword": { - "oneOf": [ - { "type": "string" }, - { "type": "array", "items": { "type": "string" }} - ] - } - } + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }} + ] }, - "watchProviderPredicate": { - "$id": "#/definitions/watchProviderPredicate", + "watchProvidersPredicate": { + "$id": "#/definitions/watchProvidersPredicate", "type": "object", - "required": ["watchProviders"], "properties": { - "watchProviders": { - "type": "object", - "properties": { - "region": { "type": "string"}, - "names": { "type": "array", "items": { "type": "string" }} - } - } + "region": { "type": "string"}, + "names": { "type": "array", "items": { "type": "string" }} } }, "castPredicate": { "$id": "#/definitions/castPredicate", - "type": "object", - "required": ["cast"], - "properties": { - "cast": { - "oneOf": [ - { "type": "array", "items": { "type": "string" }}, - { - "type": "object", - "properties": { - "voice": { "type": "string", "enum": ["include", "exclude"]}, - "names": { "type": "array", "items": {"type": "string" }} - } - } - ] + "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", - "type": "object", - "required": ["crew"], - "properties": { - "crew": { - "oneOf": [ - { "type": "array", "items": { "type": "string" }}, - { - "type": "object", - "properties": { - "job": { "type": "string"}, - "names": { "type": "array", "items": {"type": "string" }} - } - } - ] + "oneOf": [ + { "type": "array", "items": { "type": "string" }}, + { + "type": "object", + "properties": { + "job": { "type": "string"}, + "names": { "type": "array", "items": {"type": "string" }} + } } - } + ] }, "productionCompanyPredicate": { "$id": "#/definitions/productionCompanyPredicate", - "type": "object", - "required": ["productionCompany"], - "properties": { - "productionCompany": { "type": "array", "items": {"type": "string" }} - } + "type": "array", + "items": {"type": "string" } }, "runtimePredicate": { "$id": "#/definitions/runtimePredicate", - "type": "object", - "required": ["runtime"], - "properties": { - "runtime": { "type": "string" } - } + "type": "string" }, "originalLanguagePredicate": { "$id": "#/definitions/originalLanguagePredicate", - "type": "object", - "required": ["originalLanguage"], - "properties": { - "originalLanguage": { - "oneOf": [ - { "type": "string" }, - { "type": "array", "items": { "type": "string" }} - ] - } - } + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }} + ] }, "statusPredicate": { "$id": "#/definitions/statusPredicate", - "type": "object", - "required": ["status"], - "properties": { - "status": { - "oneOf": [ - { "type": "string" }, - { "type": "array", "items": { "type": "string" }} - ] - } - } + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }} + ] } } } From a2d028a84fea2e9bd59b1f63483c8a48556b4ce9 Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Fri, 30 Aug 2024 20:01:12 +0200 Subject: [PATCH 08/13] feat: show human readable configuration errors --- package-lock.json | 60 +++++++++++++++++++++++++++++---------------- package.json | 1 + src/lib/settings.ts | 11 ++++++--- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index a34227d..41ef9c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@types/js-yaml": "^4.0.5", "ajv": "^8.17.1", "axios": "^1.4.0", + "better-ajv-errors": "^1.2.0", "cookie": "^0.5.0", "cors": "^2.8.5", "croner": "^6.0.6", @@ -29,6 +30,7 @@ "@commitlint/cli": "^19.4.0", "@commitlint/config-conventional": "^19.2.2", "@eslint/js": "^9.9.0", + "@semantic-release/github": "^10.1.7", "@types/cookie": "^0.5.1", "@types/cors": "^2.8.13", "@types/eslint__js": "^8.42.3", @@ -65,7 +67,6 @@ "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", - "dev": true, "dependencies": { "@babel/highlight": "^7.24.7", "picocolors": "^1.0.0" @@ -233,7 +234,6 @@ "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -264,7 +264,6 @@ "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", - "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.24.7", "chalk": "^2.4.2", @@ -279,7 +278,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -291,7 +289,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -305,7 +302,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -313,14 +309,12 @@ "node_modules/@babel/highlight/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "node_modules/@babel/highlight/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, "engines": { "node": ">=0.8.0" } @@ -329,7 +323,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, "engines": { "node": ">=4" } @@ -338,7 +331,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -1964,6 +1956,14 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/momoa": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-2.0.4.tgz", + "integrity": "sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==", + "engines": { + "node": ">=10.10.0" + } + }, "node_modules/@humanwhocodes/retry": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", @@ -3801,7 +3801,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -4004,6 +4003,24 @@ "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", "dev": true }, + "node_modules/better-ajv-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/better-ajv-errors/-/better-ajv-errors-1.2.0.tgz", + "integrity": "sha512-UW+IsFycygIo7bclP9h5ugkNH8EjCSgqyFB/yQ4Hqqa1OEYDtb0uFIkYE0b6+CjkgJYVM5UKI/pJPxjYe9EZlA==", + "dependencies": { + "@babel/code-frame": "^7.16.0", + "@humanwhocodes/momoa": "^2.0.2", + "chalk": "^4.1.2", + "jsonpointer": "^5.0.0", + "leven": "^3.1.0 < 4" + }, + "engines": { + "node": ">= 12.13.0" + }, + "peerDependencies": { + "ajv": "4.11.8 - 8" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4193,7 +4210,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4421,7 +4437,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -6335,7 +6350,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -7613,8 +7627,7 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.1.0", @@ -7701,6 +7714,14 @@ "node >= 0.2.0" ] }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/JSONStream": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", @@ -7744,7 +7765,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, "engines": { "node": ">=6" } @@ -11213,8 +11233,7 @@ "node_modules/picocolors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", - "dev": true + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -12577,7 +12596,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, diff --git a/package.json b/package.json index ba6788c..b31d8f3 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@types/js-yaml": "^4.0.5", "ajv": "^8.17.1", "axios": "^1.4.0", + "better-ajv-errors": "^1.2.0", "cookie": "^0.5.0", "cors": "^2.8.5", "croner": "^6.0.6", diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 562aa29..b65e7ac 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -3,6 +3,7 @@ import yaml from 'js-yaml'; import logger from '@core/log'; import { RulesetOptions } from '@core/lib/rules/interfaces'; import Ajv from 'ajv'; +import betterAjvErrors from 'better-ajv-errors'; import envVar from '@core/env'; const SCHEMA_PATH = './schema/schema.json'; @@ -82,11 +83,15 @@ class Settings { try { const yamlSettings = yaml.load(fs.readFileSync(this.path, 'utf8')) as YamlSettings; const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8')); - const validator = new Ajv(); - const isValid = validator.validate(schema, yamlSettings); + const validate = new Ajv().compile(schema); + const isValid = validate(yamlSettings); if (!isValid) { - throw new Error(`Invalid configuration file. ${JSON.stringify(validator.errors)}`); + const output = betterAjvErrors(schema, yamlSettings, validate.errors!, { + indent: 2, + }); + logger.error(output); + throw new Error(`Invalid configuration file.`); } this._data = yamlSettings.config as CrawlrrSettings; From 534b436d8b423d05bc7c6256bca6aef92b3c9a4c Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Fri, 30 Aug 2024 21:48:20 +0200 Subject: [PATCH 09/13] test: rework predicate to make them unit test compliant --- .../rules/__tests__/testPredicateBuilders.ts | 201 ++++++++++++++++++ src/lib/rules/__tests__/testRules.ts | 116 ++++------ src/lib/rules/interfaces.ts | 4 +- src/lib/rules/predicate/adult.ts | 17 +- src/lib/rules/predicate/age.ts | 24 ++- src/lib/rules/predicate/boolean.ts | 10 +- src/lib/rules/predicate/cast.ts | 22 +- src/lib/rules/predicate/crew.ts | 22 +- src/lib/rules/predicate/genre.ts | 17 +- src/lib/rules/predicate/keywords.ts | 17 +- src/lib/rules/predicate/number.ts | 22 +- src/lib/rules/predicate/originalLanguage.ts | 17 +- src/lib/rules/predicate/productionCompany.ts | 17 +- src/lib/rules/predicate/released.ts | 17 +- src/lib/rules/predicate/runtime.ts | 24 ++- src/lib/rules/predicate/score.ts | 14 +- src/lib/rules/predicate/status.ts | 17 +- src/lib/rules/predicate/tag.ts | 6 +- src/lib/rules/predicate/time.ts | 17 +- src/lib/rules/predicate/voteCount.ts | 14 +- src/lib/rules/predicate/watchproviders.ts | 20 +- 21 files changed, 438 insertions(+), 197 deletions(-) create mode 100644 src/lib/rules/__tests__/testPredicateBuilders.ts diff --git a/src/lib/rules/__tests__/testPredicateBuilders.ts b/src/lib/rules/__tests__/testPredicateBuilders.ts new file mode 100644 index 0000000..2072642 --- /dev/null +++ b/src/lib/rules/__tests__/testPredicateBuilders.ts @@ -0,0 +1,201 @@ +import { PredicateFactory } from '@core/lib/rules/factory'; +import yaml from 'js-yaml'; +import { PredicateOption } from '@core/lib/rules/interfaces'; +import { AdultPredicate } from '@core/lib/rules/predicate/adult'; +import { AgePredicate } from '@core/lib/rules/predicate/age'; +import { CastPredicate } from '@core/lib/rules/predicate/cast'; +import { CrewPredicate } from '@core/lib/rules/predicate/crew'; +import { GenrePredicate } from '@core/lib/rules/predicate/genre'; +import { KeywordPredicate } from '@core/lib/rules/predicate/keywords'; +import { OriginalLanguagePredicate } from '@core/lib/rules/predicate/originalLanguage'; +import { ProductionCompanyPredicate } from '@core/lib/rules/predicate/productionCompany'; +import { ReleasedPredicate } from '@core/lib/rules/predicate/released'; +import { RuntimePredicate } from '@core/lib/rules/predicate/runtime'; +import { ScorePredicate } from '@core/lib/rules/predicate/score'; +import { StatusPredicate } from '@core/lib/rules/predicate/status'; +import { VoteCountPredicate } from '@core/lib/rules/predicate/voteCount'; +import { WatchprovidersPredicate } from '@core/lib/rules/predicate/watchproviders'; + +const from = (data: string) => { + const options = yaml.load(data) as PredicateOption; + const [head] = PredicateFactory.buildPredicates([options]); + return head; +}; + +const expectPredicate = (data: string) => { + return expect(from(data)); +}; + +describe('AdultPredicateBuilder', () => { + it('should match', async () => { + expectPredicate(`adult: yes`).toEqual(new AdultPredicate({ value: true })); + expectPredicate(`adult: true`).toEqual(new AdultPredicate({ value: true })); + + expectPredicate(`adult: no`).toEqual(new AdultPredicate({ value: false })); + expectPredicate(`adult: false`).toEqual(new AdultPredicate({ value: false })); + }); +}); + +describe('AgePredicateBuilder', () => { + it('should match', async () => { + expectPredicate(`age: less than 1 minute`).toEqual(new AgePredicate({ operator: 'lt', threshold: 60 })); + expectPredicate(`age: less than 1 minutes`).toEqual(new AgePredicate({ operator: 'lt', threshold: 60 })); + expectPredicate(`age: more than 1 minute`).toEqual(new AgePredicate({ operator: 'gt', threshold: 60 })); + expectPredicate(`age: more than 1 minutes`).toEqual(new AgePredicate({ operator: 'gt', threshold: 60 })); + }); +}); + +describe('CastPredicateBuilder', () => { + it('should match', async () => { + expectPredicate(` + cast: + - Korben Dallas + - Dumbo + `).toEqual(new CastPredicate({ terms: ['Korben Dallas', 'Dumbo'], excludeVoice: false })); + expectPredicate(` + cast: + voice: exclude + names: + - Korben Dallas + - Dumbo + `).toEqual(new CastPredicate({ terms: ['Korben Dallas', 'Dumbo'], excludeVoice: true })); + }); +}); + +describe('CrewPredicateBuilder', () => { + it('should match', async () => { + expectPredicate(` + crew: + - Korben Dallas + - Dumbo + `).toEqual(new CrewPredicate({ terms: ['Korben Dallas', 'Dumbo'], job: undefined })); + expectPredicate(` + crew: + job: dummy + names: + - Korben Dallas + - Dumbo + `).toEqual(new CrewPredicate({ terms: ['Korben Dallas', 'Dumbo'], job: 'dummy' })); + }); +}); + +describe('GenrePredicateBuilder', () => { + it('should match', async () => { + expectPredicate(` + genre: Comedy + `).toEqual(new GenrePredicate({ terms: ['Comedy'] })); + expectPredicate(` + genre: + - Comedy + - Romance + `).toEqual(new GenrePredicate({ terms: ['Comedy', 'Romance'] })); + }); +}); + +describe('KeywordPredicateBuilder', () => { + it('should match', async () => { + expectPredicate(` + keyword: Comedy + `).toEqual(new KeywordPredicate({ terms: ['Comedy'] })); + expectPredicate(` + keyword: + - space + - futuristic + `).toEqual(new GenrePredicate({ terms: ['space', 'futuristic'] })); + }); +}); + +describe('OriginalLanguagePredicateBuilder', () => { + it('should match', async () => { + expectPredicate(` + originalLanguage: en + `).toEqual(new OriginalLanguagePredicate({ terms: ['en'] })); + expectPredicate(` + originalLanguage: + - de + - en + `).toEqual(new OriginalLanguagePredicate({ terms: ['de', 'en'] })); + }); +}); + +describe('ProductionCompanyPredicateBuilder', () => { + it('should match', async () => { + expectPredicate(` + productionCompany: Warner Bros. + `).toEqual(new ProductionCompanyPredicate({ terms: ['Warner Bros.'] })); + expectPredicate(` + productionCompany: + - Warner Bros. + - 20th Century Fox + `).toEqual(new ProductionCompanyPredicate({ terms: ['Warner Bros.', '20th Century Fox'] })); + }); +}); + +describe('ReleasedPredicateBuilder', () => { + it('should match', async () => { + expectPredicate(`released: yes`).toEqual(new ReleasedPredicate({ value: true })); + expectPredicate(`released: true`).toEqual(new ReleasedPredicate({ value: true })); + + expectPredicate(`released: no`).toEqual(new ReleasedPredicate({ value: false })); + expectPredicate(`released: false`).toEqual(new ReleasedPredicate({ value: false })); + }); +}); + +describe('RuntimePredicateBuilder', () => { + it('should match', async () => { + expectPredicate(`runtime: less than 1 hour`).toEqual(new RuntimePredicate({ operator: 'lt', threshold: 60 * 60 })); + expectPredicate(`runtime: less than 1 hours`).toEqual(new RuntimePredicate({ operator: 'lt', threshold: 60 * 60 })); + + expectPredicate(`runtime: more than 3 hour`).toEqual(new RuntimePredicate({ operator: 'gt', threshold: 3 * 60 * 60 })); + expectPredicate(`runtime: more than 3 hours`).toEqual(new RuntimePredicate({ operator: 'gt', threshold: 3 * 60 * 60 })); + }); +}); + +describe('ScorePredicateBuilder', () => { + it('should match', async () => { + expectPredicate(`score: above 8`).toEqual(new ScorePredicate({ operator: 'gt', threshold: 8 })); + expectPredicate(`score: above 8.4`).toEqual(new ScorePredicate({ operator: 'gt', threshold: 8.4 })); + expectPredicate(`score: above 85/100`).toEqual(new ScorePredicate({ operator: 'gt', threshold: 8.5 })); + + expectPredicate(`score: below 8`).toEqual(new ScorePredicate({ operator: 'lt', threshold: 8 })); + expectPredicate(`score: below 8.4`).toEqual(new ScorePredicate({ operator: 'lt', threshold: 8.4 })); + expectPredicate(`score: below 85/100`).toEqual(new ScorePredicate({ operator: 'lt', threshold: 8.5 })); + }); +}); + +describe('StatusPredicateBuilder', () => { + it('should match', async () => { + expectPredicate(` + status: status 1 + `).toEqual(new StatusPredicate({ terms: ['status 1'] })); + expectPredicate(` + status: + - status 1 + - status 2 + `).toEqual(new StatusPredicate({ terms: ['status 1', 'status 2'] })); + }); +}); + +describe('VoteCountPredicateBuilder', () => { + it('should match', async () => { + expectPredicate(`voteCount: greater than 1000`).toEqual(new VoteCountPredicate({ operator: 'gt', threshold: 1000 })); + expectPredicate(`voteCount: above 84`).toEqual(new ScorePredicate({ operator: 'gt', threshold: 84 })); + expectPredicate(`voteCount: above 84.32`).toEqual(new ScorePredicate({ operator: 'gt', threshold: 84.32 })); + + expectPredicate(`voteCount: below 8`).toEqual(new ScorePredicate({ operator: 'lt', threshold: 8 })); + expectPredicate(`voteCount: under 85`).toEqual(new ScorePredicate({ operator: 'lt', threshold: 85 })); + expectPredicate(`voteCount: less than 100`).toEqual(new ScorePredicate({ operator: 'lt', threshold: 100 })); + }); +}); + +describe('WatchProvidersPredicateBuilder', () => { + it('should match', async () => { + expectPredicate(` + watchProviders: + region: us + names: + - Platform 1 + - Platform 2 + `).toEqual(new WatchprovidersPredicate({ terms: ['Platform 1', 'Platform 2'], region: 'us' })); + }); +}); diff --git a/src/lib/rules/__tests__/testRules.ts b/src/lib/rules/__tests__/testRules.ts index 47521f7..79cb1e9 100644 --- a/src/lib/rules/__tests__/testRules.ts +++ b/src/lib/rules/__tests__/testRules.ts @@ -14,6 +14,7 @@ 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'; +import { fromHumanReadableDuration } from '@core/lib/rules/predicate/time'; const movie = movieJson as MovieDetails; const testRule = (predicate: Predicate | Predicate[]) => new Rule('test rule', Array.isArray(predicate) ? predicate : [predicate], 'accept'); @@ -87,29 +88,29 @@ describe('orPredicate', () => { it('should not match when vote count below 8554 or above 8556', async () => { { - const rule = testRule(new VoteCountPredicate({ voteCount: 'less than 8554' })); + const rule = testRule(new VoteCountPredicate({ operator: 'lt', threshold: 8554 })); assertRuleDoesntMatch(rule, movie); } { - const rule = testRule(new VoteCountPredicate({ voteCount: 'above 8556' })); + const rule = testRule(new VoteCountPredicate({ operator: 'gt', threshold: 8556 })); assertRuleDoesntMatch(rule, movie); } }); it('should match when vote count above 8555', async () => { { - const rule = testRule(new VoteCountPredicate({ voteCount: 'above 8554' })); + const rule = testRule(new VoteCountPredicate({ operator: 'gt', threshold: 8554 })); assertRuleMatches(rule, movie); } { - const rule = testRule(new VoteCountPredicate({ voteCount: 'less than 8556' })); + const rule = testRule(new VoteCountPredicate({ operator: 'lt', threshold: 8556 })); assertRuleMatches(rule, movie); } }); it('should match genres', async () => { { - const rule = testRule(new GenrePredicate({ genre: 'science-fiction' })); + const rule = testRule(new GenrePredicate({ terms: ['science-fiction'] })); assertRuleMatches(rule, movie); } { @@ -117,7 +118,7 @@ describe('orPredicate', () => { 'simple', [ new GenrePredicate({ - genre: ['science-fiction', 'comédie'], + terms: ['science-fiction', 'comédie'], }), ], 'accept' @@ -125,7 +126,7 @@ describe('orPredicate', () => { assertRuleMatches(rule, movie); } { - const rule = testRule(new GenrePredicate({ genre: 'comédie' })); + const rule = testRule(new GenrePredicate({ terms: ['comédie'] })); assertRuleDoesntMatch(rule, movie); } }); @@ -133,54 +134,48 @@ describe('orPredicate', () => { describe('agePredicate', () => { it('should match', async () => { - const rule = testRule(new AgePredicate({ age: 'more than 1 year' })); + const rule = testRule(new AgePredicate(fromHumanReadableDuration('more than 1 year'))); assertRuleMatches(rule, movie); }); }); describe('castPredicate', () => { it('should match', async () => { - const rule = testRule(new CastPredicate({ cast: ['Sigourney Weaver'] })); + const rule = testRule(new CastPredicate({ terms: ['Sigourney Weaver'], excludeVoice: false })); assertRuleMatches(rule, movie); }); it('should match despite different case', async () => { - const rule = testRule(new CastPredicate({ cast: ['SiGoUrnEy weAVEr'] })); + const rule = testRule(new CastPredicate({ terms: ['SiGoUrnEy weAVEr'], excludeVoice: false })); assertRuleMatches(rule, movie); }); it('should match if at least one cast match', async () => { - const rule = testRule([new CastPredicate({ cast: ['Sigourney Weaver', 'Jessica Alba'] })]); + const rule = testRule([new CastPredicate({ terms: ['Sigourney Weaver', 'Jessica Alba'], excludeVoice: false })]); assertRuleMatches(rule, movie); }); it('should not match', async () => { - const rule = testRule(new CastPredicate({ cast: ['Jessica Alba'] })); + const rule = testRule(new CastPredicate({ terms: ['Jessica Alba'], excludeVoice: false })); assertRuleDoesntMatch(rule, movie); }); it('should match with voice field', async () => { const ruleIncludeVoice = testRule( new CastPredicate({ - cast: { - voice: 'include', - names: ['Bob Sherman'], - }, + terms: ['Bob Sherman'], + excludeVoice: false, }) ); const ruleExcludeVoice = testRule( new CastPredicate({ - cast: { - voice: 'exclude', - names: ['Bob Sherman'], - }, + terms: ['Bob Sherman'], + excludeVoice: true, }) ); const ruleExcludeVoice2 = testRule( new CastPredicate({ - cast: { - voice: 'exclude', - names: ['Bob Sherman', 'Sigourney Weaver'], - }, + terms: ['Bob Sherman', 'Sigourney Weaver'], + excludeVoice: true, }) ); assertRuleMatches(ruleIncludeVoice, movie); @@ -191,21 +186,22 @@ describe('castPredicate', () => { describe('crewPredicate', () => { it('should match', async () => { - const rule = testRule(new CrewPredicate({ crew: ['James Cameron'] })); + const rule = testRule(new CrewPredicate({ terms: ['James Cameron'] })); assertRuleMatches(rule, movie); }); it('should match with a job', async () => { const rule = testRule( new CrewPredicate({ - crew: { job: 'director', names: ['James Cameron'] }, + job: 'director', + terms: ['James Cameron'], }) ); assertRuleMatches(rule, movie); }); it('should match despite different case', async () => { - const rule = testRule(new CrewPredicate({ crew: ['JaMEs CamerOn'] })); + const rule = testRule(new CrewPredicate({ terms: ['JaMEs CamerOn'] })); assertRuleMatches(rule, movie); }); @@ -213,10 +209,8 @@ describe('crewPredicate', () => { it('should match if at least one crew match', async () => { const rule = testRule( new CrewPredicate({ - crew: { - job: 'Art Direction', - names: ['Bert Davey', 'Ken Court'], - }, + job: 'Art Direction', + terms: ['Bert Davey', 'Ken Court'], }) ); @@ -224,14 +218,15 @@ describe('crewPredicate', () => { }); it('should not match', async () => { - const rule = testRule(new CrewPredicate({ crew: ['Jessica Alba'] })); + const rule = testRule(new CrewPredicate({ terms: ['Jessica Alba'] })); assertRuleDoesntMatch(rule, movie); }); it('should not match when person found in another job', async () => { const rule = testRule( new CrewPredicate({ - crew: { job: 'director', names: ['Peter Lamont'] }, + job: 'director', + terms: ['Peter Lamont'], }) ); @@ -240,20 +235,14 @@ describe('crewPredicate', () => { }); describe('keywordPredicate', () => { - it('should match string', async () => { - const rule = testRule(new KeywordPredicate({ keyword: 'space travel' })); - - assertRuleMatches(rule, movie); - }); - - it('should match array', async () => { - const rule = testRule(new KeywordPredicate({ keyword: ['space travel'] })); + it('should match', async () => { + const rule = testRule(new KeywordPredicate({ terms: ['space travel'] })); assertRuleMatches(rule, movie); }); it('should match despite different case', async () => { - const rule = testRule(new KeywordPredicate({ keyword: ['SpAcE TraVel'] })); + const rule = testRule(new KeywordPredicate({ terms: ['SpAcE TraVel'] })); assertRuleMatches(rule, movie); }); @@ -261,30 +250,22 @@ describe('keywordPredicate', () => { it('should match if at least one cast match', async () => { const rule = testRule( new KeywordPredicate({ - keyword: ['space travel', 'unknown keyword'], + terms: ['space travel', 'unknown keyword'], }) ); assertRuleMatches(rule, movie); }); it('should not match', async () => { - const rule = testRule(new KeywordPredicate({ keyword: ['unknown keyword'] })); + const rule = testRule(new KeywordPredicate({ terms: ['unknown keyword'] })); 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' })); + const noRule = testRule(new AdultPredicate({ value: false })); + const yesRule = testRule(new AdultPredicate({ value: true })); assertRuleMatches(noRule, movie); assertRuleDoesntMatch(yesRule, movie); @@ -293,39 +274,36 @@ describe('adult predicate', () => { 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); + assertRuleMatches(testRule(new RuntimePredicate({ operator: 'lt', threshold: 3 * 60 * 60 })), movie); + assertRuleMatches(testRule(new RuntimePredicate({ operator: 'gt', threshold: 2 * 60 * 60 })), 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); + assertRuleDoesntMatch(testRule(new RuntimePredicate({ operator: 'gt', threshold: 3 * 60 * 60 })), movie); + assertRuleDoesntMatch(testRule(new RuntimePredicate({ operator: 'lt', threshold: 2 * 60 * 60 })), movie); }); }); describe('originalLanguage predicate', () => { it('should match', async () => { - assertRuleMatches(testRule(new OriginalLanguagePredicate({ originalLanguage: 'en' })), movie); - assertRuleMatches(testRule(new OriginalLanguagePredicate({ originalLanguage: ['en', 'fr'] })), movie); + assertRuleMatches(testRule(new OriginalLanguagePredicate({ terms: ['en'] })), movie); + assertRuleMatches(testRule(new OriginalLanguagePredicate({ terms: ['en', 'fr'] })), movie); }); it('should not match', async () => { - assertRuleDoesntMatch(testRule(new OriginalLanguagePredicate({ originalLanguage: 'fr' })), movie); - assertRuleDoesntMatch(testRule(new OriginalLanguagePredicate({ originalLanguage: ['fr', 'de'] })), movie); + assertRuleDoesntMatch(testRule(new OriginalLanguagePredicate({ terms: ['fr'] })), movie); + assertRuleDoesntMatch(testRule(new OriginalLanguagePredicate({ terms: ['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); + assertRuleMatches(testRule(new StatusPredicate({ terms: ['released'] })), movie); + assertRuleMatches(testRule(new StatusPredicate({ terms: ['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); + assertRuleDoesntMatch(testRule(new StatusPredicate({ terms: ['canceled'] })), movie); + assertRuleDoesntMatch(testRule(new StatusPredicate({ terms: ['canceled', 'post production'] })), movie); }); }); diff --git a/src/lib/rules/interfaces.ts b/src/lib/rules/interfaces.ts index a4209a9..24d360c 100644 --- a/src/lib/rules/interfaces.ts +++ b/src/lib/rules/interfaces.ts @@ -44,7 +44,7 @@ export interface NotFilterOptions { } export type AdultOptions = { - adult: string; + adult: string | boolean; }; export type AgeOptions = { @@ -81,7 +81,7 @@ export type ProductionCompanyOptions = { }; export type ReleasedOptions = { - released: string; + released: string | boolean; }; export type ScoreOptions = { diff --git a/src/lib/rules/predicate/adult.ts b/src/lib/rules/predicate/adult.ts index aac4bcc..8176583 100644 --- a/src/lib/rules/predicate/adult.ts +++ b/src/lib/rules/predicate/adult.ts @@ -1,13 +1,13 @@ import { PredicateBuilder } from '@core/lib/rules'; import { MovieDetails } from '@core/api/overseerr/interfaces'; -import { BooleanPredicate } from '@core/lib/rules/predicate/boolean'; +import { BooleanPredicate, BooleanPredicateParameters, fromHumanReadableBoolean } from '@core/lib/rules/predicate/boolean'; import { AdultOptions } from '@core/lib/rules/interfaces'; +export type AdultPredicateParameters = BooleanPredicateParameters; + export class AdultPredicate extends BooleanPredicate { - constructor(options: AdultOptions) { - super({ - value: options.adult, - }); + constructor(options: AdultPredicateParameters) { + super(options); } matches(movie: MovieDetails): boolean { @@ -17,5 +17,10 @@ export class AdultPredicate extends BooleanPredicate { export const AdultPredicateBuilder: PredicateBuilder = { key: 'adult', - build: (data: AdultOptions) => new AdultPredicate(data), + build: (data: AdultOptions) => { + const parameters = { + value: typeof data.adult === 'boolean' ? data.adult : fromHumanReadableBoolean(data.adult), + }; + return new AdultPredicate(parameters); + }, }; diff --git a/src/lib/rules/predicate/age.ts b/src/lib/rules/predicate/age.ts index d175793..d34a421 100644 --- a/src/lib/rules/predicate/age.ts +++ b/src/lib/rules/predicate/age.ts @@ -1,22 +1,24 @@ import { MovieDetails } from '@core/api/overseerr/interfaces'; import { PredicateBuilder } from '@core/lib/rules'; -import { fromHumanReadableDuration, TimePredicate } from '@core/lib/rules/predicate/time'; +import { fromHumanReadableDuration, TimePredicate, TimePredicateParameters } from '@core/lib/rules/predicate/time'; import { AgeOptions } from '@core/lib/rules/interfaces'; +export type AgePredicateParameters = TimePredicateParameters; + export class AgePredicate extends TimePredicate { - constructor(options: AgeOptions) { - super(fromHumanReadableDuration(options.age), ageMetric); + constructor(options: AgePredicateParameters) { + super(options); + } + + protected getMetrics(movie: MovieDetails): number { + if (!movie.releaseDate) { + return 0; + } + return (Date.now() - Date.parse(movie.releaseDate)) / 1000; } } -const ageMetric = (movie: MovieDetails): number => { - if (!movie.releaseDate) { - return 0; - } - return (Date.now() - Date.parse(movie.releaseDate)) / 1000; -}; - export const AgePredicateBuilder: PredicateBuilder = { key: 'age', - build: (data: AgeOptions) => new AgePredicate(data), + build: (data: AgeOptions) => new AgePredicate(fromHumanReadableDuration(data.age)), }; diff --git a/src/lib/rules/predicate/boolean.ts b/src/lib/rules/predicate/boolean.ts index 6143759..6aaad15 100644 --- a/src/lib/rules/predicate/boolean.ts +++ b/src/lib/rules/predicate/boolean.ts @@ -1,19 +1,19 @@ import { Predicate } from '@core/lib/rules'; -export interface BooleanPredicateOptions { - value: string | boolean; +export interface BooleanPredicateParameters { + value: boolean; } export abstract class BooleanPredicate extends Predicate { protected targetValue: boolean; - protected constructor(options: BooleanPredicateOptions) { + protected constructor(options: BooleanPredicateParameters) { super(); - this.targetValue = typeof options.value === 'boolean' ? options.value : fromHumanReadableBoolean(options.value); + this.targetValue = options.value; } } export const fromHumanReadableBoolean = (str: string): boolean => { - const acceptableTrueBoolean = ['yes', 'true', '1']; + const acceptableTrueBoolean = ['yes', 'true']; return acceptableTrueBoolean.includes(str); }; diff --git a/src/lib/rules/predicate/cast.ts b/src/lib/rules/predicate/cast.ts index bec9979..7b5aa44 100644 --- a/src/lib/rules/predicate/cast.ts +++ b/src/lib/rules/predicate/cast.ts @@ -1,16 +1,18 @@ import { MovieDetails } from '@core/api/overseerr/interfaces'; -import TagsPredicate from '@core/lib/rules/predicate/tag'; import { PredicateBuilder } from '@core/lib/rules'; import { CastOptions } from '@core/lib/rules/interfaces'; +import { TagsPredicate, TagsPredicateParameters } from '@core/lib/rules/predicate/tag'; + +export type CastPredicateParameters = TagsPredicateParameters & { + excludeVoice: boolean; +}; export class CastPredicate extends TagsPredicate { private excludeVoice: boolean = false; - constructor(options: CastOptions) { - super({ - terms: Array.isArray(options.cast) ? options.cast : options.cast.names, - }); - this.excludeVoice = Array.isArray(options.cast) ? false : options.cast.voice?.toLowerCase() === 'exclude'; + constructor(options: CastPredicateParameters) { + super(options); + this.excludeVoice = options.excludeVoice; } getTags(movie: MovieDetails): string[] { @@ -29,5 +31,11 @@ export class CastPredicate extends TagsPredicate { export const CastPredicateBuilder: PredicateBuilder = { key: 'cast', - build: (data: CastOptions) => new CastPredicate(data), + build: (data: CastOptions) => { + const parameters: CastPredicateParameters = { + terms: Array.isArray(data.cast) ? data.cast : data.cast.names, + excludeVoice: Array.isArray(data.cast) ? false : data.cast.voice?.toLowerCase() === 'exclude', + }; + return new CastPredicate(parameters); + }, }; diff --git a/src/lib/rules/predicate/crew.ts b/src/lib/rules/predicate/crew.ts index 521ee65..bb8eb66 100644 --- a/src/lib/rules/predicate/crew.ts +++ b/src/lib/rules/predicate/crew.ts @@ -1,16 +1,18 @@ import { MovieDetails } from '@core/api/overseerr/interfaces'; import { PredicateBuilder } from '@core/lib/rules'; -import TagsPredicate from '@core/lib/rules/predicate/tag'; +import { TagsPredicate, TagsPredicateParameters } from '@core/lib/rules/predicate/tag'; import { CrewOptions } from '@core/lib/rules/interfaces'; +export type CrewPredicateParameters = TagsPredicateParameters & { + job?: string; +}; + export class CrewPredicate extends TagsPredicate { private job?: string; - constructor(options: CrewOptions) { - super({ - terms: Array.isArray(options.crew) ? options.crew : options.crew.names, - }); - this.job = Array.isArray(options.crew) ? undefined : options.crew.job; + constructor(parameters: CrewPredicateParameters) { + super(parameters); + this.job = parameters.job; } getTags(movie: MovieDetails): string[] { @@ -29,5 +31,11 @@ export class CrewPredicate extends TagsPredicate { export const CrewPredicateBuilder: PredicateBuilder = { key: 'crew', - build: (data: CrewOptions) => new CrewPredicate(data), + build: (data: CrewOptions) => { + const parameters = { + terms: Array.isArray(data.crew) ? data.crew : data.crew.names, + job: Array.isArray(data.crew) ? undefined : data.crew.job, + }; + return new CrewPredicate(parameters); + }, }; diff --git a/src/lib/rules/predicate/genre.ts b/src/lib/rules/predicate/genre.ts index 817b9d4..1372121 100644 --- a/src/lib/rules/predicate/genre.ts +++ b/src/lib/rules/predicate/genre.ts @@ -1,13 +1,13 @@ import { MovieDetails } from '@core/api/overseerr/interfaces'; import { PredicateBuilder } from '@core/lib/rules'; -import TagsPredicate from '@core/lib/rules/predicate/tag'; +import { TagsPredicate, TagsPredicateParameters } from '@core/lib/rules/predicate/tag'; import { GenreOptions } from '@core/lib/rules/interfaces'; +export type GenrePredicateParameters = TagsPredicateParameters; + export class GenrePredicate extends TagsPredicate { - constructor(options: GenreOptions) { - super({ - terms: Array.isArray(options.genre) ? options.genre : [options.genre], - }); + constructor(options: GenrePredicateParameters) { + super(options); } getTags(movie: MovieDetails): string[] { @@ -21,5 +21,10 @@ export class GenrePredicate extends TagsPredicate { export const GenrePredicateBuilder: PredicateBuilder = { key: 'genre', - build: (data: GenreOptions) => new GenrePredicate(data), + build: (data: GenreOptions) => { + const parameters = { + terms: Array.isArray(data.genre) ? data.genre : [data.genre], + }; + return new GenrePredicate(parameters); + }, }; diff --git a/src/lib/rules/predicate/keywords.ts b/src/lib/rules/predicate/keywords.ts index 47f21f4..d7ec861 100644 --- a/src/lib/rules/predicate/keywords.ts +++ b/src/lib/rules/predicate/keywords.ts @@ -1,13 +1,13 @@ import { MovieDetails } from '@core/api/overseerr/interfaces'; import { PredicateBuilder } from '@core/lib/rules'; -import TagsPredicate from '@core/lib/rules/predicate/tag'; +import { TagsPredicate, TagsPredicateParameters } from '@core/lib/rules/predicate/tag'; import { KeywordOptions } from '@core/lib/rules/interfaces'; +export type KeywordPredicateParameters = TagsPredicateParameters; + export class KeywordPredicate extends TagsPredicate { - constructor(options: KeywordOptions) { - super({ - terms: Array.isArray(options.keyword) ? options.keyword : [options.keyword], - }); + constructor(options: KeywordPredicateParameters) { + super(options); } getTags(movie: MovieDetails): string[] { @@ -24,5 +24,10 @@ export class KeywordPredicate extends TagsPredicate { export const KeywordPredicateBuilder: PredicateBuilder = { key: 'keyword', - build: (data: KeywordOptions) => new KeywordPredicate(data), + build: (data: KeywordOptions) => { + const parameters = { + terms: Array.isArray(data.keyword) ? data.keyword : [data.keyword], + }; + return new KeywordPredicate(parameters); + }, }; diff --git a/src/lib/rules/predicate/number.ts b/src/lib/rules/predicate/number.ts index e1c50b6..bdb263a 100644 --- a/src/lib/rules/predicate/number.ts +++ b/src/lib/rules/predicate/number.ts @@ -1,37 +1,29 @@ import { Predicate } from '@core/lib/rules'; import { MovieDetails } from '@core/api/overseerr/interfaces'; -export type NumberPredicateOptions = { +export type NumberPredicateParameters = { threshold: number; operator: 'lt' | 'gt'; }; -export interface MetricsExtractor { - (movie: MovieDetails): number; -} - -export class NumberPredicate extends Predicate { +export abstract class NumberPredicate extends Predicate { private threshold: number; private operator: 'lt' | 'gt'; - private metrics: MetricsExtractor; - constructor(options: NumberPredicateOptions, metrics: MetricsExtractor) { + constructor(options: NumberPredicateParameters) { super(); this.threshold = options.threshold; this.operator = options.operator; - this.metrics = metrics; } - private getMetrics(movie: MovieDetails): number { - return this.metrics(movie); - } + protected abstract getMetrics(movie: MovieDetails): number; matches(movie: MovieDetails): boolean { - const measuredSeconds = this.getMetrics(movie); + const metric = this.getMetrics(movie); if (this.operator === 'lt') { - return measuredSeconds < this.threshold; + return metric < this.threshold; } - return measuredSeconds > this.threshold; + return metric > this.threshold; } } diff --git a/src/lib/rules/predicate/originalLanguage.ts b/src/lib/rules/predicate/originalLanguage.ts index 32dc24a..816060b 100644 --- a/src/lib/rules/predicate/originalLanguage.ts +++ b/src/lib/rules/predicate/originalLanguage.ts @@ -1,13 +1,13 @@ import { MovieDetails } from '@core/api/overseerr/interfaces'; import { PredicateBuilder } from '@core/lib/rules'; -import TagsPredicate from '@core/lib/rules/predicate/tag'; +import { TagsPredicate, TagsPredicateParameters } from '@core/lib/rules/predicate/tag'; import { OriginalLanguageOptions } from '@core/lib/rules/interfaces'; +export type OriginalLanguagePredicateParameters = TagsPredicateParameters; + export class OriginalLanguagePredicate extends TagsPredicate { - constructor(options: OriginalLanguageOptions) { - super({ - terms: Array.isArray(options.originalLanguage) ? options.originalLanguage : [options.originalLanguage], - }); + constructor(options: OriginalLanguagePredicateParameters) { + super(options); } getTags(movie: MovieDetails): string[] { @@ -21,5 +21,10 @@ export class OriginalLanguagePredicate extends TagsPredicate { export const OriginalLanguagePredicateBuilder: PredicateBuilder = { key: 'originalLanguage', - build: (data: OriginalLanguageOptions) => new OriginalLanguagePredicate(data), + build: (data: OriginalLanguageOptions) => { + const parameters = { + terms: Array.isArray(data.originalLanguage) ? data.originalLanguage : [data.originalLanguage], + }; + return new OriginalLanguagePredicate(parameters); + }, }; diff --git a/src/lib/rules/predicate/productionCompany.ts b/src/lib/rules/predicate/productionCompany.ts index 5b94913..16b647c 100644 --- a/src/lib/rules/predicate/productionCompany.ts +++ b/src/lib/rules/predicate/productionCompany.ts @@ -1,13 +1,13 @@ import { MovieDetails } from '@core/api/overseerr/interfaces'; import { PredicateBuilder } from '@core/lib/rules'; -import TagsPredicate from '@core/lib/rules/predicate/tag'; import { ProductionCompanyOptions } from '@core/lib/rules/interfaces'; +import { TagsPredicate, TagsPredicateParameters } from '@core/lib/rules/predicate/tag'; + +export type ProductionCompanyPredicateParameters = TagsPredicateParameters; export class ProductionCompanyPredicate extends TagsPredicate { - constructor(options: ProductionCompanyOptions) { - super({ - terms: Array.isArray(options.productionCompany) ? options.productionCompany : [options.productionCompany], - }); + constructor(options: ProductionCompanyPredicateParameters) { + super(options); } getTags(movie: MovieDetails): string[] { @@ -24,5 +24,10 @@ export class ProductionCompanyPredicate extends TagsPredicate { export const ProductionCompanyPredicateBuilder: PredicateBuilder = { key: 'productionCompany', - build: (data: ProductionCompanyOptions) => new ProductionCompanyPredicate(data), + build: (data: ProductionCompanyOptions) => { + const parameters = { + terms: Array.isArray(data.productionCompany) ? data.productionCompany : [data.productionCompany], + }; + return new ProductionCompanyPredicate(parameters); + }, }; diff --git a/src/lib/rules/predicate/released.ts b/src/lib/rules/predicate/released.ts index 84e664c..0089b13 100644 --- a/src/lib/rules/predicate/released.ts +++ b/src/lib/rules/predicate/released.ts @@ -1,13 +1,13 @@ import { PredicateBuilder } from '@core/lib/rules'; import { MovieDetails } from '@core/api/overseerr/interfaces'; -import { BooleanPredicate } from '@core/lib/rules/predicate/boolean'; +import { BooleanPredicate, BooleanPredicateParameters, fromHumanReadableBoolean } from '@core/lib/rules/predicate/boolean'; import { ReleasedOptions } from '@core/lib/rules/interfaces'; +export type ReleasedPredicateParameters = BooleanPredicateParameters; + export class ReleasedPredicate extends BooleanPredicate { - constructor(options: ReleasedOptions) { - super({ - value: options.released, - }); + constructor(options: ReleasedPredicateParameters) { + super(options); } matches(movie: MovieDetails): boolean { @@ -25,5 +25,10 @@ export class ReleasedPredicate extends BooleanPredicate { export const ReleasedPredicateBuilder: PredicateBuilder = { key: 'released', - build: (data: ReleasedOptions) => new ReleasedPredicate(data), + build: (data: ReleasedOptions) => { + const parameters = { + value: typeof data.released === 'boolean' ? data.released : fromHumanReadableBoolean(data.released), + }; + return new ReleasedPredicate(parameters); + }, }; diff --git a/src/lib/rules/predicate/runtime.ts b/src/lib/rules/predicate/runtime.ts index 58cc13e..adbd5d3 100644 --- a/src/lib/rules/predicate/runtime.ts +++ b/src/lib/rules/predicate/runtime.ts @@ -1,22 +1,24 @@ import { MovieDetails } from '@core/api/overseerr/interfaces'; import { PredicateBuilder } from '@core/lib/rules'; -import { fromHumanReadableDuration, TimePredicate } from '@core/lib/rules/predicate/time'; +import { fromHumanReadableDuration, TimePredicate, TimePredicateParameters } from '@core/lib/rules/predicate/time'; import { RuntimeOptions } from '@core/lib/rules/interfaces'; +export type RuntimePredicateParameters = TimePredicateParameters; + export class RuntimePredicate extends TimePredicate { - constructor(options: RuntimeOptions) { - super(fromHumanReadableDuration(options.runtime), runtimeMetric); + constructor(options: RuntimePredicateParameters) { + super(options); + } + + protected getMetrics(movie: MovieDetails): number { + if (!movie.runtime) { + return 0; + } + return Number(movie.runtime) * 60; } } -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), + build: (data: RuntimeOptions) => new RuntimePredicate(fromHumanReadableDuration(data.runtime)), }; diff --git a/src/lib/rules/predicate/score.ts b/src/lib/rules/predicate/score.ts index 58d3aa1..cbdeb62 100644 --- a/src/lib/rules/predicate/score.ts +++ b/src/lib/rules/predicate/score.ts @@ -1,12 +1,18 @@ -import { NumberPredicate } from '@core/lib/rules/predicate/number'; +import { NumberPredicate, NumberPredicateParameters } from '@core/lib/rules/predicate/number'; import { MovieDetails } from '@core/api/overseerr/interfaces'; import { PredicateBuilder } from '@core/lib/rules'; import logger from '@core/log'; import { ScoreOptions } from '@core/lib/rules/interfaces'; +export type ScorePredicateParameters = NumberPredicateParameters; + export class ScorePredicate extends NumberPredicate { - constructor(options: ScoreOptions) { - super(fromHumanReadableScore(options.score), (movie: MovieDetails) => movie.voteAverage); + constructor(options: ScorePredicateParameters) { + super(options); + } + + protected getMetrics(movie: MovieDetails): number { + return movie.voteAverage; } } @@ -46,5 +52,5 @@ export const fromHumanReadableScore = (str: string): { threshold: number; operat export const ScorePredicateBuilder: PredicateBuilder = { key: 'score', - build: (data: ScoreOptions) => new ScorePredicate(data), + build: (data: ScoreOptions) => new ScorePredicate(fromHumanReadableScore(data.score)), }; diff --git a/src/lib/rules/predicate/status.ts b/src/lib/rules/predicate/status.ts index e753a2a..ec1f294 100644 --- a/src/lib/rules/predicate/status.ts +++ b/src/lib/rules/predicate/status.ts @@ -1,13 +1,13 @@ import { MovieDetails } from '@core/api/overseerr/interfaces'; import { PredicateBuilder } from '@core/lib/rules'; -import TagsPredicate from '@core/lib/rules/predicate/tag'; +import { TagsPredicate, TagsPredicateParameters } from '@core/lib/rules/predicate/tag'; import { StatusOptions } from '@core/lib/rules/interfaces'; +export type StatusPredicateParameters = TagsPredicateParameters; + export class StatusPredicate extends TagsPredicate { - constructor(options: StatusOptions) { - super({ - terms: Array.isArray(options.status) ? options.status : [options.status], - }); + constructor(options: StatusPredicateParameters) { + super(options); } getTags(movie: MovieDetails): string[] { @@ -21,5 +21,10 @@ export class StatusPredicate extends TagsPredicate { export const StatusPredicateBuilder: PredicateBuilder = { key: 'status', - build: (data: StatusOptions) => new StatusPredicate(data), + build: (data: StatusOptions) => { + const parameters = { + terms: Array.isArray(data.status) ? data.status : [data.status], + }; + return new StatusPredicate(parameters); + }, }; diff --git a/src/lib/rules/predicate/tag.ts b/src/lib/rules/predicate/tag.ts index 430b8bc..5715a2e 100644 --- a/src/lib/rules/predicate/tag.ts +++ b/src/lib/rules/predicate/tag.ts @@ -1,14 +1,14 @@ import { Predicate } from '@core/lib/rules'; import { MovieDetails } from '@core/api/overseerr/interfaces'; -export type TagsPredicateOptions = { +export type TagsPredicateParameters = { terms: string[]; }; export abstract class TagsPredicate extends Predicate { private terms: string[]; - protected constructor(options: TagsPredicateOptions) { + protected constructor(options: TagsPredicateParameters) { super(); this.terms = options.terms; } @@ -20,5 +20,3 @@ export abstract class TagsPredicate extends Predicate { return this.terms.some((t) => tags.includes(t.toLowerCase())); } } - -export default TagsPredicate; diff --git a/src/lib/rules/predicate/time.ts b/src/lib/rules/predicate/time.ts index 0555346..8a6286e 100644 --- a/src/lib/rules/predicate/time.ts +++ b/src/lib/rules/predicate/time.ts @@ -1,19 +1,14 @@ -import { NumberPredicate, NumberPredicateOptions } from '@core/lib/rules/predicate/number'; -import { MovieDetails } from '@core/api/overseerr/interfaces'; +import { NumberPredicate, NumberPredicateParameters } from '@core/lib/rules/predicate/number'; -export type TimePredicateOptions = NumberPredicateOptions; +export type TimePredicateParameters = NumberPredicateParameters; -export interface TimeMetricsExtractor { - (movie: MovieDetails): number; -} - -export class TimePredicate extends NumberPredicate { - protected constructor(options: TimePredicateOptions, metrics: TimeMetricsExtractor) { - super(options, metrics); +export abstract class TimePredicate extends NumberPredicate { + protected constructor(options: TimePredicateParameters) { + super(options); } } -export const fromHumanReadableDuration = (str: string): NumberPredicateOptions => { +export const fromHumanReadableDuration = (str: string): NumberPredicateParameters => { 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]; diff --git a/src/lib/rules/predicate/voteCount.ts b/src/lib/rules/predicate/voteCount.ts index 837c1f3..1d92249 100644 --- a/src/lib/rules/predicate/voteCount.ts +++ b/src/lib/rules/predicate/voteCount.ts @@ -1,15 +1,21 @@ -import { fromHumanReadableNumber, NumberPredicate } from '@core/lib/rules/predicate/number'; +import { fromHumanReadableNumber, NumberPredicate, NumberPredicateParameters } from '@core/lib/rules/predicate/number'; import { MovieDetails } from '@core/api/overseerr/interfaces'; import { PredicateBuilder } from '@core/lib/rules'; import { VoteCountOptions } from '@core/lib/rules/interfaces'; +export type VoteCountPredicateParameters = NumberPredicateParameters; + export class VoteCountPredicate extends NumberPredicate { - constructor(options: VoteCountOptions) { - super(fromHumanReadableNumber(options.voteCount), (movie: MovieDetails) => movie.voteCount); + constructor(options: VoteCountPredicateParameters) { + super(options); + } + + protected getMetrics(movie: MovieDetails): number { + return movie.voteCount; } } export const VoteCountPredicateBuilder: PredicateBuilder = { key: 'voteCount', - build: (data: VoteCountOptions) => new VoteCountPredicate(data), + build: (data: VoteCountOptions) => new VoteCountPredicate(fromHumanReadableNumber(data.voteCount)), }; diff --git a/src/lib/rules/predicate/watchproviders.ts b/src/lib/rules/predicate/watchproviders.ts index c30ae6d..746cf0d 100644 --- a/src/lib/rules/predicate/watchproviders.ts +++ b/src/lib/rules/predicate/watchproviders.ts @@ -1,14 +1,18 @@ import { MovieDetails } from '@core/api/overseerr/interfaces'; import { PredicateBuilder } from '@core/lib/rules'; -import TagsPredicate from '@core/lib/rules/predicate/tag'; import { WatchProvidersOptions } from '@core/lib/rules/interfaces'; +import { TagsPredicate, TagsPredicateParameters } from '@core/lib/rules/predicate/tag'; + +export type WatchProvidersPredicateParameters = TagsPredicateParameters & { + region: string; +}; export class WatchprovidersPredicate extends TagsPredicate { private region: string; - constructor(options: WatchProvidersOptions) { - super({ terms: options.watchProviders.names }); - this.region = options.watchProviders.region.toLowerCase(); + constructor(options: WatchProvidersPredicateParameters) { + super(options); + this.region = options.region; } getTags(movie: MovieDetails): string[] { @@ -29,5 +33,11 @@ export class WatchprovidersPredicate extends TagsPredicate { export const WatchProvidersPredicateBuilder: PredicateBuilder = { key: 'watchProviders', - build: (data: WatchProvidersOptions) => new WatchprovidersPredicate(data), + build: (data: WatchProvidersOptions) => { + const parameters = { + terms: data.watchProviders.names, + region: data.watchProviders.region ?? '', + }; + return new WatchprovidersPredicate(parameters); + }, }; From 23906fa773b494e93207547e3dd05ac5c230ef5f Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Fri, 30 Aug 2024 22:11:42 +0200 Subject: [PATCH 10/13] chore: added information when testing connections --- src/api/httpApi.ts | 2 +- src/api/overseerr/index.ts | 4 +++- src/api/plex/index.ts | 3 ++- src/index.ts | 2 +- src/lib/cron.ts | 1 + src/lib/ruleset.ts | 1 + src/log.ts | 3 ++- 7 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/api/httpApi.ts b/src/api/httpApi.ts index 81799c0..6725af7 100644 --- a/src/api/httpApi.ts +++ b/src/api/httpApi.ts @@ -15,7 +15,7 @@ interface HttpApiOptions { class HttpApi { protected axios: AxiosInstance; private cookie: string; - private baseUrl: string; + protected baseUrl: string; private cache?: NodeCache; constructor(baseUrl: string, params: Record, options: HttpApiOptions = {}, debug: boolean = false) { diff --git a/src/api/overseerr/index.ts b/src/api/overseerr/index.ts index 2545f2d..3080e79 100644 --- a/src/api/overseerr/index.ts +++ b/src/api/overseerr/index.ts @@ -16,7 +16,9 @@ class OverseerrApi extends HttpApi { public test = async () => { try { - logger.info('Testing overseerr connection...'); + logger.info('Testing overseerr connection using:'); + logger.info(` - url: ${this.baseUrl}`); + logger.info(` - user: ${this.overseerrUser}`); await this.auth(); logger.info(success(' Overseerr connection successful')); } catch (e: unknown) { diff --git a/src/api/plex/index.ts b/src/api/plex/index.ts index 5b35d13..4ecb56a 100644 --- a/src/api/plex/index.ts +++ b/src/api/plex/index.ts @@ -49,7 +49,8 @@ class PlexApi extends HttpApi { public test = async () => { try { - logger.info('Testing Plex connection...'); + logger.info('Testing Plex connection using:'); + logger.info(` - url: ${this.baseUrl}`); await this.get('/'); logger.info(success(' Plex connection successful')); } catch (e: unknown) { diff --git a/src/index.ts b/src/index.ts index 6954131..fdfefc0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,5 +54,5 @@ const corsOptions = { app.use(cors(corsOptions)); app.use('/api', apiRouter); app.listen(port, () => { - return logger.info(`Server is listening at http://localhost:${port} `); + return logger.info(`Overcrawlrr is listening at http://localhost:${port} `); }); diff --git a/src/lib/cron.ts b/src/lib/cron.ts index c417c64..4297021 100644 --- a/src/lib/cron.ts +++ b/src/lib/cron.ts @@ -35,6 +35,7 @@ const jobDefinitionsGetter = (settings: Settings) => [ const jobs: Cron[] = []; export function registerCrons(settings: Settings) { + logger.info('Configuring jobs...'); jobs.forEach((j) => j.stop()); jobDefinitionsGetter(settings).forEach((jobDefinition: JobDefinition) => { if (!jobDefinition.isEnabled()) { diff --git a/src/lib/ruleset.ts b/src/lib/ruleset.ts index 11e2bb9..d0bcd4a 100644 --- a/src/lib/ruleset.ts +++ b/src/lib/ruleset.ts @@ -69,6 +69,7 @@ let rulesets: Map; export const loadRulesets = (settings: Settings): void => { try { + logger.info('Loading rulesets...'); rulesets = new Map(); const configuration = settings.rulesets; for (const options of configuration) { diff --git a/src/log.ts b/src/log.ts index 2959a54..08ce1f8 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,4 +1,5 @@ import { createLogger, format, transports } from 'winston'; +import envVar from '@core/env'; const logFormat = format.printf(({ timestamp, level, message, stack }) => { const log = `[ ${timestamp.replace(/[TZ]/gm, ' ')}] ${level}: ${message}`; @@ -7,7 +8,7 @@ const logFormat = format.printf(({ timestamp, level, message, stack }) => { }); const logger = createLogger({ - level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + level: process.env.NODE_ENV === 'production' ? (envVar('LOG_LEVEL', 'info') as string) : 'debug', format: format.combine(format.errors({ stack: true }), format.timestamp(), format.colorize(), format.prettyPrint(), format.simple(), logFormat), transports: [new transports.Console()], exceptionHandlers: [new transports.Console()], From dc46e5910f9952733b56ef44b32ee0aa032132c9 Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Fri, 30 Aug 2024 22:24:20 +0200 Subject: [PATCH 11/13] docs: added basic troubleshooting page --- docs/troubleshooting.md | 40 ++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 ++ 2 files changed, 42 insertions(+) create mode 100644 docs/troubleshooting.md diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..a2454ea --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,40 @@ +# Troubleshooting + +### Error while connecting to Overseerr + +The application is not able to connect to Overseerr/Jellyseerr. + +--- + +**Request failed with status code 404** + +Most likely your `urlApi` is not configured properly. + +You can test it by opening it in a browser. It should display something like: + +```json +{ + "api":"Overseerr API", + "version":"1.0" +} +``` + +For proper configuration, please refer to [the Configuration page](configuration.md) + +--- + +**Request failed with status code 403** + +Your credentials are not correct. + +Make sure you're using the email of your Overseerr user in the `overseerr/user` field of `settings.yaml` or in your environment variable. + +For proper configuration, please refer to [the Configuration page](configuration.md) + +--- + +Should you encounter another issue, please open a [Github issue](https://github.com/psyko-gh/overcrawlrr/issues) including any element that can be helpful. + +!!! warning Be careful about posting logs + + Overcrawlrr logs can display the URL your are using for Overseerr/Jellyseerr/Plex. Make sure to redact them if you don't want to make them public diff --git a/mkdocs.yml b/mkdocs.yml index e5ebf1d..6111a5b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,6 +30,8 @@ nav: - Rules: - rulesets.md - predicates.md + - Troubleshooting: + - troubleshooting.md - Additional: - additional.md markdown_extensions: From 0d90bb034b08235327225d51ab8127ca44551d3b Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Fri, 30 Aug 2024 22:27:47 +0200 Subject: [PATCH 12/13] docs: fix format --- docs/troubleshooting.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a2454ea..e3709b0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -14,8 +14,8 @@ You can test it by opening it in a browser. It should display something like: ```json { - "api":"Overseerr API", - "version":"1.0" + "api": "Overseerr API", + "version": "1.0" } ``` From 39c9b06a8edd03a3afec606b9699d21fa2d449c7 Mon Sep 17 00:00:00 2001 From: psyko-gh Date: Fri, 30 Aug 2024 22:31:02 +0200 Subject: [PATCH 13/13] test: fixed unit test --- src/lib/rules/__tests__/testFromHumanReadable.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/rules/__tests__/testFromHumanReadable.ts b/src/lib/rules/__tests__/testFromHumanReadable.ts index e176773..1cb1ed8 100644 --- a/src/lib/rules/__tests__/testFromHumanReadable.ts +++ b/src/lib/rules/__tests__/testFromHumanReadable.ts @@ -89,11 +89,9 @@ describe('fromHumanReadableBoolean', () => { it('should match', async () => { expect(fromHumanReadableBoolean('yes')).toEqual(true); expect(fromHumanReadableBoolean('true')).toEqual(true); - expect(fromHumanReadableBoolean('1')).toEqual(true); expect(fromHumanReadableBoolean('no')).toEqual(false); expect(fromHumanReadableBoolean('false')).toEqual(false); - expect(fromHumanReadableBoolean('0')).toEqual(false); expect(fromHumanReadableBoolean('abc')).toEqual(false); }); });