diff --git a/apps/server/src/app/config/databasePath.ts b/apps/server/src/app/config/databasePath.ts new file mode 100644 index 00000000..b38ae4b3 --- /dev/null +++ b/apps/server/src/app/config/databasePath.ts @@ -0,0 +1,43 @@ +import * as fs from 'fs' +import path from 'path' + +export const dataDir = + process.env.NODE_ENV === 'production' + ? '/opt/data' + : path.join(__dirname, '../../../../../data') + +const defaultDatabaseName = 'maintainerr.sqlite' +const databaseFilenamePattern = /^maintainerr.*\.sqlite$/i + +export const resolveDatabasePath = () => { + const defaultDatabasePath = path.join(dataDir, defaultDatabaseName) + + if (fs.existsSync(defaultDatabasePath)) { + return defaultDatabasePath + } + + if (!fs.existsSync(dataDir)) { + return defaultDatabasePath + } + + const matchingDatabases = fs + .readdirSync(dataDir, { withFileTypes: true }) + .filter( + (entry) => entry.isFile() && databaseFilenamePattern.test(entry.name), + ) + .map((entry) => entry.name) + + if (matchingDatabases.length === 0) { + return defaultDatabasePath + } + + if (matchingDatabases.length > 1) { + throw new Error( + `Multiple Maintainerr database files found in ${dataDir}: ${matchingDatabases.join( + ', ', + )}. Rename the database you want to use to ${defaultDatabaseName}.`, + ) + } + + return path.join(dataDir, matchingDatabases[0]) +} diff --git a/apps/server/src/app/config/typeOrmConfig.ts b/apps/server/src/app/config/typeOrmConfig.ts index 39d8e686..884d504d 100644 --- a/apps/server/src/app/config/typeOrmConfig.ts +++ b/apps/server/src/app/config/typeOrmConfig.ts @@ -1,12 +1,10 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm' +import { resolveDatabasePath } from './databasePath' const ormConfig: TypeOrmModuleOptions = { type: 'better-sqlite3', logging: false, - database: - process.env.NODE_ENV === 'production' - ? '/opt/data/maintainerr.sqlite' - : '../../data/maintainerr.sqlite', + database: resolveDatabasePath(), subscribers: ['./**/*.subscriber{.ts,.js}'], migrations: process.env.NODE_ENV === 'production' diff --git a/apps/server/src/datasource-config.ts b/apps/server/src/datasource-config.ts index e3f26d3f..f394a803 100644 --- a/apps/server/src/datasource-config.ts +++ b/apps/server/src/datasource-config.ts @@ -1,8 +1,9 @@ import { DataSource } from 'typeorm' +import { resolveDatabasePath } from './app/config/databasePath' const datasource = new DataSource({ type: 'better-sqlite3', - database: '../../data/maintainerr.sqlite', + database: resolveDatabasePath(), entities: ['./src/**/*.entities.ts'], synchronize: false, migrationsTableName: 'migrations', diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index f5d2b051..5db496f6 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -6,13 +6,9 @@ import * as fs from 'fs' import { cleanupOpenApiDoc } from 'nestjs-zod' import path from 'path' import { AppModule } from './app/app.module' +import { dataDir, resolveDatabasePath } from './app/config/databasePath' import { MaintainerrLogger } from './modules/logging/logs.service' -const dataDir = - process.env.NODE_ENV === 'production' - ? '/opt/data' - : path.join(__dirname, '../../../data') - async function bootstrap() { const app = await NestFactory.create(AppModule, { bufferLogs: true, @@ -60,7 +56,7 @@ function createDataDirectoryStructure() { } // if db already exists, check r/w permissions - const db = path.join(dataDir, 'maintainerr.sqlite') + const db = resolveDatabasePath() if (fs.existsSync(db)) { fs.accessSync(db, fs.constants.R_OK | fs.constants.W_OK) } diff --git a/apps/server/src/modules/rules/tasks/rule-executor.service.spec.ts b/apps/server/src/modules/rules/tasks/rule-executor.service.spec.ts index 3f02f072..f6fede1a 100644 --- a/apps/server/src/modules/rules/tasks/rule-executor.service.spec.ts +++ b/apps/server/src/modules/rules/tasks/rule-executor.service.spec.ts @@ -58,6 +58,13 @@ describe('RuleExecutorService', () => { const settings = { media_server_type: mediaServerType, testConnections: jest.fn().mockResolvedValue(true), + testMediaServerConnection: jest.fn().mockResolvedValue(true), + testRadarr: jest.fn().mockResolvedValue({ status: 'OK' }), + testSonarr: jest.fn().mockResolvedValue({ status: 'OK' }), + testSeerr: jest.fn().mockResolvedValue({ status: 'OK' }), + testTautulli: jest.fn().mockResolvedValue({ status: 'OK' }), + seerrConfigured: jest.fn().mockReturnValue(true), + tautulliConfigured: jest.fn().mockReturnValue(true), testSetup: jest.fn().mockResolvedValue(true), } as unknown as jest.Mocked @@ -250,4 +257,47 @@ describe('RuleExecutorService', () => { ) expect(progressManager.reset).not.toHaveBeenCalled() }) + + it('only checks the selected Radarr server when executing a Radarr rule group', async () => { + const { service, rulesService, settings } = createService( + MediaServerType.PLEX, + ) + + rulesService.getRuleGroup.mockResolvedValue({ + id: 77, + name: 'Movies', + isActive: true, + libraryId: '1', + dataType: 'movie', + useRules: true, + collection: { + radarrSettingsId: 1, + }, + rules: [ + { + ruleJson: JSON.stringify({ + firstVal: [1, 0], + action: 0, + customVal: { + ruleTypeId: 0, + value: 1, + }, + }), + }, + ], + } as any) + rulesService.getRuleGroupById.mockResolvedValue({ + id: 77, + collectionId: 1, + } as any) + + const abortController = new AbortController() + + await service.executeForRuleGroups(77, abortController.signal) + + expect(settings.testRadarr).toHaveBeenCalledTimes(1) + expect(settings.testRadarr).toHaveBeenCalledWith(1) + expect(settings.testSonarr).not.toHaveBeenCalled() + expect(settings.testConnections).not.toHaveBeenCalled() + }) }) diff --git a/apps/server/src/modules/rules/tasks/rule-executor.service.ts b/apps/server/src/modules/rules/tasks/rule-executor.service.ts index bfae7660..3273fe81 100644 --- a/apps/server/src/modules/rules/tasks/rule-executor.service.ts +++ b/apps/server/src/modules/rules/tasks/rule-executor.service.ts @@ -22,7 +22,8 @@ import { } from '../../events/events.dto' import { MaintainerrLogger } from '../../logging/logs.service' import { SettingsService } from '../../settings/settings.service' -import { RuleConstants } from '../constants/rules.constants' +import { Application, RuleConstants } from '../constants/rules.constants' +import { RuleDto } from '../dtos/rule.dto' import { RulesDto } from '../dtos/rules.dto' import { RuleGroup } from '../entities/rule-group.entities' import { RuleComparatorServiceFactory } from '../helpers/rule.comparator.service' @@ -110,7 +111,7 @@ export class RuleExecutorService { return } - const appStatus = await this.settings.testConnections() + const appStatus = await this.testRuleGroupConnections(ruleGroup) if (appStatus) { // reset API caches, make sure latest data is used @@ -340,6 +341,70 @@ export class RuleExecutorService { } } + private async testRuleGroupConnections(ruleGroup: RulesDto) { + if (!(await this.settings.testMediaServerConnection())) { + return false + } + + const referencedApplications = this.getReferencedApplications(ruleGroup) + const selectedRadarrSettingsId = ruleGroup.collection?.radarrSettingsId + const selectedSonarrSettingsId = ruleGroup.collection?.sonarrSettingsId + + if ( + (referencedApplications.has(Application.RADARR) || + selectedRadarrSettingsId != null) && + (!selectedRadarrSettingsId || + (await this.settings.testRadarr(selectedRadarrSettingsId)).status !== + 'OK') + ) { + return false + } + + if ( + (referencedApplications.has(Application.SONARR) || + selectedSonarrSettingsId != null) && + (!selectedSonarrSettingsId || + (await this.settings.testSonarr(selectedSonarrSettingsId)).status !== + 'OK') + ) { + return false + } + + if ( + referencedApplications.has(Application.SEERR) && + (!this.settings.seerrConfigured() || + (await this.settings.testSeerr()).status !== 'OK') + ) { + return false + } + + if ( + referencedApplications.has(Application.TAUTULLI) && + (!this.settings.tautulliConfigured() || + (await this.settings.testTautulli()).status !== 'OK') + ) { + return false + } + + return true + } + + private getReferencedApplications(ruleGroup: RulesDto) { + const applications = new Set() + + for (const rule of ruleGroup.rules ?? []) { + const parsedRule = + 'ruleJson' in rule ? (JSON.parse(rule.ruleJson) as RuleDto) : rule + applications.add(parsedRule.firstVal[0]) + + if (parsedRule.lastVal) { + applications.add(parsedRule.lastVal[0]) + } + } + + return applications + } + private async handleCollection(rulegroup: RuleGroup): Promise> { try { let collection = await this.collectionService.getCollection( diff --git a/apps/ui/src/components/Common/DeleteButton/index.tsx b/apps/ui/src/components/Common/DeleteButton/index.tsx index e4d89e8f..441596f3 100644 --- a/apps/ui/src/components/Common/DeleteButton/index.tsx +++ b/apps/ui/src/components/Common/DeleteButton/index.tsx @@ -9,7 +9,7 @@ interface IDeleteButton { const DeleteButton = (props: IDeleteButton) => { return (