From dd3dc7acdd4291f24aae9f65434e75c23ffdb754 Mon Sep 17 00:00:00 2001 From: Ben Scobie Date: Mon, 23 Sep 2024 15:44:42 +0100 Subject: [PATCH] feat: Add Tautulli integration --- .vscode/settings.json | 58 ++-- README.md | 4 +- server/src/app/app.module.ts | 5 + .../1727097172777-Add_Tautulli_settings.ts | 19 ++ server/src/modules/api/lib/cache.ts | 4 +- .../helpers/tautulli-api.helper.ts | 22 ++ .../tautulli-api/tautulli-api.controller.ts | 7 + .../api/tautulli-api/tautulli-api.module.ts | 12 + .../api/tautulli-api/tautulli-api.service.ts | 288 ++++++++++++++++++ .../modules/collections/collections.module.ts | 2 + .../rules/constants/rules.constants.ts | 25 +- .../modules/rules/getter/getter.service.ts | 5 + .../rules/getter/tautulli-getter.service.ts | 112 +++++++ server/src/modules/rules/rules.module.ts | 4 + server/src/modules/rules/rules.service.ts | 57 ++-- .../src/modules/settings/dto's/setting.dto.ts | 4 + .../settings/entities/settings.entities.ts | 6 + .../modules/settings/settings.controller.ts | 4 + .../src/modules/settings/settings.module.ts | 2 + .../src/modules/settings/settings.service.ts | 39 ++- .../components/Settings/Overseerr/index.tsx | 2 +- ui/src/components/Settings/Plex/index.tsx | 2 +- ui/src/components/Settings/Radarr/index.tsx | 2 +- ui/src/components/Settings/Sonarr/index.tsx | 2 +- ui/src/components/Settings/Tautulli/index.tsx | 229 ++++++++++++++ ui/src/components/Settings/index.tsx | 5 + ui/src/contexts/settings-context.tsx | 2 + ui/src/pages/settings/tautulli/index.tsx | 13 + 28 files changed, 884 insertions(+), 52 deletions(-) create mode 100644 server/src/database/migrations/1727097172777-Add_Tautulli_settings.ts create mode 100644 server/src/modules/api/tautulli-api/helpers/tautulli-api.helper.ts create mode 100644 server/src/modules/api/tautulli-api/tautulli-api.controller.ts create mode 100644 server/src/modules/api/tautulli-api/tautulli-api.module.ts create mode 100644 server/src/modules/api/tautulli-api/tautulli-api.service.ts create mode 100644 server/src/modules/rules/getter/tautulli-getter.service.ts create mode 100644 ui/src/components/Settings/Tautulli/index.tsx create mode 100644 ui/src/pages/settings/tautulli/index.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 6522b4d5..4b33c252 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,23 +1,39 @@ { - "eslint.enable": true, - "eslint.validate": [ - "javascript", - "javascriptreact", - "typescript", - "typescriptreact" - ], - "typescript.tsdk": "node_modules/typescript/lib", - "sqltools.connections": [ - { - "previewLimit": 50, - "driver": "SQLite", - "name": "Local SQLite", - "database": "./data/maintainerr.sqlite" - } - ], - "editor.formatOnSave": true, - "typescript.preferences.importModuleSpecifier": "non-relative", - "files.associations": { - "globals.css": "tailwindcss" + "eslint.enable": true, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact" + ], + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "sqltools.connections": [ + { + "previewLimit": 50, + "driver": "SQLite", + "name": "Local SQLite", + "database": "./data/maintainerr.sqlite" } - } \ No newline at end of file + ], + "editor.formatOnSave": true, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[css]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "typescript.preferences.importModuleSpecifier": "relative", + "files.associations": { + "globals.css": "tailwindcss" + }, +} \ No newline at end of file diff --git a/README.md b/README.md index 8114ee03..e2706e7c 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,12 @@ It's a one-stop-shop for handling those outlying shows and movies that take up p # Features -- Configure rules specific to your needs, based off of several available options from Plex, Overseerr, Radarr, and Sonarr. +- Configure rules specific to your needs, based off of several available options from Plex, Overseerr, Radarr, Sonarr and Tautulli. - Manually add media to a collection, in case it's not included after rule execution. (one-off items that don't match a rule set) - Selectively exclude media from being added to a collection, even if it matches a rule. - Show a collection, containing rule matched media, on the Plex home screen for a specific duration before deletion. Think "Leaving soon". - Optionally, use a manual Plex collection, in case you don't want Maintainerr to add & remove Plex collections at will. - Manage media straight from the collection within Plex. Maintainerr will sync and add or exclude media to/from the internal collection. - - Remove or unmonitor media from \*arr - Clear requests from Overseerr - Delete files from disk @@ -47,6 +46,7 @@ Currently, Maintainerr supports rule parameters from these apps : - Overseerr - Radarr - Sonarr +- Tautulli # Preview diff --git a/server/src/app/app.module.ts b/server/src/app/app.module.ts index fae72a2e..6e0fe892 100644 --- a/server/src/app/app.module.ts +++ b/server/src/app/app.module.ts @@ -15,6 +15,8 @@ import { PlexApiService } from '../modules/api/plex-api/plex-api.service'; import { OverseerrApiService } from '../modules/api/overseerr-api/overseerr-api.service'; import { ServarrService } from '../modules/api/servarr-api/servarr.service'; import ormConfig from './config/typeOrmConfig'; +import { TautulliApiModule } from '../modules/api/tautulli-api/tautulli-api.module'; +import { TautulliApiService } from '../modules/api/tautulli-api/tautulli-api.service'; @Module({ imports: [ @@ -25,6 +27,7 @@ import ormConfig from './config/typeOrmConfig'; TmdbApiModule, ServarrApiModule, OverseerrApiModule, + TautulliApiModule, RulesModule, CollectionsModule, ], @@ -37,6 +40,7 @@ export class AppModule implements OnModuleInit { private readonly plexApi: PlexApiService, private readonly overseerApi: OverseerrApiService, private readonly servarr: ServarrService, + private readonly tautulliApi: TautulliApiService, ) {} async onModuleInit() { // Initialize stuff needing settings here.. Otherwise problems @@ -44,5 +48,6 @@ export class AppModule implements OnModuleInit { await this.plexApi.initialize({}); await this.servarr.init(); await this.overseerApi.init(); + await this.tautulliApi.init(); } } diff --git a/server/src/database/migrations/1727097172777-Add_Tautulli_settings.ts b/server/src/database/migrations/1727097172777-Add_Tautulli_settings.ts new file mode 100644 index 00000000..70b596e0 --- /dev/null +++ b/server/src/database/migrations/1727097172777-Add_Tautulli_settings.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTautulliSettings1727097172777 implements MigrationInterface { + name = 'AddTautulliSettings1727097172777'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE settings ADD COLUMN "tautulli_url" varchar', + ); + await queryRunner.query( + 'ALTER TABLE settings ADD COLUMN "tautulli_api_key" varchar', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE settings DROP "tautulli_url"`); + await queryRunner.query(`ALTER TABLE settings DROP "tautulli_api_key"`); + } +} diff --git a/server/src/modules/api/lib/cache.ts b/server/src/modules/api/lib/cache.ts index 108b9be8..3d66aef2 100644 --- a/server/src/modules/api/lib/cache.ts +++ b/server/src/modules/api/lib/cache.ts @@ -7,7 +7,8 @@ export type AvailableCacheIds = | 'plexguid' | 'plextv' | 'overseerr' - | 'plexcommunity'; + | 'plexcommunity' + | 'tautulli'; const DEFAULT_TTL = 300; // 5 min const DEFAULT_CHECK_PERIOD = 120; // 2 min @@ -51,6 +52,7 @@ class CacheManager { plextv: new Cache('plextv', 'Plex.tv'), overseerr: new Cache('overseerr', 'Overseerr API'), plexcommunity: new Cache('plexcommunity', 'community.Plex.tv'), + tautulli: new Cache('tautulli', 'Tautulli API'), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/src/modules/api/tautulli-api/helpers/tautulli-api.helper.ts b/server/src/modules/api/tautulli-api/helpers/tautulli-api.helper.ts new file mode 100644 index 00000000..a53f6ab5 --- /dev/null +++ b/server/src/modules/api/tautulli-api/helpers/tautulli-api.helper.ts @@ -0,0 +1,22 @@ +import { ExternalApiService } from '../../external-api/external-api.service'; +import cacheManager from '../../lib/cache'; + +export class TautulliApi extends ExternalApiService { + constructor({ + url, + apiKey, + }: { + url: string; + apiKey: string; + }) { + super( + url, + { + apikey: apiKey, + }, + { + nodeCache: cacheManager.getCache('tautulli').data, + }, + ); + } +} diff --git a/server/src/modules/api/tautulli-api/tautulli-api.controller.ts b/server/src/modules/api/tautulli-api/tautulli-api.controller.ts new file mode 100644 index 00000000..0f224f44 --- /dev/null +++ b/server/src/modules/api/tautulli-api/tautulli-api.controller.ts @@ -0,0 +1,7 @@ +import { Controller } from '@nestjs/common'; +import { TautulliApiService } from './tautulli-api.service'; + +@Controller('api/tautulli') +export class TautulliApiController { + constructor(private readonly tautulliApiService: TautulliApiService) {} +} diff --git a/server/src/modules/api/tautulli-api/tautulli-api.module.ts b/server/src/modules/api/tautulli-api/tautulli-api.module.ts new file mode 100644 index 00000000..b19d1737 --- /dev/null +++ b/server/src/modules/api/tautulli-api/tautulli-api.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TautulliApiService } from './tautulli-api.service'; +import { TautulliApiController } from './tautulli-api.controller'; +import { ExternalApiModule } from '../external-api/external-api.module'; + +@Module({ + imports: [ExternalApiModule], + controllers: [TautulliApiController], + providers: [TautulliApiService], + exports: [TautulliApiService], +}) +export class TautulliApiModule {} diff --git a/server/src/modules/api/tautulli-api/tautulli-api.service.ts b/server/src/modules/api/tautulli-api/tautulli-api.service.ts new file mode 100644 index 00000000..d9124bb4 --- /dev/null +++ b/server/src/modules/api/tautulli-api/tautulli-api.service.ts @@ -0,0 +1,288 @@ +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { SettingsService } from '../../..//modules/settings/settings.service'; +import { TautulliApi } from './helpers/tautulli-api.helper'; +import _ from 'lodash'; + +interface TautulliInfo { + machine_identifier: string; + version: string; +} + +export interface TautulliUser { + user_id: number; + username: string; +} + +export interface TautulliMetadata { + media_type: + | 'season' + | 'episode' + | 'movie' + | 'track' + | 'album' + | 'artist' + | 'show'; + rating_key: string; + parent_rating_key: string; + grandparent_rating_key: string; +} + +interface TautulliChildrenMetadata { + children_count: number; + children_list: TautulliMetadata[]; +} + +interface TautulliHistory { + recordsFiltered: number; + recordsTotal: number; + data: TautulliHistoryItem[]; + draw: number; + filter_duration: string; + total_duration: string; +} + +interface TautulliHistoryItem { + user_id: number; + user: string; + watched_status: number; +} + +interface TautulliHistoryRequestOptions { + grouping?: 0 | 1; + include_activity?: 0 | 1; + user?: string; + user_id?: number; + rating_key?: number | string; + parent_rating_key?: number | string; + grandparent_rating_key?: number | string; + start_date?: string; + before?: string; + after?: string; + section_id?: number; + media_type?: 'movie' | 'episode' | 'track' | 'live'; + transcode_decision?: 'direct play' | 'transcode' | 'copy'; + guid?: string; + order_column?: string; + order_dir?: 'desc' | 'asc'; + start?: number; + length?: number; + search?: string; +} + +interface Response { + response: + | { + message: string | null; + result: 'success'; + data: T; + } + | { + message: string | null; + result: 'error'; + data: {}; + }; +} + +const MAX_PAGE_SIZE = 100; + +@Injectable() +export class TautulliApiService { + api: TautulliApi; + + private readonly logger = new Logger(TautulliApiService.name); + constructor( + @Inject(forwardRef(() => SettingsService)) + private readonly settings: SettingsService, + ) {} + + public async init() { + this.api = new TautulliApi({ + url: `${this.settings.tautulli_url}api/v2`, + apiKey: `${this.settings.tautulli_api_key}`, + }); + } + + public async info(): Promise | null> { + try { + const response: Response = await this.api.getWithoutCache( + '', + { + signal: AbortSignal.timeout(10000), + params: { + cmd: 'get_server_identity', + }, + }, + ); + return response; + } catch (e) { + this.logger.log("Couldn't fetch Tautulli info!", { + label: 'Tautulli API', + errorMessage: e.message, + }); + this.logger.debug(e); + return null; + } + } + + public async getPaginatedHistory( + options?: TautulliHistoryRequestOptions, + ): Promise { + try { + options.length = options.length ? options.length : MAX_PAGE_SIZE; + options.start = options.start || options.start === 0 ? options.start : 0; + + const response: Response = await this.api.get('', { + params: { + cmd: 'get_history', + ...options, + }, + }); + + if (response.response.result !== 'success') { + throw new Error('Non-success response when fetching Tautulli users'); + } + + return response.response.data; + } catch (e) { + this.logger.log("Couldn't fetch Tautulli history!", { + label: 'Tautulli API', + errorMessage: e.message, + }); + this.logger.debug(e); + return null; + } + } + + public async getHistory( + options?: Omit, + ): Promise { + try { + const newOptions: TautulliHistoryRequestOptions = { + ...options, + length: MAX_PAGE_SIZE, + start: 0, + }; + + let data = await this.getPaginatedHistory(newOptions); + const pageSize: number = MAX_PAGE_SIZE; + + const totalCount: number = + data && data && data.recordsFiltered ? data.recordsFiltered : 0; + const pageCount: number = Math.ceil(totalCount / pageSize); + let currentPage = 1; + + let results: TautulliHistoryItem[] = []; + results = _.unionBy( + results, + data && data.data && data.data && data.data.length ? data.data : [], + 'id', + ); + + if (results.length < totalCount) { + while (currentPage < pageCount) { + newOptions.start = currentPage * pageSize; + data = await this.getPaginatedHistory(newOptions); + + currentPage++; + + results = _.unionBy( + results, + data && data.data && data.data && data.data.length ? data.data : [], + 'id', + ); + + if (results.length === totalCount) { + break; + } + } + } + + return results; + } catch (e) { + this.logger.log("Couldn't fetch Tautulli history!", { + label: 'Tautulli API', + errorMessage: e.message, + }); + this.logger.debug(e); + return null; + } + } + + public async getMetadata( + ratingKey: number | string, + ): Promise { + try { + const response: Response = await this.api.get('', { + params: { + cmd: 'get_metadata', + rating_key: ratingKey, + }, + }); + + if (response.response.result !== 'success') { + throw new Error('Non-success response when fetching Tautulli users'); + } + + return response.response.data; + } catch (e) { + this.logger.log("Couldn't fetch Tautulli metadata!", { + label: 'Tautulli API', + errorMessage: e.message, + }); + this.logger.debug(e); + return null; + } + } + + public async getChildrenMetadata( + ratingKey: number | string, + ): Promise { + try { + const response: Response = await this.api.get( + '', + { + params: { + cmd: 'get_children_metadata', + rating_key: ratingKey, + }, + }, + ); + + if (response.response.result !== 'success') { + throw new Error('Non-success response when fetching Tautulli users'); + } + + return response.response.data.children_list; + } catch (e) { + this.logger.log("Couldn't fetch Tautulli children metadata!", { + label: 'Tautulli API', + errorMessage: e.message, + }); + this.logger.debug(e); + return null; + } + } + + public async getUsers(): Promise { + try { + const response: Response = await this.api.get('', { + params: { + cmd: 'get_users', + }, + }); + + if (response.response.result !== 'success') { + throw new Error('Non-success response when fetching Tautulli users'); + } + + return response.response.data; + } catch (e) { + this.logger.log("Couldn't fetch Tautulli users!", { + label: 'Tautulli API', + errorMessage: e.message, + }); + this.logger.debug(e); + return null; + } + } +} diff --git a/server/src/modules/collections/collections.module.ts b/server/src/modules/collections/collections.module.ts index e6093e86..29018a6e 100644 --- a/server/src/modules/collections/collections.module.ts +++ b/server/src/modules/collections/collections.module.ts @@ -14,6 +14,7 @@ import { TasksModule } from '../tasks/tasks.module'; import { Exclusion } from '../rules/entities/exclusion.entities'; import { CollectionLog } from '../collections/entities/collection_log.entities'; import { CollectionLogCleanerService } from '../collections/tasks/collection-log-cleaner.service'; +import { TautulliApiModule } from '../api/tautulli-api/tautulli-api.module'; @Module({ imports: [ @@ -26,6 +27,7 @@ import { CollectionLogCleanerService } from '../collections/tasks/collection-log Exclusion, ]), OverseerrApiModule, + TautulliApiModule, TmdbApiModule, ServarrApiModule, TasksModule, diff --git a/server/src/modules/rules/constants/rules.constants.ts b/server/src/modules/rules/constants/rules.constants.ts index 481f4e17..b8b7dde9 100644 --- a/server/src/modules/rules/constants/rules.constants.ts +++ b/server/src/modules/rules/constants/rules.constants.ts @@ -25,6 +25,7 @@ export const enum Application { RADARR, SONARR, OVERSEERR, + TAUTULLI, } export const enum ArrAction { @@ -128,7 +129,7 @@ export class RuleConstants { name: 'seenBy', humanName: '[list] Viewed by (username)', mediaType: MediaType.MOVIE, - type: RuleType.TEXT, // returns id[] + type: RuleType.TEXT, // returns usernames [] } as Property, { id: 2, @@ -612,5 +613,27 @@ export class RuleConstants { } as Property, ], }, + { + id: Application.TAUTULLI, + name: 'Tautulli', + mediaType: MediaType.BOTH, + props: [ + { + id: 0, + name: 'seenBy', + humanName: '[list] Viewed by (username)', + mediaType: MediaType.MOVIE, + type: RuleType.TEXT, // returns usernames [] + } as Property, + { + id: 1, + name: 'sw_allEpisodesSeenBy', + humanName: '[list] Users that saw all available episodes', + mediaType: MediaType.SHOW, + type: RuleType.TEXT, // return usernames [] + showType: [EPlexDataType.SHOWS, EPlexDataType.SEASONS], + } as Property, + ], + }, ]; } diff --git a/server/src/modules/rules/getter/getter.service.ts b/server/src/modules/rules/getter/getter.service.ts index 21cf27cb..21b2b5ac 100644 --- a/server/src/modules/rules/getter/getter.service.ts +++ b/server/src/modules/rules/getter/getter.service.ts @@ -7,6 +7,7 @@ import { RadarrGetterService } from './radarr-getter.service'; import { SonarrGetterService } from './sonarr-getter.service'; import { RulesDto } from '../dtos/rules.dto'; import { EPlexDataType } from '../../api/plex-api/enums/plex-data-type-enum'; +import { TautulliGetterService } from './tautulli-getter.service'; @Injectable() export class ValueGetterService { @@ -15,6 +16,7 @@ export class ValueGetterService { private readonly radarrGetter: RadarrGetterService, private readonly sonarrGetter: SonarrGetterService, private readonly overseerGetter: OverseerrGetterService, + private readonly tautulliGetter: TautulliGetterService, ) {} async get( @@ -36,6 +38,9 @@ export class ValueGetterService { case Application.OVERSEERR: { return await this.overseerGetter.get(val2, libItem, dataType); } + case Application.TAUTULLI: { + return await this.tautulliGetter.get(val2, libItem, dataType); + } default: { return null; } diff --git a/server/src/modules/rules/getter/tautulli-getter.service.ts b/server/src/modules/rules/getter/tautulli-getter.service.ts new file mode 100644 index 00000000..28504843 --- /dev/null +++ b/server/src/modules/rules/getter/tautulli-getter.service.ts @@ -0,0 +1,112 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PlexLibraryItem } from '../../../modules/api/plex-api/interfaces/library.interfaces'; +import { + Application, + Property, + RuleConstants, +} from '../constants/rules.constants'; +import { EPlexDataType } from '../../api/plex-api/enums/plex-data-type-enum'; +import _ from 'lodash'; +import { + TautulliApiService, + TautulliMetadata, +} from '../../api/tautulli-api/tautulli-api.service'; + +@Injectable() +export class TautulliGetterService { + appProperties: Property[]; + private readonly logger = new Logger(TautulliGetterService.name); + + constructor(private readonly tautulliApi: TautulliApiService) { + const ruleConstanst = new RuleConstants(); + this.appProperties = ruleConstanst.applications.find( + (el) => el.id === Application.TAUTULLI, + ).props; + } + + async get(id: number, libItem: PlexLibraryItem, dataType?: EPlexDataType) { + try { + const prop = this.appProperties.find((el) => el.id === id); + const metadata = await this.tautulliApi.getMetadata(libItem.ratingKey); + + switch (prop.name) { + case 'seenBy': { + const history = await this.tautulliApi.getHistory({ + rating_key: metadata.rating_key, + media_type: 'movie', + }); + + if (history.length > 0) { + const viewers = history + .filter((x) => x.watched_status == 1) + .map((el) => el.user); + + const uniqueViewers = [...new Set(viewers)]; + + return uniqueViewers; + } else { + return []; + } + } + case 'sw_allEpisodesSeenBy': { + const users = await this.tautulliApi.getUsers(); + let seasons: TautulliMetadata[]; + + if (metadata.media_type !== 'season') { + seasons = await this.tautulliApi.getChildrenMetadata( + metadata.rating_key, + ); + } else { + seasons = [metadata]; + } + + const allViewers = users.slice(); + for (const season of seasons) { + const episodes = await this.tautulliApi.getChildrenMetadata( + season.rating_key, + ); + + for (const episode of episodes) { + const viewers = await this.tautulliApi.getHistory({ + rating_key: episode.rating_key, + media_type: 'episode', + }); + + const arrLength = allViewers.length - 1; + allViewers + .slice() + .reverse() + .forEach((el, idx) => { + if ( + !viewers?.find( + (viewEl) => + viewEl.watched_status == 1 && + el.user_id === viewEl.user_id, + ) + ) { + allViewers.splice(arrLength - idx, 1); + } + }); + } + } + + if (allViewers && allViewers.length > 0) { + const viewerIds = allViewers.map((el) => el.user_id); + return users + .filter((el) => viewerIds.includes(el.user_id)) + .map((el) => el.username); + } + + return []; + } + default: { + return null; + } + } + } catch (e) { + console.log(e); + this.logger.warn(`Tautulli-Getter - Action failed : ${e.message}`); + return undefined; + } + } +} diff --git a/server/src/modules/rules/rules.module.ts b/server/src/modules/rules/rules.module.ts index 503cb14f..703ee92b 100644 --- a/server/src/modules/rules/rules.module.ts +++ b/server/src/modules/rules/rules.module.ts @@ -26,6 +26,8 @@ import { RuleYamlService } from './helpers/yaml.service'; import { RuleComparatorService } from './helpers/rule.comparator.service'; import { RuleConstanstService } from './constants/constants.service'; import { ExclusionTypeCorrectorService } from './tasks/exclusion-corrector.service'; +import { TautulliApiModule } from '../api/tautulli-api/tautulli-api.module'; +import { TautulliGetterService } from './getter/tautulli-getter.service'; @Module({ imports: [ @@ -41,6 +43,7 @@ import { ExclusionTypeCorrectorService } from './tasks/exclusion-corrector.servi Settings, ]), OverseerrApiModule, + TautulliApiModule, TmdbApiModule, CollectionsModule, TasksModule, @@ -54,6 +57,7 @@ import { ExclusionTypeCorrectorService } from './tasks/exclusion-corrector.servi RadarrGetterService, SonarrGetterService, OverseerrGetterService, + TautulliGetterService, ValueGetterService, RuleYamlService, RuleComparatorService, diff --git a/server/src/modules/rules/rules.service.ts b/server/src/modules/rules/rules.service.ts index 833f8c5e..35782609 100644 --- a/server/src/modules/rules/rules.service.ts +++ b/server/src/modules/rules/rules.service.ts @@ -40,7 +40,8 @@ export interface ReturnStatus { @Injectable() export class RulesService { private readonly logger = new Logger(RulesService.name); - private readonly communityUrl = 'https://jsonbin.maintainerr.info/maintainerr-app/rules'; + private readonly communityUrl = + 'https://jsonbin.maintainerr.info/maintainerr-app/rules'; private readonly key = '788bfded-1fd0-46e8-8616-28d76e8a2904'; ruleConstants: RuleConstants; @@ -91,6 +92,13 @@ export class RulesService { (el) => el.id !== Application.SONARR, ); } + + // remove tautulli if not configured + if (!settings.tautulli_url || !settings.tautulli_api_key) { + localConstants.applications = localConstants.applications.filter( + (el) => el.id !== Application.TAUTULLI, + ); + } } return localConstants; @@ -293,9 +301,9 @@ export class RulesService { if ( group.dataType !== params.dataType || params.collection.manualCollection !== - dbCollection.manualCollection || + dbCollection.manualCollection || params.collection.manualCollectionName !== - dbCollection.manualCollectionName || + dbCollection.manualCollectionName || params.libraryId !== dbCollection.libraryId ) { this.logger.log( @@ -477,10 +485,12 @@ export class RulesService { } this.logger.log( - `Added ${data.ruleGroupId === undefined ? 'global ' : '' - }exclusion for media with id ${media.plexId} ${data.ruleGroupId !== undefined - ? `and rulegroup id ${data.ruleGroupId}` - : '' + `Added ${ + data.ruleGroupId === undefined ? 'global ' : '' + }exclusion for media with id ${media.plexId} ${ + data.ruleGroupId !== undefined + ? `and rulegroup id ${data.ruleGroupId}` + : '' } `, ); } @@ -575,10 +585,12 @@ export class RulesService { ); } this.logger.log( - `Removed ${data.ruleGroupId === undefined ? 'global ' : '' - }exclusion for media with id ${media.plexId} ${data.ruleGroupId !== undefined - ? `and rulegroup id ${data.ruleGroupId}` - : '' + `Removed ${ + data.ruleGroupId === undefined ? 'global ' : '' + }exclusion for media with id ${media.plexId} ${ + data.ruleGroupId !== undefined + ? `and rulegroup id ${data.ruleGroupId}` + : '' } `, ); } @@ -640,12 +652,12 @@ export class RulesService { return rulegroupId ? exclusions.concat( - await this.exclusionRepo.find({ - where: { - ruleGroupId: null, - }, - }), - ) + await this.exclusionRepo.find({ + where: { + ruleGroupId: null, + }, + }), + ) : exclusions; } return []; @@ -857,8 +869,8 @@ export class RulesService { if (rules.find((r) => r.id === id) === undefined) { this.logger.log( `Rules - Tried to edit the karma of rule with id ` + - id + - `, but it doesn't exist`, + id + + `, but it doesn't exist`, ); return this.createReturnStatus( false, @@ -940,6 +952,7 @@ export class RulesService { cacheManager.getCache('overseerr').data.flushAll(); cacheManager.getCache('radarr').data.flushAll(); cacheManager.getCache('sonarr').data.flushAll(); + cacheManager.getCache('tautulli').data.flushAll(); const mediaResp = await this.plexApi.getMetadata(mediaId); const group = await this.getRuleGroupById(rulegroupId); @@ -975,7 +988,7 @@ export class RulesService { //test first value const first = constant.applications[parsedRule.firstVal[0]].props[ - parsedRule.firstVal[1] + parsedRule.firstVal[1] ]; result = first?.cacheReset ? true : result; @@ -983,8 +996,8 @@ export class RulesService { // test second value const second = parsedRule.lastVal ? constant.applications[parsedRule.lastVal[0]].props[ - parsedRule.lastVal[1] - ] + parsedRule.lastVal[1] + ] : undefined; result = second?.cacheReset ? true : result; diff --git a/server/src/modules/settings/dto's/setting.dto.ts b/server/src/modules/settings/dto's/setting.dto.ts index a5faaeb0..1a8928b9 100644 --- a/server/src/modules/settings/dto's/setting.dto.ts +++ b/server/src/modules/settings/dto's/setting.dto.ts @@ -35,6 +35,10 @@ export class SettingDto { sonarr_api_key: string; + tautulli_url: string; + + tautulli_api_key: string; + collection_handler_job_cron: string; rules_handler_job_cron: string; diff --git a/server/src/modules/settings/entities/settings.entities.ts b/server/src/modules/settings/entities/settings.entities.ts index fc03054b..0a5b4e1c 100644 --- a/server/src/modules/settings/entities/settings.entities.ts +++ b/server/src/modules/settings/entities/settings.entities.ts @@ -59,6 +59,12 @@ export class Settings implements SettingDto { @Column({ nullable: true }) sonarr_api_key: string; + @Column({ nullable: true }) + tautulli_url: string; + + @Column({ nullable: true }) + tautulli_api_key: string; + @Column({ nullable: false, default: CronExpression.EVERY_12_HOURS }) collection_handler_job_cron: string; diff --git a/server/src/modules/settings/settings.controller.ts b/server/src/modules/settings/settings.controller.ts index 72f0937b..75fc6548 100644 --- a/server/src/modules/settings/settings.controller.ts +++ b/server/src/modules/settings/settings.controller.ts @@ -53,6 +53,10 @@ export class SettingsController { testPlex() { return this.settingsService.testPlex(); } + @Get('/test/tautulli') + testTautulli() { + return this.settingsService.testTautulli(); + } @Get('/plex/devices/servers') async getPlexServers() { diff --git a/server/src/modules/settings/settings.module.ts b/server/src/modules/settings/settings.module.ts index e02de77a..daa212ca 100644 --- a/server/src/modules/settings/settings.module.ts +++ b/server/src/modules/settings/settings.module.ts @@ -7,6 +7,7 @@ import { PlexApiModule } from '../api/plex-api/plex-api.module'; import { ServarrApiModule } from '../api/servarr-api/servarr-api.module'; import { OverseerrApiModule } from '../api/overseerr-api/overseerr-api.module'; import { InternalApiModule } from '../api/internal-api/internal-api.module'; +import { TautulliApiModule } from '../api/tautulli-api/tautulli-api.module'; @Global() @Module({ @@ -14,6 +15,7 @@ import { InternalApiModule } from '../api/internal-api/internal-api.module'; forwardRef(() => PlexApiModule), forwardRef(() => ServarrApiModule), forwardRef(() => OverseerrApiModule), + forwardRef(() => TautulliApiModule), forwardRef(() => InternalApiModule), TypeOrmModule.forFeature([Settings]), ], diff --git a/server/src/modules/settings/settings.service.ts b/server/src/modules/settings/settings.service.ts index 927fab26..c2081558 100644 --- a/server/src/modules/settings/settings.service.ts +++ b/server/src/modules/settings/settings.service.ts @@ -10,6 +10,7 @@ import { ServarrService } from '../api/servarr-api/servarr.service'; import { SettingDto } from "./dto's/setting.dto"; import { Settings } from './entities/settings.entities'; import { InternalApiService } from '../api/internal-api/internal-api.service'; +import { TautulliApiService } from '../api/tautulli-api/tautulli-api.service'; @Injectable() export class SettingsService implements SettingDto { @@ -50,6 +51,10 @@ export class SettingsService implements SettingDto { sonarr_api_key: string; + tautulli_url: string; + + tautulli_api_key: string; + collection_handler_job_cron: string; rules_handler_job_cron: string; @@ -61,6 +66,8 @@ export class SettingsService implements SettingDto { private readonly servarr: ServarrService, @Inject(forwardRef(() => OverseerrApiService)) private readonly overseerr: OverseerrApiService, + @Inject(forwardRef(() => TautulliApiService)) + private readonly tautulli: TautulliApiService, @Inject(forwardRef(() => InternalApiService)) private readonly internalApi: InternalApiService, @InjectRepository(Settings) @@ -90,6 +97,8 @@ export class SettingsService implements SettingDto { this.radarr_api_key = settingsDb?.radarr_api_key; this.sonarr_url = settingsDb?.sonarr_url; this.sonarr_api_key = settingsDb?.sonarr_api_key; + this.tautulli_url = settingsDb?.tautulli_url; + this.tautulli_api_key = settingsDb?.tautulli_api_key; this.collection_handler_job_cron = settingsDb?.collection_handler_job_cron; this.rules_handler_job_cron = settingsDb?.rules_handler_job_cron; @@ -147,6 +156,7 @@ export class SettingsService implements SettingDto { settings.radarr_url = settings.radarr_url?.toLowerCase(); settings.sonarr_url = settings.sonarr_url?.toLowerCase(); settings.overseerr_url = settings.overseerr_url?.toLowerCase(); + settings.tautulli_url = settings.tautulli_url?.toLowerCase(); const settingsDb = await this.settingsRepo.findOne({ where: {} }); // Plex SSL specifics @@ -173,6 +183,7 @@ export class SettingsService implements SettingDto { this.plexApi.initialize({}); this.servarr.init(); this.overseerr.init(); + this.tautulli.init(); this.internalApi.init(); // reload Rule handler job if changed @@ -240,6 +251,17 @@ export class SettingsService implements SettingDto { } } + public async testTautulli(): Promise { + try { + const resp = await this.tautulli.info(); + return resp?.response.result == 'success' + ? { status: 'OK', code: 1, message: resp.response.data.version } + : { status: 'NOK', code: 0, message: 'Failure' }; + } catch { + return { status: 'NOK', code: 0, message: 'Failure' }; + } + } + public async testRadarr(): Promise { try { const resp = await this.servarr.RadarrApi.info(); @@ -283,6 +305,7 @@ export class SettingsService implements SettingDto { let radarrState = true; let sonarrState = true; let overseerrState = true; + let tautulliState = true; if (this.radarrConfigured()) { radarrState = (await this.testRadarr()).status === 'OK'; } @@ -295,7 +318,17 @@ export class SettingsService implements SettingDto { overseerrState = (await this.testOverseerr()).status === 'OK'; } - if (plexState && radarrState && sonarrState && overseerrState) { + if (this.tautulliConfigured()) { + tautulliState = (await this.testTautulli()).status === 'OK'; + } + + if ( + plexState && + radarrState && + sonarrState && + overseerrState && + tautulliState + ) { return true; } else { return false; @@ -318,6 +351,10 @@ export class SettingsService implements SettingDto { return this.overseerr_url !== null && this.overseerr_api_key !== null; } + public tautulliConfigured(): boolean { + return this.tautulli_url !== null && this.tautulli_api_key !== null; + } + // Test if all required settings are set. public async testSetup(): Promise { try { diff --git a/ui/src/components/Settings/Overseerr/index.tsx b/ui/src/components/Settings/Overseerr/index.tsx index f2701f98..24520fba 100644 --- a/ui/src/components/Settings/Overseerr/index.tsx +++ b/ui/src/components/Settings/Overseerr/index.tsx @@ -127,7 +127,7 @@ const OverseerrSettings = () => { testBanner.status ? ( ) : ( { testBanner.status ? ( ) : ( { testBanner.status ? ( ) : ( { testBanner.status ? ( ) : ( { + const settingsCtx = useContext(SettingsContext) + const hostnameRef = useRef(null) + const portRef = useRef(null) + const apiKeyRef = useRef(null) + const [hostname, setHostname] = useState() + const [port, setPort] = useState() + const [error, setError] = useState() + const [changed, setChanged] = useState() + const [testBanner, setTestbanner] = useState<{ + status: Boolean + version: string + }>({ status: false, version: '0' }) + + useEffect(() => { + document.title = 'Maintainerr - Settings - Tautulli' + }, []) + + useEffect(() => { + // hostname + setHostname(removePortFromUrl(settingsCtx.settings.tautulli_url)) + // @ts-ignore + hostnameRef.current = { + value: removePortFromUrl(settingsCtx.settings.tautulli_url), + } + + // port + setPort(getPortFromUrl(settingsCtx.settings.tautulli_url)) + // @ts-ignore + portRef.current = { + value: getPortFromUrl(settingsCtx.settings.tautulli_url), + } + }, [settingsCtx]) + + const submit = async (e: React.FormEvent) => { + e.preventDefault() + + // if port not specified, but hostname is. Derive the port + if (!portRef.current?.value && hostnameRef.current?.value) { + const derivedPort = hostnameRef.current.value.includes('http://') + ? 80 + : hostnameRef.current.value.includes('https://') + ? 443 + : 80 + + if (derivedPort) { + setPort(derivedPort.toString()) + // @ts-ignore + portRef.current = { value: derivedPort.toString() } + } + } + + if ( + hostnameRef.current?.value && + apiKeyRef.current?.value && + portRef.current?.value + ) { + const hostnameVal = hostnameRef.current.value.includes('http://') + ? hostnameRef.current.value + : hostnameRef.current.value.includes('https://') + ? hostnameRef.current.value + : portRef.current.value == '443' + ? 'https://' + hostnameRef.current.value + : 'http://' + hostnameRef.current.value + + const payload = { + tautulli_url: addPortToUrl(hostnameVal, +portRef.current.value), + tautulli_api_key: apiKeyRef.current.value, + } + + const resp: { code: 0 | 1; message: string } = await PostApiHandler( + '/settings', + { + ...settingsCtx.settings, + ...payload, + }, + ) + if (Boolean(resp.code)) { + settingsCtx.addSettings({ + ...settingsCtx.settings, + ...payload, + }) + setError(false) + setChanged(true) + } else setError(true) + } else { + setError(true) + } + } + + const appTest = (result: { status: boolean; version: string }) => { + setTestbanner({ status: result.status, version: result.version }) + } + + return ( +
+
+

