From 72cf3922055ca1ffbddaf65acb6307fc94a5fe77 Mon Sep 17 00:00:00 2001 From: Joren V <9069536+jorenn92@users.noreply.github.com> Date: Mon, 15 Jan 2024 19:54:26 +0100 Subject: [PATCH] feat: Added the ability to test media items against a rule, returning a detailed execution breakdown * chore: Add backend code for rule statistics. Also add an API endpoint to test a single media item * refactor: Add application to rule statistics output name * chore: Add UI modal * chore: UI - button margin * refactor: Fix broken seasons / episodes after rule comparator refactor * refactor: Add a global statistic result indicating if the item matched the complete rule * refactor: add info header * refactor: Added validation for media type & improved info messages * refactor: button caps * refactor: import/export : fetch identifier from id instead of array location * refactor: Responsive monaco editor height * fix(collection): Clicking on the card will now navigate to the details page --- docs/3-using-maintainerr/1-rules/Glossary.md | 2 +- package.json | 4 +- .../plex-api/interfaces/media.interface.ts | 13 +- .../rules/constants/constants.service.ts | 128 +++++ .../modules/rules/getter/getter.service.ts | 4 +- .../rules/getter/plex-getter.service.ts | 123 +++-- .../rules/getter/radarr-getter.service.ts | 8 +- .../rules/helpers/rule.comparator.service.ts | 518 ++++++++++++++++++ .../src/modules/rules/helpers/yaml.service.ts | 140 +---- .../modules/rules/rule-executor.service.ts | 311 +---------- server/src/modules/rules/rules.controller.ts | 14 +- server/src/modules/rules/rules.module.ts | 4 + server/src/modules/rules/rules.service.ts | 33 ++ ...e.comparator.service.doRuleAction.spec.ts} | 162 +++--- ui/src/components/AddModal/index.tsx | 48 +- .../CollectionDetail/TestMediaItem/index.tsx | 282 ++++++++++ .../Collection/CollectionDetail/index.tsx | 42 +- .../Collection/CollectionItem/index.tsx | 159 +++--- .../Collection/CollectionOverview/index.tsx | 2 +- .../Common/CommunityRuleModal/index.tsx | 23 +- .../Common/SearchMediaITem/index.tsx | 64 +++ .../Common/YamlImporterModal/index.tsx | 14 +- .../RuleCreator/CommunityRuleUpload/index.tsx | 2 +- .../Rules/Rule/RuleCreator/index.tsx | 2 +- .../Rules/RuleGroup/AddModal/index.tsx | 4 +- ui/src/components/Rules/index.tsx | 5 +- ui/styles/globals.css | 7 +- yarn.lock | 193 ++++++- 28 files changed, 1589 insertions(+), 722 deletions(-) create mode 100644 server/src/modules/rules/constants/constants.service.ts create mode 100644 server/src/modules/rules/helpers/rule.comparator.service.ts rename server/src/modules/rules/tests/{rule-executor.service.doRuleAction.spec.ts => rule.comparator.service.doRuleAction.spec.ts} (74%) create mode 100644 ui/src/components/Collection/CollectionDetail/TestMediaItem/index.tsx create mode 100644 ui/src/components/Common/SearchMediaITem/index.tsx diff --git a/docs/3-using-maintainerr/1-rules/Glossary.md b/docs/3-using-maintainerr/1-rules/Glossary.md index 1e310e5f..4a9423bc 100644 --- a/docs/3-using-maintainerr/1-rules/Glossary.md +++ b/docs/3-using-maintainerr/1-rules/Glossary.md @@ -1,6 +1,6 @@ ## Rule glossary -This glossary describes the available rules that can be used in the maintainerr. +This glossary describes the available rules that can be used in maintainerr. Each rule contains the media type it applies to, the key and the data type of the returned value. The key is used for identification in Yaml rule files. diff --git a/package.json b/package.json index 8ddc93ac..017ae664 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "plex-api": "^5.3.2", "react": "18.2.0", "react-dom": "18.2.0", + "react-select": "^5.8.0", "react-toast-notifications": "^2.5.1", "react-transition-group": "^4.4.5", "reflect-metadata": "^0.1.13", @@ -96,7 +97,6 @@ "babel-plugin-react-intl-auto": "^3.3.0", "clean-jsdoc-theme": "^4.2.14", "eslint": "^8.56.0", - "eslint-config-prettier": "^9.1.0", "eslint-config-next": "14.0.4", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.0.1", @@ -171,4 +171,4 @@ "@semantic-release/github" ] } -} \ No newline at end of file +} diff --git a/server/src/modules/api/plex-api/interfaces/media.interface.ts b/server/src/modules/api/plex-api/interfaces/media.interface.ts index e07cb95a..b259fcec 100644 --- a/server/src/modules/api/plex-api/interfaces/media.interface.ts +++ b/server/src/modules/api/plex-api/interfaces/media.interface.ts @@ -1,8 +1,10 @@ +import { PlexActor, PlexGenre } from './library.interfaces'; + export interface PlexMetadata { ratingKey: string; parentRatingKey?: string; guid: string; - type: 'movie' | 'show' | 'season'; + type: 'movie' | 'show' | 'season' | 'episode' | 'collection'; title: string; Guid: { id: string; @@ -15,13 +17,20 @@ export interface PlexMetadata { parentIndex?: number; Collection?: { tag: string }[]; leafCount: number; - grandparentRatingKey?: number; + grandparentRatingKey?: string; viewedLeafCount: number; addedAt: number; updatedAt: number; media: Media[]; parentData?: PlexMetadata; Label?: { tag: string }[]; + rating?: number; + audienceRating?: number; + userRating?: number; + Role?: PlexActor[]; + originallyAvailableAt: string; + Media: Media[]; + Genre?: PlexGenre[]; } export interface Media { diff --git a/server/src/modules/rules/constants/constants.service.ts b/server/src/modules/rules/constants/constants.service.ts new file mode 100644 index 00000000..74f466a0 --- /dev/null +++ b/server/src/modules/rules/constants/constants.service.ts @@ -0,0 +1,128 @@ +import { Injectable } from '@nestjs/common'; +import { RuleConstants, RuleType } from './rules.constants'; + +export interface ICustomIdentifier { + type: string; + value: string | number; +} + +@Injectable() +export class RuleConstanstService { + ruleConstants: RuleConstants; + + constructor() { + this.ruleConstants = new RuleConstants(); + } + + public getRuleConstants() { + return this.ruleConstants; + } + + public getValueIdentifier(location: [number, number]) { + const application = this.ruleConstants.applications.find( + (el) => el.id === location[0], + )?.name; + + const rule = this.ruleConstants.applications + .find((el) => el.id === location[0]) + ?.props.find((el) => el.id === location[1])?.name; + + return application + '.' + rule; + } + + public getValueHumanName(location: [number, number]) { + return `${this.ruleConstants.applications.find( + (el) => el.id === location[0], + )?.name} - ${this.ruleConstants.applications + .find((el) => el.id === location[0]) + ?.props.find((el) => el.id === location[1])?.humanName}`; + } + + public getValueFromIdentifier(identifier: string): [number, number] { + const application = identifier.split('.')[0]; + const rule = identifier.split('.')[1]; + + const applicationConstant = this.ruleConstants.applications.find( + (el) => el.name.toLowerCase() === application.toLowerCase(), + ); + + const ruleConstant = applicationConstant.props.find( + (el) => el.name.toLowerCase() === rule.toLowerCase(), + ); + return [applicationConstant.id, ruleConstant.id]; + } + + public getCustomValueIdentifier(customValue: { + ruleTypeId: number; + value: string; + }): ICustomIdentifier { + let ruleType: RuleType; + let value: string | number; + switch (customValue.ruleTypeId) { + case 0: + if (+customValue.value % 86400 === 0 && +customValue.value != 0) { + // when it's custom_days, translate to custom_days + ruleType = new RuleType('4', [], 'custom_days'); + value = (+customValue.value / 86400).toString(); + } else { + // otherwise, it's a normal number + ruleType = RuleType.NUMBER; + value = +customValue.value; + } + break; + case 1: + ruleType = RuleType.DATE; + value = customValue.value; + break; + case 2: + ruleType = RuleType.TEXT; + value = customValue.value; + break; + case 3: + ruleType = RuleType.BOOL; + value = customValue.value == '1' ? 'true' : 'false'; + break; + } + + return { type: ruleType.humanName, value: value }; + } + + public getCustomValueFromIdentifier(identifier: ICustomIdentifier): { + ruleTypeId: number; + value: string; + } { + let ruleType: RuleType; + let value: string; + + switch (identifier.type.toUpperCase()) { + case 'NUMBER': + ruleType = RuleType.NUMBER; + value = identifier.value.toString(); + break; + case 'DATE': + ruleType = RuleType.DATE; + value = identifier.value.toString(); + break; + case 'TEXT': + ruleType = RuleType.TEXT; + value = identifier.value.toString(); + break; + case 'BOOLEAN': + ruleType = RuleType.BOOL; + value = identifier.value == 'true' ? '1' : '0'; + break; + case 'BOOL': + ruleType = RuleType.BOOL; + value = identifier.value == 'true' ? '1' : '0'; + break; + case 'CUSTOM_DAYS': + ruleType = RuleType.NUMBER; + value = (+identifier.value * 86400).toString(); + } + + return { + ruleTypeId: +ruleType.toString(), // tostring returns the key + value: value, + }; + } +} diff --git a/server/src/modules/rules/getter/getter.service.ts b/server/src/modules/rules/getter/getter.service.ts index c3f2df7b..aa0e02d1 100644 --- a/server/src/modules/rules/getter/getter.service.ts +++ b/server/src/modules/rules/getter/getter.service.ts @@ -5,8 +5,8 @@ import { OverseerrGetterService } from './overseerr-getter.service'; import { PlexGetterService } from './plex-getter.service'; import { RadarrGetterService } from './radarr-getter.service'; import { SonarrGetterService } from './sonarr-getter.service'; -import { EPlexDataType } from '../../api/plex-api/enums/plex-data-type-enum'; import { RulesDto } from '../dtos/rules.dto'; +import { EPlexDataType } from 'src/modules/api/plex-api/enums/plex-data-type-enum'; @Injectable() export class ValueGetterService { @@ -20,8 +20,8 @@ export class ValueGetterService { async get( [val1, val2]: [number, number], libItem: PlexLibraryItem, - dataType?: EPlexDataType, ruleGroup?: RulesDto, + dataType?: EPlexDataType, ) { switch (val1) { case Application.PLEX: { diff --git a/server/src/modules/rules/getter/plex-getter.service.ts b/server/src/modules/rules/getter/plex-getter.service.ts index cc64b153..f05b9389 100644 --- a/server/src/modules/rules/getter/plex-getter.service.ts +++ b/server/src/modules/rules/getter/plex-getter.service.ts @@ -10,9 +10,9 @@ import { Property, RuleConstants, } from '../constants/rules.constants'; -import { EPlexDataType } from 'src/modules/api/plex-api/enums/plex-data-type-enum'; import { RulesDto } from '../dtos/rules.dto'; import { PlexMetadata } from 'src/modules/api/plex-api/interfaces/media.interface'; +import { EPlexDataType } from 'src/modules/api/plex-api/enums/plex-data-type-enum'; @Injectable() export class PlexGetterService { @@ -34,18 +34,19 @@ export class PlexGetterService { ) { try { const prop = this.plexProperties.find((el) => el.id === id); + // fetch metadata from cache, this data is more complete const metadata: PlexMetadata = await this.plexApi.getMetadata( libItem.ratingKey, - ); // fetch metadata from cache, this data is more complete + ); switch (prop.name) { case 'addDate': { - return libItem.addedAt ? new Date(+libItem.addedAt * 1000) : null; + return metadata.addedAt ? new Date(+metadata.addedAt * 1000) : null; } case 'seenBy': { const plexUsers = await this.getCorrectedUsers(); const viewers: PlexSeenBy[] = await this.plexApi - .getWatchHistory(libItem.ratingKey) + .getWatchHistory(metadata.ratingKey) .catch((_err) => { return null; }); @@ -59,37 +60,37 @@ export class PlexGetterService { } } case 'releaseDate': { - return new Date(libItem.originallyAvailableAt) - ? new Date(libItem.originallyAvailableAt) + return new Date(metadata.originallyAvailableAt) + ? new Date(metadata.originallyAvailableAt) : null; } case 'rating_critics': { - return libItem.rating ? +libItem.rating : 0; + return metadata.rating ? +metadata.rating : 0; } case 'rating_audience': { - return libItem.audienceRating ? +libItem.audienceRating : 0; + return metadata.audienceRating ? +metadata.audienceRating : 0; } case 'rating_user': { - return libItem.userRating ? +libItem.userRating : 0; + return metadata.userRating ? +metadata.userRating : 0; } case 'people': { - return libItem.Role ? libItem.Role.map((el) => el.tag) : null; + return metadata.Role ? metadata.Role.map((el) => el.tag) : null; } case 'viewCount': { - const count = await this.plexApi.getWatchHistory(libItem.ratingKey); + const count = await this.plexApi.getWatchHistory(metadata.ratingKey); return count ? count.length : 0; } case 'labels': { const item = - libItem.type === 'episode' - ? ((await this.plexApi.getMetadata( - libItem.grandparentRatingKey, - )) as unknown as PlexLibraryItem) - : libItem.type === 'season' + metadata.type === 'episode' ? ((await this.plexApi.getMetadata( - libItem.parentRatingKey, + metadata.grandparentRatingKey, )) as unknown as PlexLibraryItem) - : metadata; + : metadata.type === 'season' + ? ((await this.plexApi.getMetadata( + metadata.parentRatingKey, + )) as unknown as PlexLibraryItem) + : metadata; return item.Label ? item.Label.map((l) => l.tag) : []; } @@ -109,13 +110,13 @@ export class PlexGetterService { : 0; } case 'playlists': { - if (libItem.type !== 'episode' && libItem.type !== 'movie') { + if (metadata.type !== 'episode' && metadata.type !== 'movie') { const filtered = []; const seasons = - libItem.type !== 'season' - ? await this.plexApi.getChildrenMetadata(libItem.ratingKey) - : [libItem]; + metadata.type !== 'season' + ? await this.plexApi.getChildrenMetadata(metadata.ratingKey) + : [metadata]; for (const season of seasons) { const episodes = await this.plexApi.getChildrenMetadata( season.ratingKey, @@ -136,19 +137,19 @@ export class PlexGetterService { return filtered.length; } else { const playlists = await this.plexApi.getPlaylists( - libItem.ratingKey, + metadata.ratingKey, ); return playlists.length; } } case 'playlist_names': { - if (libItem.type !== 'episode' && libItem.type !== 'movie') { + if (metadata.type !== 'episode' && metadata.type !== 'movie') { const filtered = []; const seasons = - libItem.type !== 'season' - ? await this.plexApi.getChildrenMetadata(libItem.ratingKey) - : [libItem]; + metadata.type !== 'season' + ? await this.plexApi.getChildrenMetadata(metadata.ratingKey) + : [metadata]; for (const season of seasons) { const episodes = await this.plexApi.getChildrenMetadata( season.ratingKey, @@ -169,7 +170,7 @@ export class PlexGetterService { return filtered ? filtered.map((el) => el.title.trim()) : []; } else { const playlists = await this.plexApi.getPlaylists( - libItem.ratingKey, + metadata.ratingKey, ); return playlists ? playlists.map((el) => el.title.trim()) : []; } @@ -181,7 +182,7 @@ export class PlexGetterService { } case 'lastViewedAt': { return await this.plexApi - .getWatchHistory(libItem.ratingKey) + .getWatchHistory(metadata.ratingKey) .then((seenby) => { if (seenby.length > 0) { return new Date( @@ -199,38 +200,38 @@ export class PlexGetterService { }); } case 'fileVideoResolution': { - return libItem.Media[0].videoResolution - ? libItem.Media[0].videoResolution + return metadata.Media[0].videoResolution + ? metadata.Media[0].videoResolution : null; } case 'fileBitrate': { - return libItem.Media[0].bitrate ? libItem.Media[0].bitrate : 0; + return metadata.Media[0].bitrate ? metadata.Media[0].bitrate : 0; } case 'fileVideoCodec': { - return libItem.Media[0].videoCodec - ? libItem.Media[0].videoCodec + return metadata.Media[0].videoCodec + ? metadata.Media[0].videoCodec : null; } case 'genre': { const item = - libItem.type === 'episode' + metadata.type === 'episode' ? ((await this.plexApi.getMetadata( - libItem.grandparentRatingKey, + metadata.grandparentRatingKey, )) as unknown as PlexLibraryItem) - : libItem.type === 'season' - ? ((await this.plexApi.getMetadata( - libItem.parentRatingKey, - )) as unknown as PlexLibraryItem) - : libItem; + : metadata.type === 'season' + ? ((await this.plexApi.getMetadata( + metadata.parentRatingKey, + )) as unknown as PlexLibraryItem) + : metadata; return item.Genre ? item.Genre.map((el) => el.tag) : null; } case 'sw_allEpisodesSeenBy': { const plexUsers = await this.getCorrectedUsers(); const seasons = - libItem.type !== 'season' - ? await this.plexApi.getChildrenMetadata(libItem.ratingKey) - : [libItem]; + metadata.type !== 'season' + ? await this.plexApi.getChildrenMetadata(metadata.ratingKey) + : [metadata]; const allViewers = plexUsers.slice(); for (const season of seasons) { const episodes = await this.plexApi.getChildrenMetadata( @@ -271,7 +272,7 @@ export class PlexGetterService { const plexUsers = await this.getCorrectedUsers(); const watchHistory = await this.plexApi.getWatchHistory( - libItem.ratingKey, + metadata.ratingKey, ); const viewers = watchHistory @@ -288,7 +289,7 @@ export class PlexGetterService { } case 'sw_lastWatched': { let watchHistory = await this.plexApi.getWatchHistory( - libItem.ratingKey, + metadata.ratingKey, ); watchHistory?.sort((a, b) => a.parentIndex - b.parentIndex).reverse(); watchHistory = watchHistory?.filter( @@ -300,21 +301,21 @@ export class PlexGetterService { : null; } case 'sw_episodes': { - if (libItem.type === 'season') { + if (metadata.type === 'season') { const eps = await this.plexApi.getChildrenMetadata( - libItem.ratingKey, + metadata.ratingKey, ); return eps.length ? eps.length : 0; } - return libItem.leafCount ? +libItem.leafCount : 0; + return metadata.leafCount ? +metadata.leafCount : 0; } case 'sw_viewedEpisodes': { let viewCount = 0; const seasons = - libItem.type !== 'season' - ? await this.plexApi.getChildrenMetadata(libItem.ratingKey) - : [libItem]; + metadata.type !== 'season' + ? await this.plexApi.getChildrenMetadata(metadata.ratingKey) + : [metadata]; for (const season of seasons) { const episodes = await this.plexApi.getChildrenMetadata( season.ratingKey, @@ -332,16 +333,18 @@ export class PlexGetterService { let viewCount = 0; // for episodes - if (libItem.type === 'episode') { - const views = await this.plexApi.getWatchHistory(libItem.ratingKey); + if (metadata.type === 'episode') { + const views = await this.plexApi.getWatchHistory( + metadata.ratingKey, + ); viewCount = views?.length > 0 ? viewCount + views.length : viewCount; } else { // for seasons & shows const seasons = - libItem.type !== 'season' - ? await this.plexApi.getChildrenMetadata(libItem.ratingKey) - : [libItem]; + metadata.type !== 'season' + ? await this.plexApi.getChildrenMetadata(metadata.ratingKey) + : [metadata]; for (const season of seasons) { const episodes = await this.plexApi.getChildrenMetadata( season.ratingKey, @@ -359,11 +362,11 @@ export class PlexGetterService { } case 'sw_lastEpisodeAddedAt': { const seasons = - libItem.type !== 'season' + metadata.type !== 'season' ? ( - await this.plexApi.getChildrenMetadata(libItem.ratingKey) + await this.plexApi.getChildrenMetadata(metadata.ratingKey) ).sort((a, b) => a.index - b.index) - : [libItem]; + : [metadata]; const lastEpDate = await this.plexApi .getChildrenMetadata(seasons[seasons.length - 1].ratingKey) diff --git a/server/src/modules/rules/getter/radarr-getter.service.ts b/server/src/modules/rules/getter/radarr-getter.service.ts index 74afb1cf..fd6a2aaf 100644 --- a/server/src/modules/rules/getter/radarr-getter.service.ts +++ b/server/src/modules/rules/getter/radarr-getter.service.ts @@ -104,10 +104,10 @@ export class RadarrGetterService { ? new Date(movieResponse.digitalRelease) : new Date(movieResponse.physicalRelease) : movieResponse.physicalRelease - ? new Date(movieResponse.physicalRelease) - : movieResponse.digitalRelease - ? new Date(movieResponse.digitalRelease) - : null; + ? new Date(movieResponse.physicalRelease) + : movieResponse.digitalRelease + ? new Date(movieResponse.digitalRelease) + : null; } case 'inCinemas': { return movieResponse?.inCinemas diff --git a/server/src/modules/rules/helpers/rule.comparator.service.ts b/server/src/modules/rules/helpers/rule.comparator.service.ts new file mode 100644 index 00000000..65143c30 --- /dev/null +++ b/server/src/modules/rules/helpers/rule.comparator.service.ts @@ -0,0 +1,518 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + RuleOperators, + RulePossibility, + RuleType, +} from '../constants/rules.constants'; +import { RuleDto } from '../dtos/rule.dto'; +import _ from 'lodash'; +import { PlexLibraryItem } from 'src/modules/api/plex-api/interfaces/library.interfaces'; +import { RulesDto } from '../dtos/rules.dto'; +import { ValueGetterService } from '../getter/getter.service'; +import { EPlexDataType } from 'src/modules/api/plex-api/enums/plex-data-type-enum'; +import { RuleDbDto } from '../dtos/ruleDb.dto'; +import { RuleConstanstService } from '../constants/constants.service'; + +interface IComparisonStatistics { + plexId: number; + result: boolean; + sectionResults: ISectionComparisonResults[]; +} + +interface ISectionComparisonResults { + id: number; + result: boolean; + operator?: string; + ruleResults: IRuleComparisonResult[]; +} + +interface IRuleComparisonResult { + firstValueName: string; + firstValue: any; + secondValueName: string; + secondValue: any; + action: string; + operator?: string; + result: boolean; +} + +interface IComparatorReturnValue { + stats: IComparisonStatistics[]; + data: PlexLibraryItem[]; +} + +@Injectable() +export class RuleComparatorService { + private readonly logger = new Logger(RuleComparatorService.name); + workerData: PlexLibraryItem[]; + resultData: PlexLibraryItem[]; + plexData: PlexLibraryItem[]; + plexDataType: EPlexDataType; + statistics: IComparisonStatistics[]; + statisticWorker: IRuleComparisonResult[]; + enabledStats: boolean; + + constructor( + private readonly valueGetter: ValueGetterService, + private readonly ruleConstanstService: RuleConstanstService, + ) {} + + public async executeRulesWithData( + rulegroup: RulesDto, + plexData: PlexLibraryItem[], + withStats = false, + ): Promise { + try { + // prepare + this.plexData = plexData; + this.plexDataType = rulegroup.dataType ? rulegroup.dataType : undefined; + this.enabledStats = withStats; + this.workerData = []; + this.resultData = []; + this.statistics = []; + this.statisticWorker = []; + + // run rules + let currentSection = 0; + let sectionActionAnd = false; + + // prepare statistics if needed + this.prepareStatistics(); + + for (const rule of rulegroup.rules) { + const parsedRule = JSON.parse((rule as RuleDbDto).ruleJson) as RuleDto; + if (currentSection === (rule as RuleDbDto).section) { + // if section didn't change + // execute and store in work array + await this.executeRule(parsedRule, rulegroup); + } else { + // set the stat results of the completed section, if needed + this.setStatisticSectionResults(); + + // handle section action + this.handleSectionAction(sectionActionAnd); + + // save new section action + sectionActionAnd = +parsedRule.operator === 0; + // reset first operator of new section + parsedRule.operator = null; + // add new section to stats + this.addSectionToStatistics( + (rule as RuleDbDto).section, + sectionActionAnd, + ); + // Execute the rule and set the new section + await this.executeRule(parsedRule, rulegroup); + currentSection = (rule as RuleDbDto).section; + } + } + // set the stat results of the last section, if needed + this.setStatisticSectionResults(); + + // handle last section + this.handleSectionAction(sectionActionAnd); + + // update statistics results when needed + this.updateStatisticResults(); + + // update result for matched media + this.statistics.forEach((el) => { + el.result = this.resultData.some((i) => +i.ratingKey === +el.plexId); + }); + + // return comparatorReturnValue + return { stats: this.statistics, data: this.resultData }; + } catch (e) { + this.logger.log( + `Something went wrong while running rule ${rulegroup.name}`, + ); + this.logger.debug(e); + } + } + + updateStatisticResults() { + if (this.enabledStats) { + this.statistics.forEach((el) => { + el.result = this.resultData.some((i) => +i.ratingKey === +el.plexId); + }); + } + } + + private setStatisticSectionResults() { + // add the result of the last section. If media is in workerData, section = true. + if (this.enabledStats) { + this.statistics.forEach((stat) => { + if (this.workerData.find((el) => +el.ratingKey === +stat.plexId)) { + stat.sectionResults[stat.sectionResults.length - 1].result = true; + } else { + stat.sectionResults[stat.sectionResults.length - 1].result = false; + } + }); + } + } + + private addSectionToStatistics(id: number, isAND: boolean) { + if (this.enabledStats) { + this.statistics.forEach((data) => { + data.sectionResults.push({ + id: id, + result: undefined, + operator: isAND ? 'AND' : 'OR', + ruleResults: [], + }); + }); + } + } + + private async executeRule(rule: RuleDto, ruleGroup: RulesDto) { + let data: PlexLibraryItem[]; + let firstVal: any; + let secondVal: any; + + if (rule.operator === null || +rule.operator === +RuleOperators.OR) { + data = _.cloneDeep(this.plexData); + } else { + data = _.cloneDeep(this.workerData); + } + + // loop media items + for (let i = data.length - 1; i >= 0; i--) { + // fetch values + firstVal = await this.valueGetter.get( + rule.firstVal, + data[i], + ruleGroup, + this.plexDataType, + ); + secondVal = await this.getSecondValue(rule, data[i], ruleGroup, firstVal); + + if ( + (firstVal !== undefined || null) && + (secondVal !== undefined || null) + ) { + // do action + const comparisonResult = this.doRuleAction( + firstVal, + secondVal, + rule.action, + ); + + // add stats if enabled + this.addStatistictoParent( + rule, + firstVal, + secondVal, + +data[i].ratingKey, + comparisonResult, + ); + + // alter workerData + if (rule.operator === null || +rule.operator === +RuleOperators.OR) { + if (comparisonResult) { + // add to workerdata if not yet available + if ( + this.workerData.find((e) => e.ratingKey === data[i].ratingKey) === + undefined + ) { + this.workerData.push(data[i]); + } + } + } else { + if (!comparisonResult) { + // remove from workerdata + this.workerData.splice(i, 1); + } + } + } + } + } + + async getSecondValue( + rule: RuleDto, + data: PlexLibraryItem, + rulegroup: RulesDto, + firstVal: any, + ): Promise { + let secondVal; + if (rule.lastVal) { + secondVal = await this.valueGetter.get( + rule.lastVal, + data, + rulegroup, + this.plexDataType, + ); + } else { + secondVal = + rule.customVal.ruleTypeId === +RuleType.DATE + ? rule.customVal.value.includes('-') + ? new Date(rule.customVal.value) + : new Date(+rule.customVal.value * 1000) + : rule.customVal.ruleTypeId === +RuleType.TEXT + ? rule.customVal.value + : rule.customVal.ruleTypeId === +RuleType.NUMBER || + rule.customVal.ruleTypeId === +RuleType.BOOL + ? +rule.customVal.value + : null; + if ( + firstVal instanceof Date && + rule.customVal.ruleTypeId === +RuleType.NUMBER + ) { + if ( + [RulePossibility.IN_LAST, RulePossibility.BEFORE].includes( + rule.action, + ) + ) { + secondVal = new Date(new Date().getTime() - +secondVal * 1000); + } else { + secondVal = new Date(new Date().getTime() + +secondVal * 1000); + } + } else if ( + firstVal instanceof Date && + rule.customVal.ruleTypeId === +RuleType.DATE + ) { + secondVal = new Date(+secondVal); + } + if ( + // if custom secondval is text, check if it's parsable as an array + rule.customVal.ruleTypeId === +RuleType.TEXT && + this.isStringParsableToArray(secondVal as string) + ) { + secondVal = JSON.parse(secondVal); + } + } + return secondVal; + } + + private prepareStatistics() { + if (this.enabledStats) { + this.plexData.forEach((data) => { + this.statistics.push({ + plexId: +data.ratingKey, + result: false, + sectionResults: [ + { + id: 0, + result: undefined, + ruleResults: [], + }, + ], + }); + }); + } + } + + private addStatistictoParent( + rule: RuleDto, + firstVal: any, + secondVal: any, + plexId: number, + result: boolean, + ) { + if (this.enabledStats) { + const index = this.statistics.findIndex((el) => +el.plexId === +plexId); + const sectionIndex = this.statistics[index].sectionResults.length - 1; + + // push result to currently last section + this.statistics[index].sectionResults[sectionIndex].ruleResults.push({ + operator: + rule.operator === null || rule.operator === undefined + ? RuleOperators[1] + : RuleOperators[rule.operator], + action: RulePossibility[rule.action].toLowerCase(), + firstValueName: this.ruleConstanstService.getValueHumanName( + rule.firstVal, + ), + firstValue: firstVal, + secondValueName: rule.lastVal + ? this.ruleConstanstService.getValueHumanName(rule.lastVal) + : this.ruleConstanstService.getCustomValueIdentifier(rule.customVal) + .type, + secondValue: secondVal, + result: result, + }); + + // If it's the first rule of a section (but not the first one) then add the operator to the sectionResult + if ( + index > 0 && + this.statistics[index].sectionResults[sectionIndex].ruleResults + .length === 1 + ) { + this.statistics[index].sectionResults[sectionIndex].operator = + rule.operator === null || rule.operator === undefined + ? RuleOperators[1] + : RuleOperators[rule.operator]; + } + } + } + + private handleSectionAction(sectionActionAnd: boolean) { + if (!sectionActionAnd) { + // section action is OR, then push in result array + this.resultData.push(...this.workerData); + } else { + // section action is AND, then filter media not in work array out of result array + this.resultData = this.resultData.filter((el) => { + // If in current data.. Otherwise we're removing previously added media + if (this.plexData.some((plexEl) => plexEl.ratingKey === el.ratingKey)) { + return this.workerData.some( + (workEl) => workEl.ratingKey === el.ratingKey, + ); + } else { + // If not in current data, skip check + return true; + } + }); + } + // empty workerdata. prepare for execution of new section + this.workerData = []; + } + + private doRuleAction(val1: T, val2: T, action: RulePossibility): boolean { + if ( + typeof val1 === 'string' || + (Array.isArray(val1) ? typeof val1[0] === 'string' : false) + ) { + val1 = Array.isArray(val1) + ? (val1.map((el) => el?.toLowerCase()) as unknown as T) + : ((val1 as string)?.toLowerCase() as unknown as T); + } + if ( + typeof val2 === 'string' || + (Array.isArray(val2) ? typeof val2[0] === 'string' : false) + ) { + val2 = Array.isArray(val2) + ? (val2.map((el) => el?.toLowerCase()) as unknown as T) + : ((val2 as string)?.toLowerCase() as unknown as T); + } + if (action === RulePossibility.BIGGER) { + return val1 > val2; + } + if (action === RulePossibility.SMALLER) { + return val1 < val2; + } + if (action === RulePossibility.EQUALS) { + if (!Array.isArray(val1)) { + if (val1 instanceof Date && val2 instanceof Date) { + return ( + new Date(val1?.toDateString()).valueOf() === + new Date(val2?.toDateString()).valueOf() + ); + } + if (typeof val1 === 'boolean') { + return val1 == val2; + } + return val1 === val2; + } else { + if (val1.length > 0) { + return val1?.every((e) => { + e = + typeof e === 'string' + ? (e as unknown as string)?.toLowerCase() + : e; + if (Array.isArray(val2)) { + return (val2 as unknown as T[])?.includes(e); + } else { + return e === val2; + } + }); + } else { + return false; + } + } + } + if (action === RulePossibility.NOT_EQUALS) { + return !this.doRuleAction(val1, val2, RulePossibility.EQUALS); + } + if (action === RulePossibility.CONTAINS) { + try { + if (!Array.isArray(val2)) { + return (val1 as unknown as T[])?.includes(val2); + } else { + if (val2.length > 0) { + return val2?.some((el) => { + return (val1 as unknown as T[])?.includes(el); + }); + } else { + return false; + } + } + } catch (_err) { + return null; + } + } + if (action === RulePossibility.CONTAINS_PARTIAL) { + try { + if (!Array.isArray(val2)) { + // return (val1 as unknown as T[])?.includes(val2); + return ( + (Array.isArray(val1) ? (val1 as unknown as T[]) : [val1])?.some( + (line) => { + return typeof line === 'string' && + val2 != undefined && + String(val2).length > 0 + ? line.includes(String(val2)) + : line == val2 + ? true + : false; + }, + ) || false + ); + } else { + if (val2.length > 0) { + return val2?.some((el) => { + // return (val1 as unknown as T[])?.includes(el); + return ( + (val1 as unknown as T[])?.some((line) => { + return typeof line === 'string' && + el != undefined && + el.length > 0 + ? line.includes(String(el)) + : line == el + ? true + : false; + }) || false + ); + }); + } else { + return false; + } + } + } catch (_err) { + return null; + } + } + if (action === RulePossibility.NOT_CONTAINS) { + return !this.doRuleAction(val1, val2, RulePossibility.CONTAINS); + } + if (action === RulePossibility.NOT_CONTAINS_PARTIAL) { + return !this.doRuleAction(val1, val2, RulePossibility.CONTAINS_PARTIAL); + } + if (action === RulePossibility.BEFORE) { + return val1 && val2 ? val1 <= val2 : false; + } + if (action === RulePossibility.AFTER) { + return val1 && val2 ? val1 >= val2 : false; + } + if (action === RulePossibility.IN_LAST) { + return ( + val1 >= val2 && // time in s + (val1 as unknown as Date) <= new Date() + ); + } + if (action === RulePossibility.IN_NEXT) { + return ( + val1 <= val2 && // time in s + (val1 as unknown as Date) >= new Date() + ); + } + } + + private isStringParsableToArray(str: string) { + try { + const array = JSON.parse(str); + return Array.isArray(array); + } catch (error) { + return false; + } + } +} diff --git a/server/src/modules/rules/helpers/yaml.service.ts b/server/src/modules/rules/helpers/yaml.service.ts index 838ee4c0..f106efc1 100644 --- a/server/src/modules/rules/helpers/yaml.service.ts +++ b/server/src/modules/rules/helpers/yaml.service.ts @@ -2,16 +2,18 @@ import { Injectable, Logger } from '@nestjs/common'; import { RuleDto } from '../dtos/rule.dto'; import { ReturnStatus } from '../rules.service'; import { - RuleConstants, RuleOperators, RulePossibility, - RuleType, } from '../constants/rules.constants'; import YAML from 'yaml'; import { EPlexDataType, PlexDataTypeStrings, } from '../../..//modules/api/plex-api/enums/plex-data-type-enum'; +import { + ICustomIdentifier, + RuleConstanstService, +} from '../constants/constants.service'; interface IRuleYamlParent { mediaType: string; @@ -22,27 +24,19 @@ interface ISectionYaml { [key: number]: IRuleYaml[]; } -interface ICustomYamlValue { - type: string; - value: string | number; -} - interface IRuleYaml { operator?: string; action: string; firstValue: string; lastValue?: string; - customValue?: ICustomYamlValue; + customValue?: ICustomIdentifier; } @Injectable() export class RuleYamlService { private readonly logger = new Logger(RuleYamlService.name); - ruleConstants: RuleConstants; - constructor() { - this.ruleConstants = new RuleConstants(); - } + constructor(private readonly ruleConstanstService: RuleConstanstService) {} public encode(rules: RuleDto[], mediaType: number): ReturnStatus { try { let workingSection = { id: 0, rules: [] }; @@ -60,13 +54,23 @@ export class RuleYamlService { // transform rule and add to workingSection workingSection.rules.push({ ...(rule.operator ? { operator: RuleOperators[+rule.operator] } : {}), - firstValue: this.getValueIdentifier(rule.firstVal), + firstValue: this.ruleConstanstService.getValueIdentifier( + rule.firstVal, + ), action: RulePossibility[+rule.action], ...(rule.lastVal - ? { lastValue: this.getValueIdentifier(rule.lastVal) } + ? { + lastValue: this.ruleConstanstService.getValueIdentifier( + rule.lastVal, + ), + } : {}), ...(rule.customVal - ? { customValue: this.getCustomValueIdentifier(rule.customVal) } + ? { + customValue: this.ruleConstanstService.getCustomValueIdentifier( + rule.customVal, + ), + } : {}), }); } @@ -124,21 +128,22 @@ export class RuleYamlService { : null, action: +RulePossibility[rule.action.toUpperCase()], section: idRef, - firstVal: this.getValueFromIdentifier( + firstVal: this.ruleConstanstService.getValueFromIdentifier( rule.firstValue.toLowerCase(), ), ...(rule.lastValue ? { - lastVal: this.getValueFromIdentifier( + lastVal: this.ruleConstanstService.getValueFromIdentifier( rule.lastValue.toLowerCase(), ), } : {}), ...(rule.customValue ? { - customVal: this.getCustomValueFromIdentifier( - rule.customValue, - ), + customVal: + this.ruleConstanstService.getCustomValueFromIdentifier( + rule.customValue, + ), } : {}), }); @@ -165,99 +170,4 @@ export class RuleYamlService { }; } } - private getValueIdentifier(location: [number, number]) { - const application = this.ruleConstants.applications[location[0]].name; - const rule = - this.ruleConstants.applications[location[0]].props[location[1]].name; - - return application + '.' + rule; - } - - private getValueFromIdentifier(identifier: string): [number, number] { - const application = identifier.split('.')[0]; - const rule = identifier.split('.')[1]; - - const applicationConstant = this.ruleConstants.applications.find( - (el) => el.name.toLowerCase() === application.toLowerCase(), - ); - - const ruleConstant = applicationConstant.props.find( - (el) => el.name.toLowerCase() === rule.toLowerCase(), - ); - return [applicationConstant.id, ruleConstant.id]; - } - - private getCustomValueIdentifier(customValue: { - ruleTypeId: number; - value: string; - }): ICustomYamlValue { - let ruleType: RuleType; - let value: string | number; - switch (customValue.ruleTypeId) { - case 0: - if (+customValue.value % 86400 === 0 && +customValue.value != 0) { - // when it's custom_days, translate to custom_days - ruleType = new RuleType('4', [], 'custom_days'); - value = (+customValue.value / 86400).toString(); - } else { - // otherwise, it's a normal number - ruleType = RuleType.NUMBER; - value = +customValue.value; - } - break; - case 1: - ruleType = RuleType.DATE; - value = customValue.value; - break; - case 2: - ruleType = RuleType.TEXT; - value = customValue.value; - break; - case 3: - ruleType = RuleType.BOOL; - value = customValue.value == '1' ? 'true' : 'false'; - break; - } - - return { type: ruleType.humanName, value: value }; - } - - private getCustomValueFromIdentifier(identifier: ICustomYamlValue): { - ruleTypeId: number; - value: string; - } { - let ruleType: RuleType; - let value: string; - - switch (identifier.type.toUpperCase()) { - case 'NUMBER': - ruleType = RuleType.NUMBER; - value = identifier.value.toString(); - break; - case 'DATE': - ruleType = RuleType.DATE; - value = identifier.value.toString(); - break; - case 'TEXT': - ruleType = RuleType.TEXT; - value = identifier.value.toString(); - break; - case 'BOOLEAN': - ruleType = RuleType.BOOL; - value = identifier.value == 'true' ? '1' : '0'; - break; - case 'BOOL': - ruleType = RuleType.BOOL; - value = identifier.value == 'true' ? '1' : '0'; - break; - case 'CUSTOM_DAYS': - ruleType = RuleType.NUMBER; - value = (+identifier.value * 86400).toString(); - } - - return { - ruleTypeId: +ruleType.toString(), // tostring returns the key - value: value, - }; - } } diff --git a/server/src/modules/rules/rule-executor.service.ts b/server/src/modules/rules/rule-executor.service.ts index b09b993d..52beafe1 100644 --- a/server/src/modules/rules/rule-executor.service.ts +++ b/server/src/modules/rules/rule-executor.service.ts @@ -1,26 +1,19 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import _ from 'lodash'; -import { isNull } from 'lodash'; import { PlexLibraryItem } from '../api/plex-api/interfaces/library.interfaces'; import { PlexApiService } from '../api/plex-api/plex-api.service'; import { CollectionsService } from '../collections/collections.service'; import { AddCollectionMedia } from '../collections/interfaces/collection-media.interface'; import { SettingsService } from '../settings/settings.service'; import { TasksService } from '../tasks/tasks.service'; -import { - RuleConstants, - RuleOperators, - RulePossibility, - RuleType, -} from './constants/rules.constants'; -import { RuleDto } from './dtos/rule.dto'; -import { RuleDbDto } from './dtos/ruleDb.dto'; +import { RuleConstants } from './constants/rules.constants'; + import { RulesDto } from './dtos/rules.dto'; import { RuleGroup } from './entities/rule-group.entities'; -import { ValueGetterService } from './getter/getter.service'; import { RulesService } from './rules.service'; import { EPlexDataType } from '../api/plex-api/enums/plex-data-type-enum'; import cacheManager, { Cache } from '../api/lib/cache'; +import { RuleComparatorService } from './helpers/rule.comparator.service'; interface PlexData { page: number; @@ -41,11 +34,11 @@ export class RuleExecutorService implements OnApplicationBootstrap { resultData: PlexLibraryItem[]; constructor( private readonly rulesService: RulesService, - private readonly valueGetter: ValueGetterService, private readonly plexApi: PlexApiService, private readonly collectionService: CollectionsService, private readonly taskService: TasksService, private readonly settings: SettingsService, + private readonly comparator: RuleComparatorService, ) { this.ruleConstants = new RuleConstants(); this.plexData = { page: 1, finished: false, data: [] }; @@ -96,39 +89,25 @@ export class RuleExecutorService implements OnApplicationBootstrap { if (rulegroup.useRules) { this.logger.log(`Executing rules for '${rulegroup.name}'`); + // prepare this.workerData = []; this.resultData = []; - this.plexData = { page: 0, finished: false, data: [] }; + this.plexDataType = rulegroup.dataType ? rulegroup.dataType : undefined; + + // Run rules data shunks of 50 while (!this.plexData.finished) { await this.getPlexData(rulegroup.libraryId); - let currentSection = 0; - let sectionActionAnd = false; - - for (const rule of rulegroup.rules) { - const parsedRule = JSON.parse( - (rule as RuleDbDto).ruleJson, - ) as RuleDto; - if (currentSection === (rule as RuleDbDto).section) { - // if section didn't change - // execute and store in work array - await this.executeRule(parsedRule, rulegroup); - } else { - // handle section action - this.handleSectionAction(sectionActionAnd); - // save new section action - sectionActionAnd = +parsedRule.operator === 0; - // reset first operator of new section - parsedRule.operator = null; - // Execute the rule and set the new section - await this.executeRule(parsedRule, rulegroup); - currentSection = (rule as RuleDbDto).section; - } + const ruleResult = await this.comparator.executeRulesWithData( + rulegroup, + this.plexData.data, + ); + if (ruleResult) { + this.resultData.push(...ruleResult?.data); } - this.handleSectionAction(sectionActionAnd); // Handle last section } await this.handleCollection( await this.rulesService.getRuleGroupById(rulegroup.id), // refetch to get latest changes @@ -211,30 +190,6 @@ export class RuleExecutorService implements OnApplicationBootstrap { } } - private handleSectionAction(sectionActionAnd: boolean) { - if (!sectionActionAnd) { - // section action is OR, then push in result array - this.resultData.push(...this.workerData); - } else { - // section action is AND, then filter media not in work array out of result array - this.resultData = this.resultData.filter((el) => { - // If in current data.. Otherwise we're removing previously added media - if ( - this.plexData.data.some((plexEl) => plexEl.ratingKey === el.ratingKey) - ) { - return this.workerData.some( - (workEl) => workEl.ratingKey === el.ratingKey, - ); - } else { - // If not in current data, skip check - return true; - } - }); - } - // empty workerdata. prepare for execution of new section - this.workerData = []; - } - private async handleCollection(rulegroup: RuleGroup) { try { let collection = await this.collectionService.getCollection( @@ -386,245 +341,7 @@ export class RuleExecutorService implements OnApplicationBootstrap { this.plexData.page++; } - private async executeRule(rule: RuleDto, ruleGroup: RulesDto) { - let data: PlexLibraryItem[]; - let firstVal: any; - let secondVal: any; - const indexesToSplice: number[] = []; - - if (isNull(rule.operator) || +rule.operator === +RuleOperators.OR) { - data = _.cloneDeep(this.plexData.data); - } else { - data = _.cloneDeep(this.workerData); - } - // for (const [index, el] of data.entries()) { - for (let i = data.length - 1; i >= 0; i--) { - firstVal = await this.valueGetter.get( - rule.firstVal, - data[i], - this.plexDataType, - ruleGroup, - ); - if (rule.lastVal) { - secondVal = await this.valueGetter.get( - rule.lastVal, - data[i], - this.plexDataType, - ruleGroup, - ); - } else { - secondVal = - rule.customVal.ruleTypeId === +RuleType.DATE - ? rule.customVal.value.includes('-') - ? new Date(rule.customVal.value) - : new Date(+rule.customVal.value * 1000) - : rule.customVal.ruleTypeId === +RuleType.TEXT - ? rule.customVal.value - : rule.customVal.ruleTypeId === +RuleType.NUMBER || - rule.customVal.ruleTypeId === +RuleType.BOOL - ? +rule.customVal.value - : null; - if ( - firstVal instanceof Date && - rule.customVal.ruleTypeId === +RuleType.NUMBER - ) { - if ( - [RulePossibility.IN_LAST, RulePossibility.BEFORE].includes( - rule.action, - ) - ) { - secondVal = new Date(new Date().getTime() - +secondVal * 1000); - } else { - secondVal = new Date(new Date().getTime() + +secondVal * 1000); - } - } else if ( - firstVal instanceof Date && - rule.customVal.ruleTypeId === +RuleType.DATE - ) { - secondVal = new Date(+secondVal); - } - if ( - // if custom secondval is text, check if it's parsable as an array - rule.customVal.ruleTypeId === +RuleType.TEXT && - this.isStringParsableToArray(secondVal as string) - ) { - secondVal = JSON.parse(secondVal); - } - } - if ( - (firstVal !== undefined || null) && - (secondVal !== undefined || null) - ) { - if (isNull(rule.operator) || +rule.operator === +RuleOperators.OR) { - if (this.doRuleAction(firstVal, secondVal, rule.action)) { - // add to workerdata if not yet available - if ( - this.workerData.find((e) => e.ratingKey === data[i].ratingKey) === - undefined - ) { - this.workerData.push(data[i]); - } - } - } else { - if (!this.doRuleAction(firstVal, secondVal, rule.action)) { - // remove from workerdata - this.workerData.splice(i, 1); - } - } - } - } - } - - private doRuleAction(val1: T, val2: T, action: RulePossibility): boolean { - if ( - typeof val1 === 'string' || - (Array.isArray(val1) ? typeof val1[0] === 'string' : false) - ) { - val1 = Array.isArray(val1) - ? (val1.map((el) => el?.toLowerCase()) as unknown as T) - : ((val1 as string)?.toLowerCase() as unknown as T); - } - if ( - typeof val2 === 'string' || - (Array.isArray(val2) ? typeof val2[0] === 'string' : false) - ) { - val2 = Array.isArray(val2) - ? (val2.map((el) => el?.toLowerCase()) as unknown as T) - : ((val2 as string)?.toLowerCase() as unknown as T); - } - if (action === RulePossibility.BIGGER) { - return val1 > val2; - } - if (action === RulePossibility.SMALLER) { - return val1 < val2; - } - if (action === RulePossibility.EQUALS) { - if (!Array.isArray(val1)) { - if (val1 instanceof Date && val2 instanceof Date) { - return ( - new Date(val1?.toDateString()).valueOf() === - new Date(val2?.toDateString()).valueOf() - ); - } - if (typeof val1 === 'boolean') { - return val1 == val2; - } - return val1 === val2; - } else { - if (val1.length > 0) { - return val1?.every((e) => { - e = - typeof e === 'string' - ? (e as unknown as string)?.toLowerCase() - : e; - if (Array.isArray(val2)) { - return (val2 as unknown as T[])?.includes(e); - } else { - return e === val2; - } - }); - } else { - return false; - } - } - } - if (action === RulePossibility.NOT_EQUALS) { - return !this.doRuleAction(val1, val2, RulePossibility.EQUALS); - } - if (action === RulePossibility.CONTAINS) { - try { - if (!Array.isArray(val2)) { - return (val1 as unknown as T[])?.includes(val2); - } else { - if (val2.length > 0) { - return val2?.some((el) => { - return (val1 as unknown as T[])?.includes(el); - }); - } else { - return false; - } - } - } catch (_err) { - return null; - } - } - if (action === RulePossibility.CONTAINS_PARTIAL) { - try { - if (!Array.isArray(val2)) { - // return (val1 as unknown as T[])?.includes(val2); - return ( - (Array.isArray(val1) ? (val1 as unknown as T[]) : [val1])?.some( - (line) => { - return typeof line === 'string' && - val2 != undefined && - String(val2).length > 0 - ? line.includes(String(val2)) - : line == val2 - ? true - : false; - }, - ) || false - ); - } else { - if (val2.length > 0) { - return val2?.some((el) => { - // return (val1 as unknown as T[])?.includes(el); - return ( - (val1 as unknown as T[])?.some((line) => { - return typeof line === 'string' && - el != undefined && - el.length > 0 - ? line.includes(String(el)) - : line == el - ? true - : false; - }) || false - ); - }); - } else { - return false; - } - } - } catch (_err) { - return null; - } - } - if (action === RulePossibility.NOT_CONTAINS) { - return !this.doRuleAction(val1, val2, RulePossibility.CONTAINS); - } - if (action === RulePossibility.NOT_CONTAINS_PARTIAL) { - return !this.doRuleAction(val1, val2, RulePossibility.CONTAINS_PARTIAL); - } - if (action === RulePossibility.BEFORE) { - return val1 && val2 ? val1 <= val2 : false; - } - if (action === RulePossibility.AFTER) { - return val1 && val2 ? val1 >= val2 : false; - } - if (action === RulePossibility.IN_LAST) { - return ( - val1 >= val2 && // time in s - (val1 as unknown as Date) <= new Date() - ); - } - if (action === RulePossibility.IN_NEXT) { - return ( - val1 <= val2 && // time in s - (val1 as unknown as Date) >= new Date() - ); - } - } - private async logInfo(message: string) { this.logger.log(message); } - - private isStringParsableToArray(str: string) { - try { - const array = JSON.parse(str); - return Array.isArray(array); - } catch (error) { - return false; - } - } } diff --git a/server/src/modules/rules/rules.controller.ts b/server/src/modules/rules/rules.controller.ts index 909c932d..8b011bbd 100644 --- a/server/src/modules/rules/rules.controller.ts +++ b/server/src/modules/rules/rules.controller.ts @@ -13,7 +13,6 @@ import { ExclusionAction, ExclusionContextDto } from './dtos/exclusion.dto'; import { RulesDto } from './dtos/rules.dto'; import { RuleExecutorService } from './rule-executor.service'; import { ReturnStatus, RulesService } from './rules.service'; -import { RuleDto } from './dtos/rule.dto'; @Controller('api/rules') export class RulesController { @@ -50,6 +49,11 @@ export class RulesController { return this.rulesService.getRules(id); } + @Get('/collection/:id') + getRuleGroupByCollectionId(@Param('id') id: string) { + return this.rulesService.getRuleGroupByCollectionId(+id); + } + @Get() getRuleGroups( @Query() @@ -176,4 +180,12 @@ export class RulesController { }; } } + + @Post('/test') + async testRuleGroup(@Body() body: { mediaId: string; rulegroupId: number }) { + return this.rulesService.testRuleGroupWithData( + body.rulegroupId, + body.mediaId, + ); + } } diff --git a/server/src/modules/rules/rules.module.ts b/server/src/modules/rules/rules.module.ts index 1bb0624d..82ca50aa 100644 --- a/server/src/modules/rules/rules.module.ts +++ b/server/src/modules/rules/rules.module.ts @@ -23,6 +23,8 @@ import { CommunityRuleKarma } from './entities/community-rule-karma.entities'; import { Settings } from '../settings/entities/settings.entities'; import { RuleMaintenanceService } from './rule-maintenance.service'; import { RuleYamlService } from './helpers/yaml.service'; +import { RuleComparatorService } from './helpers/rule.comparator.service'; +import { RuleConstanstService } from './constants/constants.service'; @Module({ imports: [ @@ -52,6 +54,8 @@ import { RuleYamlService } from './helpers/yaml.service'; OverseerrGetterService, ValueGetterService, RuleYamlService, + RuleComparatorService, + RuleConstanstService ], controllers: [RulesController], }) diff --git a/server/src/modules/rules/rules.service.ts b/server/src/modules/rules/rules.service.ts index fabdf2b7..f8c83ae8 100644 --- a/server/src/modules/rules/rules.service.ts +++ b/server/src/modules/rules/rules.service.ts @@ -26,6 +26,8 @@ import { Settings } from '../settings/entities/settings.entities'; import _ from 'lodash'; import { AddCollectionMedia } from '../collections/interfaces/collection-media.interface'; import { RuleYamlService } from './helpers/yaml.service'; +import { RuleComparatorService } from './helpers/rule.comparator.service'; +import { PlexLibraryItem } from '../api/plex-api/interfaces/library.interfaces'; export interface ReturnStatus { code: 0 | 1; @@ -59,6 +61,7 @@ export class RulesService { private readonly plexApi: PlexApiService, private readonly connection: Connection, private readonly ruleYamlService: RuleYamlService, + private readonly RuleComparatorService: RuleComparatorService, ) { this.ruleConstants = new RuleConstants(); } @@ -146,6 +149,18 @@ export class RulesService { } } + async getRuleGroupByCollectionId(id: number) { + try { + return await this.ruleGroupRepository.findOne({ + where: { collectionId: id }, + }); + } catch (e) { + this.logger.warn(`Rules - Action failed : ${e.message}`); + this.logger.debug(e); + return undefined; + } + } + async deleteRuleGroup(ruleGroupId: number): Promise { try { const group = await this.ruleGroupRepository.findOne({ @@ -709,4 +724,22 @@ export class RulesService { public decodeFromYaml(yaml: string, mediaType: number): ReturnStatus { return this.ruleYamlService.decode(yaml, mediaType); } + + public async testRuleGroupWithData( + rulegroupId: number, + mediaId: string, + ): Promise { + const mediaResp = await this.plexApi.getMetadata(mediaId); + const group = await this.getRuleGroupById(rulegroupId); + if (group && mediaResp) { + group.rules = await this.getRules(group.id.toString()); + const result = await this.RuleComparatorService.executeRulesWithData( + group as RulesDto, + [mediaResp as unknown as PlexLibraryItem], + true, + ); + return { code: 1, result: result.stats }; + } + return { code: 0, result: 'Invalid input' }; + } } diff --git a/server/src/modules/rules/tests/rule-executor.service.doRuleAction.spec.ts b/server/src/modules/rules/tests/rule.comparator.service.doRuleAction.spec.ts similarity index 74% rename from server/src/modules/rules/tests/rule-executor.service.doRuleAction.spec.ts rename to server/src/modules/rules/tests/rule.comparator.service.doRuleAction.spec.ts index 5914d188..e8307717 100644 --- a/server/src/modules/rules/tests/rule-executor.service.doRuleAction.spec.ts +++ b/server/src/modules/rules/tests/rule.comparator.service.doRuleAction.spec.ts @@ -1,33 +1,19 @@ import { TestBed } from '@automock/jest'; import { RulePossibility } from '../constants/rules.constants'; -import { RuleExecutorService } from '../rule-executor.service'; -import { PlexApiService } from '../../api/plex-api/plex-api.service'; -import { CollectionsService } from '../../collections/collections.service'; -import { SettingsService } from '../../settings/settings.service'; -import { TasksService } from '../../tasks/tasks.service'; import { ValueGetterService } from '../getter/getter.service'; -import { RulesService } from '../rules.service'; +import { RuleComparatorService } from '../helpers/rule.comparator.service'; -describe('RuleExecutorService', () => { - let ruleExecutorService: RuleExecutorService; +describe('RuleComparatorService', () => { + let ruleComparatorService: RuleComparatorService; beforeEach(async () => { - const { unit } = TestBed.create(RuleExecutorService) - .mock(RulesService) - .using({ getRuleGroups: jest.fn().mockResolvedValue([]) }) + const { unit } = TestBed.create(RuleComparatorService) + .mock(ValueGetterService) .using({ get: jest.fn() }) - .mock(PlexApiService) - .using({}) - .mock(CollectionsService) - .using({}) - .mock(TasksService) - .using({}) - .mock(SettingsService) - .using({}) .compile(); - ruleExecutorService = unit; + ruleComparatorService = unit; }); describe('doRuleAction', () => { @@ -35,7 +21,7 @@ describe('RuleExecutorService', () => { const val1 = 'abc'; const val2 = 'abc'; const action = RulePossibility.EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -43,7 +29,7 @@ describe('RuleExecutorService', () => { const val1 = 'abc'; const val2 = ''; const action = RulePossibility.EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -51,7 +37,7 @@ describe('RuleExecutorService', () => { const val1 = 'abc'; const val2 = undefined; const action = RulePossibility.EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -59,7 +45,7 @@ describe('RuleExecutorService', () => { const val1 = 'abc'; const val2 = 'abd'; const action = RulePossibility.EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -67,7 +53,7 @@ describe('RuleExecutorService', () => { const val1 = 'abc'; const val2 = 'abc'; const action = RulePossibility.NOT_EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -75,7 +61,7 @@ describe('RuleExecutorService', () => { const val1 = 'abc'; const val2 = 'abd'; const action = RulePossibility.NOT_EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -83,7 +69,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['abc', 'def']; const action = RulePossibility.EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -91,7 +77,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['abc', 'cde']; const action = RulePossibility.EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -99,7 +85,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['abc', 'def']; const action = RulePossibility.NOT_EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -107,7 +93,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['abc', 'cde']; const action = RulePossibility.NOT_EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -115,7 +101,7 @@ describe('RuleExecutorService', () => { const val1 = new Date('2022-01-01'); const val2 = new Date('2022-01-01'); const action = RulePossibility.EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -123,7 +109,7 @@ describe('RuleExecutorService', () => { const val1 = new Date('2022-01-01'); const val2 = new Date('2022-01-02'); const action = RulePossibility.EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -131,7 +117,7 @@ describe('RuleExecutorService', () => { const val1 = new Date('2022-01-01'); const val2 = new Date('2022-01-01'); const action = RulePossibility.NOT_EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -139,7 +125,7 @@ describe('RuleExecutorService', () => { const val1 = new Date('2022-01-01'); const val2 = new Date('2022-01-02'); const action = RulePossibility.NOT_EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -147,7 +133,7 @@ describe('RuleExecutorService', () => { const val1 = 5; const val2 = 5; const action = RulePossibility.EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -155,7 +141,7 @@ describe('RuleExecutorService', () => { const val1 = 5; const val2 = 4; const action = RulePossibility.EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -163,7 +149,7 @@ describe('RuleExecutorService', () => { const val1 = 5; const val2 = 5; const action = RulePossibility.NOT_EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -171,7 +157,7 @@ describe('RuleExecutorService', () => { const val1 = 5; const val2 = 4; const action = RulePossibility.NOT_EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -179,7 +165,7 @@ describe('RuleExecutorService', () => { const val1 = 'abc'; const val2 = 'ab'; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -187,7 +173,7 @@ describe('RuleExecutorService', () => { const val1 = 'abc'; const val2 = 'de'; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -195,7 +181,7 @@ describe('RuleExecutorService', () => { const val1 = 'abc'; const val2 = 'de'; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -203,7 +189,7 @@ describe('RuleExecutorService', () => { const val1 = 'abc'; const val2 = 'ab'; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -211,7 +197,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['abc']; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -219,7 +205,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['abc']; const action = RulePossibility.CONTAINS_PARTIAL; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -227,7 +213,7 @@ describe('RuleExecutorService', () => { const val1 = ['ImDb top 250', 'My birthday', 'jef']; const val2 = ['imdb']; const action = RulePossibility.CONTAINS_PARTIAL; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -235,7 +221,7 @@ describe('RuleExecutorService', () => { const val1 = ['ImDb top 250', 'My birthday', 'jef']; const val2 = ['imdb']; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -243,7 +229,7 @@ describe('RuleExecutorService', () => { const val1 = ['ImDb top 250', 'My birthday', 'jef']; const val2 = ['jos']; const action = RulePossibility.CONTAINS_PARTIAL; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -251,7 +237,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['']; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -259,7 +245,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['']; const action = RulePossibility.CONTAINS_PARTIAL; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -267,7 +253,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['ral', undefined, 'rel']; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -275,7 +261,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['ral', undefined, 'rel']; const action = RulePossibility.CONTAINS_PARTIAL; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -283,7 +269,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['ral', undefined, 'abc']; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -291,7 +277,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['ral', undefined, 'abc']; const action = RulePossibility.CONTAINS_PARTIAL; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -299,7 +285,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['ral', undefined, 'ab']; const action = RulePossibility.CONTAINS_PARTIAL; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -307,7 +293,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['ghi']; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -315,7 +301,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['ghi']; const action = RulePossibility.NOT_CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -323,7 +309,7 @@ describe('RuleExecutorService', () => { const val1 = ['ImDb top 250', 'My birthday', 'jef']; const val2 = ['ImDb']; const action = RulePossibility.NOT_CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -331,7 +317,7 @@ describe('RuleExecutorService', () => { const val1 = ['ImDb top 250', 'My birthday', 'jef']; const val2 = ['ImDb']; const action = RulePossibility.NOT_CONTAINS_PARTIAL; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -339,7 +325,7 @@ describe('RuleExecutorService', () => { const val1 = ['ImDb top 250', 'My birthday', 'jef']; const val2 = ['Jos']; const action = RulePossibility.NOT_CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -347,7 +333,7 @@ describe('RuleExecutorService', () => { const val1 = ['ImDb top 250', 'My birthday', 'jef']; const val2 = ['Jos']; const action = RulePossibility.NOT_CONTAINS_PARTIAL; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -355,7 +341,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['abc']; const action = RulePossibility.NOT_CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -363,7 +349,7 @@ describe('RuleExecutorService', () => { const val1 = ['abc', 'def']; const val2 = ['abc']; const action = RulePossibility.NOT_CONTAINS_PARTIAL; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -371,7 +357,7 @@ describe('RuleExecutorService', () => { const val1 = [1, 2, 3, 4]; const val2 = [6]; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -379,7 +365,7 @@ describe('RuleExecutorService', () => { const val1 = [1, 2, 3, 4]; const val2 = [6]; const action = RulePossibility.CONTAINS_PARTIAL; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -387,7 +373,7 @@ describe('RuleExecutorService', () => { const val1 = [1, 2, 3, 4]; const val2 = [6]; const action = RulePossibility.NOT_CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -395,7 +381,7 @@ describe('RuleExecutorService', () => { const val1 = [1, 2, 3, 4]; const val2 = [5, undefined, 6]; const action = RulePossibility.NOT_CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -403,7 +389,7 @@ describe('RuleExecutorService', () => { const val1 = [1, 2, 3, 4]; const val2 = [3]; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -411,7 +397,7 @@ describe('RuleExecutorService', () => { const val1 = [1, 2, 3, 4]; const val2 = [3]; const action = RulePossibility.NOT_CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -419,7 +405,7 @@ describe('RuleExecutorService', () => { const val1 = [1, 2, 3, 4]; const val2 = [6, 5]; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -427,7 +413,7 @@ describe('RuleExecutorService', () => { const val1 = [1, 2, 3, 4]; const val2 = [3, 1]; const action = RulePossibility.CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -435,7 +421,7 @@ describe('RuleExecutorService', () => { const val1 = [1, 2, 3, 4]; const val2 = [3, 5]; const action = RulePossibility.NOT_CONTAINS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -443,7 +429,7 @@ describe('RuleExecutorService', () => { const val1 = 5; const val2 = 3; const action = RulePossibility.BIGGER; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -451,7 +437,7 @@ describe('RuleExecutorService', () => { const val1 = 5; const val2 = 3; const action = RulePossibility.SMALLER; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -459,7 +445,7 @@ describe('RuleExecutorService', () => { const val1 = 5; const val2 = undefined; const action = RulePossibility.SMALLER; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -467,7 +453,7 @@ describe('RuleExecutorService', () => { const val1 = 5; const val2 = 5; const action = RulePossibility.EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -475,7 +461,7 @@ describe('RuleExecutorService', () => { const val1 = 5; const val2 = 5; const action = RulePossibility.NOT_EQUALS; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -483,7 +469,7 @@ describe('RuleExecutorService', () => { const val1 = new Date('2022-01-01'); const val2 = new Date('2022-01-02'); const action = RulePossibility.BEFORE; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -491,7 +477,7 @@ describe('RuleExecutorService', () => { const val1 = new Date('2022-01-01'); const val2 = undefined; const action = RulePossibility.BEFORE; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -499,7 +485,7 @@ describe('RuleExecutorService', () => { const val1 = new Date('2022-01-03'); const val2 = new Date('2022-01-02'); const action = RulePossibility.BEFORE; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -507,7 +493,7 @@ describe('RuleExecutorService', () => { const val1 = new Date('2022-01-03'); const val2 = new Date('2022-01-02'); const action = RulePossibility.AFTER; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -515,7 +501,7 @@ describe('RuleExecutorService', () => { const val1 = new Date('2022-01-01'); const val2 = new Date('2022-01-02'); const action = RulePossibility.AFTER; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -523,7 +509,7 @@ describe('RuleExecutorService', () => { const val1 = new Date('2022-01-01'); const val2 = undefined; const action = RulePossibility.AFTER; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -531,7 +517,7 @@ describe('RuleExecutorService', () => { const val1 = new Date(Date.now() - 1000); // One second ago const val2 = new Date(new Date().getTime() - +3600 * 1000); // 1 hour in seconds const action = RulePossibility.IN_LAST; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -539,7 +525,7 @@ describe('RuleExecutorService', () => { const val1 = new Date(Date.now() - 3600 * 2000); // More than 1 hour ago const val2 = new Date(new Date().getTime() - +3600 * 1000); const action = RulePossibility.IN_LAST; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -547,7 +533,7 @@ describe('RuleExecutorService', () => { const val1 = new Date(Date.now() - 3600 * 2000); // More than 1 hour ago const val2 = undefined; const action = RulePossibility.IN_LAST; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -555,7 +541,7 @@ describe('RuleExecutorService', () => { const val1 = new Date(new Date().getTime() + +432000 * 1000); // 5 days from now const val2 = new Date(new Date().getTime() + +864000 * 1000); // 10 days from now const action = RulePossibility.IN_NEXT; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(true); }); @@ -563,7 +549,7 @@ describe('RuleExecutorService', () => { const val1 = new Date(new Date().getTime() + +865000 * 1000); // More than 10 days from now const val2 = new Date(new Date().getTime() + +864000 * 1000); // 10 days from now const action = RulePossibility.IN_NEXT; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); @@ -571,7 +557,7 @@ describe('RuleExecutorService', () => { const val1 = new Date(new Date().getTime() + +865000 * 1000); // More than 10 days from now const val2 = undefined; // 10 days from now const action = RulePossibility.IN_NEXT; - const result = ruleExecutorService['doRuleAction'](val1, val2, action); + const result = ruleComparatorService['doRuleAction'](val1, val2, action); expect(result).toBe(false); }); }); diff --git a/ui/src/components/AddModal/index.tsx b/ui/src/components/AddModal/index.tsx index 8ddfcd9e..af62392f 100644 --- a/ui/src/components/AddModal/index.tsx +++ b/ui/src/components/AddModal/index.tsx @@ -49,8 +49,8 @@ const AddModal = (props: IAddModal) => { return props.type === 1 ? -1 : selectedEpisodes !== -1 - ? selectedEpisodes - : selectedSeasons + ? selectedEpisodes + : selectedSeasons }, [selectedSeasons, selectedEpisodes]) const selectedContext = useMemo(() => { @@ -58,8 +58,8 @@ const AddModal = (props: IAddModal) => { ? selectedEpisodes !== -1 ? EPlexDataType.EPISODES : selectedSeasons !== -1 - ? EPlexDataType.SEASONS - : EPlexDataType.SHOWS + ? EPlexDataType.SEASONS + : EPlexDataType.SHOWS : EPlexDataType.MOVIES }, [selectedSeasons, selectedEpisodes]) @@ -165,6 +165,8 @@ const AddModal = (props: IAddModal) => { setLoading(false) }, ) + } else { + setSelectedEpisodes(-1) } }, [selectedSeasons]) @@ -180,31 +182,31 @@ const AddModal = (props: IAddModal) => { setLoading(false) }) : selectedSeasons !== -1 - ? GetApiHandler(`/collections?typeId=3`).then((resp) => { - // get collections for episodes and seasons - GetApiHandler(`/collections?typeId=4`).then((resp2) => { - setCollectionOptions([ - ...origCollectionOptions, - ...resp, - ...resp2, - ]) - setLoading(false) - }) - }) - : GetApiHandler(`/collections?typeId=2`).then((resp) => { - // get collections for episodes, seasons and shows - GetApiHandler(`/collections?typeId=3`).then((resp2) => { - GetApiHandler(`/collections?typeId=4`).then((resp3) => { + ? GetApiHandler(`/collections?typeId=3`).then((resp) => { + // get collections for episodes and seasons + GetApiHandler(`/collections?typeId=4`).then((resp2) => { setCollectionOptions([ ...origCollectionOptions, ...resp, ...resp2, - ...resp3, ]) setLoading(false) }) }) - }) + : GetApiHandler(`/collections?typeId=2`).then((resp) => { + // get collections for episodes, seasons and shows + GetApiHandler(`/collections?typeId=3`).then((resp2) => { + GetApiHandler(`/collections?typeId=4`).then((resp3) => { + setCollectionOptions([ + ...origCollectionOptions, + ...resp, + ...resp2, + ...resp3, + ]) + setLoading(false) + }) + }) + }) : GetApiHandler(`/collections?typeId=1`).then((resp) => { // get collections for movies setCollectionOptions([...origCollectionOptions, ...resp]) @@ -219,7 +221,7 @@ const AddModal = (props: IAddModal) => { onCancel={handleCancel} onOk={handleOk} okDisabled={false} - title={props.modalType === 'add' ? 'Add / Remove media' : 'Exclude media'} + title={props.modalType === 'add' ? 'Add / Remove Media' : 'Exclude Media'} okText={'Submit'} okButtonType={'primary'} onSecondary={() => {}} @@ -242,7 +244,7 @@ const AddModal = (props: IAddModal) => { onCancel={() => setForceRemovalCheck(false)} onOk={handleForceRemoval} okDisabled={false} - title={'Confirmation required'} + title={'Confirmation Required'} okText={'Submit'} > Are you certain you want to proceed? This action will remove the{' '} diff --git a/ui/src/components/Collection/CollectionDetail/TestMediaItem/index.tsx b/ui/src/components/Collection/CollectionDetail/TestMediaItem/index.tsx new file mode 100644 index 00000000..37567fbd --- /dev/null +++ b/ui/src/components/Collection/CollectionDetail/TestMediaItem/index.tsx @@ -0,0 +1,282 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import Modal from '../../../Common/Modal' +import SearchMediaItem, { IMediaOptions } from '../../../Common/SearchMediaITem' +import { EPlexDataType } from '../../../../utils/PlexDataType-enum' +import FormItem from '../../../Common/FormItem' +import GetApiHandler, { PostApiHandler } from '../../../../utils/ApiHandler' +import { Editor } from '@monaco-editor/react' +import YAML from 'yaml' +import Alert from '../../../Common/Alert' + +interface ITestMediaItem { + onCancel: () => void + onSubmit: () => void + collectionId: number +} + +interface IOptions { + id: number + title: string +} + +interface IComparisonResult { + code: 1 | 0 + result: any +} + +const TestMediaItem = (props: ITestMediaItem) => { + const [mediaItem, setMediaItem] = useState() + const [loading, setLoading] = useState(true) + const [ruleGroup, setRuleGroup] = useState<{ + dataType: EPlexDataType + id: String + }>() + const [selectedSeasons, setSelectedSeasons] = useState(-1) + const [selectedEpisodes, setSelectedEpisodes] = useState(-1) + const [seasonOptions, setSeasonOptions] = useState([ + { + id: -1, + title: '-', + }, + ]) + const [episodeOptions, setEpisodeOptions] = useState([ + { + id: -1, + title: '-', + }, + ]) + const [comparisonResult, setComparisonResult] = useState() + const editorRef = useRef(undefined) + + const testable = useMemo(() => { + // if the rulegroup is available + if (ruleGroup) { + // if movies or shows & mediaitem is selected + if ( + (ruleGroup?.dataType === EPlexDataType.MOVIES || + ruleGroup?.dataType === EPlexDataType.SHOWS) && + mediaItem + ) { + return true + } + + // if seasons & mediaitem & season is selected + else if ( + ruleGroup?.dataType === EPlexDataType.SEASONS && + mediaItem && + selectedSeasons !== -1 + ) { + return true + } + // if episodes a mediaitem, season & episode is selected + else if ( + ruleGroup?.dataType === EPlexDataType.EPISODES && + mediaItem && + selectedSeasons !== -1 && + selectedEpisodes !== -1 + ) { + return true + } + } + // all other cases = false + return false + }, [mediaItem, selectedSeasons, selectedEpisodes]) + + function handleEditorDidMount(editor: any, monaco: any) { + editorRef.current = editor + } + + useEffect(() => { + setSelectedSeasons(-1) + setSelectedEpisodes(-1) + if (mediaItem && mediaItem.type == EPlexDataType.SHOWS) { + // get seasons + GetApiHandler(`/plex/meta/${mediaItem.id}/children`).then( + (resp: [{ ratingKey: number; title: string }]) => { + setSeasonOptions([ + { + id: -1, + title: '-', + }, + ...resp.map((el) => { + return { + id: el.ratingKey, + title: el.title, + } + }), + ]) + }, + ) + } + }, [mediaItem]) + + useEffect(() => { + if (selectedSeasons !== -1) { + // get episodes + GetApiHandler(`/plex/meta/${selectedSeasons}/children`).then( + (resp: [{ ratingKey: number; index: number }]) => { + setEpisodeOptions([ + { + id: -1, + title: '-', + }, + ...resp.map((el) => { + return { + id: el.ratingKey, + title: `Episode ${el.index}`, + } + }), + ]) + }, + ) + } else { + setSelectedEpisodes(-1) + } + }, [selectedSeasons]) + + const onSubmit = async () => { + if (ruleGroup) { + const result = await PostApiHandler(`/rules/test`, { + rulegroupId: ruleGroup.id, + mediaId: selectedMediaId, + }) + setComparisonResult(result) + } + } + + useEffect(() => { + GetApiHandler(`/rules/collection/${props.collectionId}`).then((resp) => { + setRuleGroup(resp), setLoading(false) + }) + }, []) + + const selectedMediaId = useMemo(() => { + if (mediaItem) { + return selectedEpisodes !== -1 + ? selectedEpisodes + : selectedSeasons !== -1 + ? selectedSeasons + : mediaItem?.id + } + }, [selectedSeasons, selectedEpisodes, mediaItem]) + + useEffect(() => { + if (editorRef.current) { + ;(editorRef.current as any).setValue('') + setComparisonResult(undefined) + } + }, [selectedMediaId]) + + return !loading && ruleGroup ? ( +
+ props.onCancel()} + cancelText="Close" + okDisabled={!testable} + onOk={() => onSubmit()} + okText={'Test'} + okButtonType={'primary'} + title={'Test Media'} + iconSvg={''} + > +
+
+ + {`Search for media items and validate them against the specified rule. The result will be a YAML document containing the validated steps. + `} +
+
+ {`The rule group is of type ${ + ruleGroup.dataType === EPlexDataType.MOVIES + ? 'movies' + : ruleGroup.dataType === EPlexDataType.SEASONS + ? 'seasons' + : ruleGroup.dataType === EPlexDataType.EPISODES + ? 'episodes' + : 'series' + }, as a result only media of type ${ + ruleGroup.dataType === EPlexDataType.MOVIES + ? 'movies' + : 'series' + } will be displayed in the searchbar.`} +
+
+ + { + setMediaItem(el as unknown as IMediaOptions) + }} + /> + + + {/* seasons */} +
+ {ruleGroup.dataType === EPlexDataType.SEASONS || + ruleGroup.dataType === EPlexDataType.EPISODES ? ( + + + + ) : undefined} + + {ruleGroup.dataType === EPlexDataType.EPISODES ? ( + // episodes + + + + ) : undefined} +
+ + +
+ +
+
+
+
+ ) : undefined +} + +export default TestMediaItem diff --git a/ui/src/components/Collection/CollectionDetail/index.tsx b/ui/src/components/Collection/CollectionDetail/index.tsx index dc9b4847..13736a36 100644 --- a/ui/src/components/Collection/CollectionDetail/index.tsx +++ b/ui/src/components/Collection/CollectionDetail/index.tsx @@ -1,11 +1,11 @@ -import { RewindIcon } from '@heroicons/react/solid' +import { PlayIcon } from '@heroicons/react/solid' import Router from 'next/router' -import { SetStateAction, useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { ICollection, ICollectionMedia } from '..' import GetApiHandler from '../../../utils/ApiHandler' import OverviewContent, { IPlexMetadata } from '../../Overview/Content' import _ from 'lodash' -import { SmallLoadingSpinner } from '../../Common/LoadingSpinner' +import TestMediaItem from './TestMediaItem' interface ICollectionDetail { libraryId: number @@ -16,10 +16,11 @@ interface ICollectionDetail { const CollectionDetail: React.FC = ( // TODO: this component uses it's own lazy loading mechanism instead of the one from OverviewContent. Update this. - props: ICollectionDetail + props: ICollectionDetail, ) => { const [data, setData] = useState([]) const [media, setMedia] = useState([]) + const [mediaTestModalOpen, setMediaTestModalOpen] = useState(false) // paging const pageData = useRef(0) const fetchAmount = 25 @@ -60,7 +61,7 @@ const CollectionDetail: React.FC = ( return () => { window.removeEventListener( 'scroll', - _.debounce(handleScroll.bind(this), 200) + _.debounce(handleScroll.bind(this), 200), ) } }, []) @@ -77,7 +78,7 @@ const CollectionDetail: React.FC = ( // setLoading(true) const resp: { totalSize: number; items: ICollectionMedia[] } = await GetApiHandler( - `/collections/media/${props.collection.id}/content/${pageData.current}?size=${fetchAmount}` + `/collections/media/${props.collection.id}/content/${pageData.current}?size=${fetchAmount}`, ) setTotalSize(resp.totalSize) @@ -87,7 +88,7 @@ const CollectionDetail: React.FC = ( setData([ ...dataRef.current, ...resp.items.map((el) => - el.plexData ? el.plexData : ({} as IPlexMetadata) + el.plexData ? el.plexData : ({} as IPlexMetadata), ), ]) loadingRef.current = false @@ -138,15 +139,14 @@ const CollectionDetail: React.FC = ( {`${props.title}`} - {/* -
-
- {}, 5000)} - text="Handle collection" - /> -
-
*/} + +
= ( })} />
+ + {mediaTestModalOpen && props.collection?.id ? ( + { + setMediaTestModalOpen(false) + }} + onSubmit={() => {}} + /> + ) : undefined} ) } diff --git a/ui/src/components/Collection/CollectionItem/index.tsx b/ui/src/components/Collection/CollectionItem/index.tsx index 9a09d7f5..8a5027ca 100644 --- a/ui/src/components/Collection/CollectionItem/index.tsx +++ b/ui/src/components/Collection/CollectionItem/index.tsx @@ -13,90 +13,99 @@ const CollectionItem = (props: ICollectionItem) => { return ( <> - {/*
*/} - {props.collection.media && props.collection.media.length > 1 ? ( -
- - -
-
- ) : undefined} -
-
- props.onClick(props.collection)} - {...(props.onClick - ? { onClick: () => props.onClick!(props.collection) } - : {})} - > + props.onClick(props.collection)} + {...(props.onClick + ? { onClick: () => props.onClick!(props.collection) } + : {})} + > + {/*
*/} + {props.collection.media && props.collection.media.length > 1 ? ( +
+ + +
+
+ ) : undefined} +
+ +
{props.collection.manualCollection - ? `${props.collection.manualCollectionName} (manual)` - : props.collection.title} - -
-
- {props.collection.manualCollection - ? `Handled by rule: '${props.collection.title}'` - : props.collection.description} + ? `Handled by rule: '${props.collection.title}'` + : props.collection.description} +
-
-
-
-
-

Library

-

- {' '} - { - LibrariesCtx.libraries.find( - (el) => +el.key === +props.collection.libraryId, - )?.title - } -

-
+
+
+
+

Library

+

+ {' '} + { + LibrariesCtx.libraries.find( + (el) => +el.key === +props.collection.libraryId, + )?.title + } +

+
-
-

Items

-

- {' '} - {`${props.collection.media ? props.collection.media.length : 0}`} -

+
+

Items

+

+ {' '} + {`${ + props.collection.media ? props.collection.media.length : 0 + }`} +

+
-
-
-
-

Status

-

- {' '} - {props.collection.isActive ? ( - Active - ) : ( - Inactive - )} -

-
+
+
+

Status

+

+ {' '} + {props.collection.isActive ? ( + Active + ) : ( + Inactive + )} +

+
-
-

Delete

-

{` After ${props.collection.deleteAfterDays} days`}

+
+

Delete

+

{` After ${props.collection.deleteAfterDays} days`}

+
-
- {/*
*/} + ) } diff --git a/ui/src/components/Collection/CollectionOverview/index.tsx b/ui/src/components/Collection/CollectionOverview/index.tsx index 585f0f4b..ce2de5cc 100644 --- a/ui/src/components/Collection/CollectionOverview/index.tsx +++ b/ui/src/components/Collection/CollectionOverview/index.tsx @@ -20,7 +20,7 @@ const CollectionOverview = (props: ICollectionOverview) => {
diff --git a/ui/src/components/Common/CommunityRuleModal/index.tsx b/ui/src/components/Common/CommunityRuleModal/index.tsx index a21206a2..7f106c95 100644 --- a/ui/src/components/Common/CommunityRuleModal/index.tsx +++ b/ui/src/components/Common/CommunityRuleModal/index.tsx @@ -55,7 +55,7 @@ const CommunityRuleModal = (props: ICommunityRuleModal) => { resp = resp.filter( (e) => e.appVersion!.replaceAll('.', '') <= - appVersion.current.replaceAll('.', '') && e.type === props.type + appVersion.current.replaceAll('.', '') && e.type === props.type, ) resp = resp.sort((a, b) => b.karma! - a.karma!) setCommunityRules(resp) @@ -64,7 +64,7 @@ const CommunityRuleModal = (props: ICommunityRuleModal) => { } else { setCommunityRules([]) console.log( - 'An error occurred fetching community rules. Does Maintainerr have privileges to access the internet?' + 'An error occurred fetching community rules. Does Maintainerr have privileges to access the internet?', ) } } else { @@ -100,7 +100,7 @@ const CommunityRuleModal = (props: ICommunityRuleModal) => { } else { console.log(`Couldn't fetch community rule Karma history.`) } - } + }, ) } @@ -137,8 +137,8 @@ const CommunityRuleModal = (props: ICommunityRuleModal) => { originalRules.filter( (el) => el.name.toLowerCase().includes(input.trim().toLowerCase()) || - el.description.toLowerCase().includes(input.trim().toLowerCase()) - ) + el.description.toLowerCase().includes(input.trim().toLowerCase()), + ), ) } } @@ -154,7 +154,7 @@ const CommunityRuleModal = (props: ICommunityRuleModal) => { e.karma += 10 } return e - }) + }), ) setHistory([{ id: history.length, community_rule_id: id }, ...history]) } @@ -171,7 +171,7 @@ const CommunityRuleModal = (props: ICommunityRuleModal) => { e.karma -= 10 } return e - }) + }), ) setHistory([{ id: history.length, community_rule_id: id }, ...history]) } @@ -212,10 +212,9 @@ const CommunityRuleModal = (props: ICommunityRuleModal) => { iconSvg={''} >
- + + {`Import rules made by the community. This will override your current rules`} +
{originalRules.length > 0 ? ( @@ -242,7 +241,7 @@ const CommunityRuleModal = (props: ICommunityRuleModal) => { onDoubleClick={handleInfo} thumbsActive={ history.find( - (e) => e.community_rule_id === cr.id + (e) => e.community_rule_id === cr.id, ) === undefined } onThumbsUp={handleThumbsUp} diff --git a/ui/src/components/Common/SearchMediaITem/index.tsx b/ui/src/components/Common/SearchMediaITem/index.tsx new file mode 100644 index 00000000..7f988515 --- /dev/null +++ b/ui/src/components/Common/SearchMediaITem/index.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import AsyncSelect from 'react-select/async' +import GetApiHandler from '../../../utils/ApiHandler' +import { IPlexMetadata } from '../../Overview/Content' +import { EPlexDataType } from '../../../utils/PlexDataType-enum' +import { SingleValue } from 'react-select/dist/declarations/src' +import { MediaType } from '../../../contexts/constants-context' + +export interface IMediaOptions { + id: string + name: string + type: EPlexDataType +} + +interface ISearchMediaITem { + onChange: (item: SingleValue) => void + mediatype?: EPlexDataType +} + +const SearchMediaItem = (props: ISearchMediaITem) => { + const loadData = async (query: string): Promise => { + // load your data using query + const resp: IPlexMetadata[] = await GetApiHandler(`/plex//search/${query}`) + const output = resp.map((el) => { + return { + id: el.ratingKey, + name: el.title, + type: el.type === 'movie' ? 1 : 2, + } as unknown as IMediaOptions + }) + + if (props.mediatype) { + const type = + props.mediatype !== EPlexDataType.MOVIES && + props.mediatype !== EPlexDataType.SHOWS + ? 2 + : props.mediatype + return output.filter((el) => el.type === type) + } + + return output + } + + return ( + <> + option.name} + getOptionValue={(option: IMediaOptions) => option.id} + defaultValue={[]} + defaultOptions={undefined} + loadOptions={loadData} + placeholder="Start typing... " + onChange={(selectedItem) => { + props.onChange(selectedItem) + }} + /> + + ) +} + +export default SearchMediaItem diff --git a/ui/src/components/Common/YamlImporterModal/index.tsx b/ui/src/components/Common/YamlImporterModal/index.tsx index e05a88f6..831f8853 100644 --- a/ui/src/components/Common/YamlImporterModal/index.tsx +++ b/ui/src/components/Common/YamlImporterModal/index.tsx @@ -1,6 +1,7 @@ import { useRef } from 'react' import Modal from '../Modal' import Editor from '@monaco-editor/react' +import Alert from '../Alert' export interface IYamlImporterModal { onImport: (yaml: string) => void @@ -44,10 +45,21 @@ const YamlImporterModal = (props: IYamlImporterModal) => { } okText={props.yaml ? 'Download' : 'Import'} okButtonType={'primary'} - title={'Rule Yaml editor'} + title={'Yaml Rule Editor'} iconSvg={''} > + + {`${ + props.yaml + ? 'Export your rules to a YAML document' + : 'Import rules from a YAML document. This will override your current rules' + }`} + { title={'Upload Successful'} iconSvg={''} > - + ) : undefined} diff --git a/ui/src/components/Rules/Rule/RuleCreator/index.tsx b/ui/src/components/Rules/Rule/RuleCreator/index.tsx index 77450b50..b88661b0 100644 --- a/ui/src/components/Rules/Rule/RuleCreator/index.tsx +++ b/ui/src/components/Rules/Rule/RuleCreator/index.tsx @@ -285,7 +285,7 @@ const RuleCreator = (props: iRuleCreator) => { > {}

- New section + New Section

diff --git a/ui/src/components/Rules/RuleGroup/AddModal/index.tsx b/ui/src/components/Rules/RuleGroup/AddModal/index.tsx index 68355d1b..061e91a1 100644 --- a/ui/src/components/Rules/RuleGroup/AddModal/index.tsx +++ b/ui/src/components/Rules/RuleGroup/AddModal/index.tsx @@ -700,12 +700,12 @@ const AddModal = (props: AddModal) => { }

- Community Rules + Community

-
+