Tautulli Settings

+

Tautulli configuration

+
+ {error ? ( + + ) : changed ? ( + + ) : undefined} + + {testBanner.version !== '0' ? ( + testBanner.status ? ( + + ) : ( + + ) + ) : undefined} + +
+
+
+ +
+
+ + handleSettingsInputChange(e, hostnameRef, setHostname) + } + > +
+
+
+ +
+ +
+
+ handleSettingsInputChange(e, portRef, setPort)} + > +
+
+
+ +
+ +
+
+ +
+
+
+ +
+
+ + + +
+ + + + +
+
+
+
+
+
+ ) +} + +export default TautulliSettings diff --git a/ui/src/components/Settings/index.tsx b/ui/src/components/Settings/index.tsx index c04a065f..309b5eaa 100644 --- a/ui/src/components/Settings/index.tsx +++ b/ui/src/components/Settings/index.tsx @@ -36,6 +36,11 @@ const SettingsWrapper: React.FC<{ children?: ReactNode }> = (props: { route: '/settings/sonarr', regex: /^\/settings(\/sonarr)?$/, }, + { + text: 'Tautulli', + route: '/settings/tautulli', + regex: /^\/settings(\/tautulli)?$/, + }, { text: 'Jobs', route: '/settings/jobs', diff --git a/ui/src/contexts/settings-context.tsx b/ui/src/contexts/settings-context.tsx index 7a8edabb..b852b01a 100644 --- a/ui/src/contexts/settings-context.tsx +++ b/ui/src/contexts/settings-context.tsx @@ -25,6 +25,8 @@ export interface ISettings { radarr_api_key: string sonarr_url: string sonarr_api_key: string + tautulli_url: string + tautulli_api_key: string collection_handler_job_cron: string rules_handler_job_cron: string } diff --git a/ui/src/pages/settings/tautulli/index.tsx b/ui/src/pages/settings/tautulli/index.tsx new file mode 100644 index 00000000..4d47558c --- /dev/null +++ b/ui/src/pages/settings/tautulli/index.tsx @@ -0,0 +1,13 @@ +import { NextPage } from 'next' +import SettingsWrapper from '../../../components/Settings' +import TautulliSettings from '../../../components/Settings/Tautulli' + +const SettingsTautulli: NextPage = () => { + return ( + + + + ) +} + +export default SettingsTautulli