From 7c99775c2a9483089b44240d66db172d4d3d7fdf Mon Sep 17 00:00:00 2001 From: ydkmlt84 Date: Mon, 1 Jun 2026 23:55:16 -0500 Subject: [PATCH] style: update code style by removing semicolons and adjusting formatting --- apps/server/eslint.config.mjs | 24 +- apps/server/prettier.config.mjs | 5 +- apps/server/src/app/app.controller.ts | 14 +- apps/server/src/app/app.module.ts | 76 +- apps/server/src/app/app.service.ts | 42 +- apps/server/src/app/config/typeOrmConfig.ts | 6 +- .../1674487252453-Initial_migration.ts | 32 +- ...674489541784-Optional_manual_collection.ts | 16 +- ...41-Add_collection_chidren_and_data_type.ts | 8 +- ...528-Add_collection_listExclusion_column.ts | 8 +- ...0572854-overseerr_force_remove_requests.ts | 8 +- ...02366607151-Exclusions_add_parent_field.ts | 8 +- ...1705399208460-Exclusions_add_type_field.ts | 8 +- ...705919105309-Collection_add_info_fields.ts | 16 +- .../1705930583532-Collection_Log_add.ts | 16 +- ...6592389-Collection_add_keep_logs_months.ts | 8 +- ...06275100801-Tasks_add_taskRunning_table.ts | 8 +- .../1727097172777-Add_Tautulli_settings.ts | 12 +- ...5-Add_Tautulli_watched_percent_override.ts | 8 +- ...1727693832830-Add_Notification_settings.ts | 12 +- .../1728503476668-Update_tautulli_URL.ts | 6 +- .../1729032933189-Add_arr_settings.ts | 60 +- ...945000-Notification_settings_aboutScale.ts | 8 +- ...733357722729-Remove-default-arr-servers.ts | 20 +- ...llection_add_visibleOnRecommended_field.ts | 8 +- .../1740094282798-Add_log_settings.ts | 12 +- .../1740868281450-Add_jellyseerr_settings.ts | 12 +- .../1741477145504-Add_collection_log_meta.ts | 8 +- ...78127555-Remove-tautulli-trailing-slash.ts | 6 +- ...262582-Update_notification_column_types.ts | 26 +- .../1763331885670-RemoveCacheImagesConfig.ts | 20 +- ...03599097-Collection_add_sortTitle_field.ts | 8 +- ...764888122429-AddRuleHandlerCronSchedule.ts | 10 +- .../1767576516009-JellyfinSupport.ts | 276 +++--- .../1768058840535-RemoveTaskRunning.ts | 4 +- .../1768073131263-RemoveEmptyRules.ts | 4 +- ...71699988985-AddCollectionTotalSizeBytes.ts | 20 +- .../1771712257373-SeerrMigration.ts | 40 +- apps/server/src/datasource-config.ts | 12 +- apps/server/src/main.ts | 82 +- .../src/modules/actions/actions.module.ts | 14 +- .../src/modules/actions/media-id-finder.ts | 28 +- .../actions/radarr-action-handler.spec.ts | 138 ++- .../modules/actions/radarr-action-handler.ts | 52 +- .../actions/sonarr-action-handler.spec.ts | 522 +++++------ .../modules/actions/sonarr-action-handler.ts | 148 ++-- .../api/external-api/external-api.module.ts | 4 +- .../api/external-api/external-api.service.ts | 168 ++-- .../api/github-api/github-api.module.ts | 4 +- .../api/github-api/github-api.service.ts | 112 +-- .../helpers/internal-api.helper.ts | 12 +- .../internal-api/internal-api.controller.ts | 4 +- .../api/internal-api/internal-api.module.ts | 8 +- .../api/internal-api/internal-api.service.ts | 16 +- apps/server/src/modules/api/lib/cache.ts | 54 +- apps/server/src/modules/api/lib/plexApi.ts | 148 ++-- .../src/modules/api/lib/plexCommunityApi.ts | 68 +- apps/server/src/modules/api/lib/plextvApi.ts | 244 +++--- .../modules/api/media-server/guards/index.ts | 2 +- .../guards/media-server-setup.guard.spec.ts | 31 +- .../guards/media-server-setup.guard.ts | 12 +- .../src/modules/api/media-server/index.ts | 12 +- .../api/media-server/jellyfin/index.ts | 10 +- .../jellyfin/jellyfin-adapter.service.spec.ts | 188 ++-- .../jellyfin/jellyfin-adapter.service.ts | 639 +++++++------- .../jellyfin/jellyfin.constants.ts | 12 +- .../jellyfin/jellyfin.mapper.spec.ts | 420 ++++----- .../media-server/jellyfin/jellyfin.mapper.ts | 114 +-- .../media-server/jellyfin/jellyfin.module.ts | 6 +- .../media-server/jellyfin/jellyfin.types.ts | 30 +- .../media-server/media-server.constants.ts | 6 +- .../media-server.controller.spec.ts | 100 +-- .../media-server/media-server.controller.ts | 110 +-- .../media-server/media-server.factory.spec.ts | 116 +-- .../api/media-server/media-server.factory.ts | 92 +- .../media-server/media-server.interface.ts | 66 +- .../api/media-server/media-server.module.ts | 18 +- .../plex/plex-adapter.service.spec.ts | 234 ++--- .../media-server/plex/plex-adapter.service.ts | 209 +++-- .../api/media-server/plex/plex.constants.ts | 10 +- .../api/media-server/plex/plex.mapper.spec.ts | 294 +++---- .../api/media-server/plex/plex.mapper.ts | 114 +-- .../dto/collection-hub-settings.dto.ts | 10 +- .../api/plex-api/enums/plex-data-type-enum.ts | 2 +- .../interfaces/collection.interface.ts | 82 +- .../plex-api/interfaces/library.interfaces.ts | 184 ++-- .../plex-api/interfaces/media.interface.ts | 106 +-- .../plex-api/interfaces/server.interface.ts | 72 +- .../plex-api/plex-api-legacy.controller.ts | 184 ++-- .../modules/api/plex-api/plex-api.module.ts | 10 +- .../modules/api/plex-api/plex-api.service.ts | 536 ++++++------ .../api/seerr-api/helpers/seerr-api.helper.ts | 10 +- .../api/seerr-api/seerr-api.controller.ts | 14 +- .../modules/api/seerr-api/seerr-api.module.ts | 8 +- .../api/seerr-api/seerr-api.service.ts | 334 +++---- .../servarr-api/common/servarr-api.service.ts | 94 +- .../api/servarr-api/helpers/radarr.helper.ts | 92 +- .../api/servarr-api/helpers/sonarr.helper.ts | 148 ++-- .../interfaces/radarr.interface.ts | 198 ++--- .../interfaces/servarr.interface.ts | 122 +-- .../interfaces/sonarr.interface.ts | 252 +++--- .../api/servarr-api/servarr-api.controller.ts | 12 +- .../api/servarr-api/servarr-api.module.ts | 8 +- .../api/servarr-api/servarr.service.ts | 54 +- .../helpers/tautulli-api.helper.ts | 10 +- .../tautulli-api/tautulli-api.controller.ts | 4 +- .../api/tautulli-api/tautulli-api.module.ts | 8 +- .../api/tautulli-api/tautulli-api.service.ts | 248 +++--- .../api/tmdb-api/interfaces/tmdb.interface.ts | 492 +++++------ .../modules/api/tmdb-api/tmdb-id.service.ts | 54 +- .../modules/api/tmdb-api/tmdb.controller.ts | 12 +- .../src/modules/api/tmdb-api/tmdb.module.ts | 12 +- .../src/modules/api/tmdb-api/tmdb.service.ts | 104 +-- .../collections/collection-handler.spec.ts | 220 ++--- .../modules/collections/collection-handler.ts | 76 +- .../collection-worker.server.spec.ts | 132 ++- .../collections/collection-worker.service.ts | 160 ++-- .../collections/collections.controller.ts | 92 +- .../modules/collections/collections.module.ts | 44 +- .../collections/collections.service.ts | 826 +++++++++--------- .../entities/collection.entities.ts | 76 +- .../entities/collection_log.entities.ts | 18 +- .../entities/collection_media.entities.ts | 24 +- .../interfaces/collection-media.interface.ts | 26 +- .../interfaces/collection.interface.ts | 70 +- .../tasks/collection-log-cleaner.service.ts | 24 +- .../events/events-buffer.service.spec.ts | 94 +- .../modules/events/events-buffer.service.ts | 56 +- .../src/modules/events/events.controller.ts | 84 +- .../src/modules/events/events.module.ts | 6 +- .../logging/entities/logSettings.entities.ts | 18 +- .../src/modules/logging/logFormatting.ts | 14 +- .../src/modules/logging/logs.controller.ts | 204 ++--- .../server/src/modules/logging/logs.module.ts | 50 +- .../src/modules/logging/logs.service.ts | 98 +-- .../logging/winston/eventEmitterTransport.ts | 12 +- .../src/modules/notifications/agents/agent.ts | 24 +- .../modules/notifications/agents/discord.ts | 112 +-- .../src/modules/notifications/agents/email.ts | 50 +- .../modules/notifications/agents/gotify.ts | 66 +- .../modules/notifications/agents/lunasea.ts | 48 +- .../notifications/agents/pushbullet.ts | 66 +- .../modules/notifications/agents/pushover.ts | 101 ++- .../src/modules/notifications/agents/slack.ts | 102 +-- .../modules/notifications/agents/telegram.ts | 76 +- .../modules/notifications/agents/webhook.ts | 84 +- .../notifications/email/openPgpEncrypt.ts | 116 +-- .../notifications/email/preparedEmail.ts | 16 +- .../entities/notification.entities.ts | 22 +- .../notifications/notifications-interfaces.ts | 112 +-- .../notifications-timer.service.ts | 52 +- .../notifications/notifications.controller.ts | 64 +- .../notifications/notifications.module.ts | 22 +- .../notifications/notifications.service.ts | 400 +++++---- .../rules/constants/constants.service.ts | 126 +-- .../rules/constants/rules.constants.ts | 42 +- .../modules/rules/dtos/communityRule.dto.ts | 14 +- .../src/modules/rules/dtos/exclusion.dto.ts | 20 +- .../server/src/modules/rules/dtos/rule.dto.ts | 16 +- .../src/modules/rules/dtos/ruleDb.dto.ts | 10 +- .../src/modules/rules/dtos/rules.dto.ts | 48 +- .../entities/community-rule-karma.entities.ts | 6 +- .../rules/entities/exclusion.entities.ts | 16 +- .../rules/entities/rule-group.entities.ts | 36 +- .../modules/rules/entities/rules.entities.ts | 16 +- .../modules/rules/getter/getter.service.ts | 43 +- .../getter/jellyfin-getter.service.spec.ts | 366 ++++---- .../rules/getter/jellyfin-getter.service.ts | 451 +++++----- .../rules/getter/plex-getter.service.ts | 423 +++++---- .../getter/radarr-getter.service.spec.ts | 158 ++-- .../rules/getter/radarr-getter.service.ts | 124 +-- .../rules/getter/seerr-getter.service.spec.ts | 160 ++-- .../rules/getter/seerr-getter.service.ts | 158 ++-- .../getter/sonarr-getter.service.spec.ts | 288 +++--- .../rules/getter/sonarr-getter.service.ts | 288 +++--- .../rules/getter/tautulli-getter.service.ts | 142 ++- .../helpers/collection-exclude.helper.ts | 10 +- .../rules/helpers/rule.comparator.service.ts | 326 +++---- .../src/modules/rules/helpers/yaml.service.ts | 92 +- .../src/modules/rules/rules.controller.ts | 136 +-- apps/server/src/modules/rules/rules.module.ts | 74 +- .../rules.service.deleteRuleGroup.spec.ts | 122 +-- .../server/src/modules/rules/rules.service.ts | 666 +++++++------- .../tasks/exclusion-corrector.service.spec.ts | 166 ++-- .../tasks/exclusion-corrector.service.ts | 82 +- .../rule-executor-job-manager.service.spec.ts | 164 ++-- .../rule-executor-job-manager.service.ts | 136 +-- .../tasks/rule-executor-progress.service.ts | 40 +- .../rule-executor-scheduler.service.spec.ts | 100 +-- .../tasks/rule-executor-scheduler.service.ts | 162 ++-- .../rules/tasks/rule-executor.service.spec.ts | 134 +-- .../rules/tasks/rule-executor.service.ts | 314 ++++--- .../rules/tasks/rule-maintenance.service.ts | 62 +- ...le.comparator.service.doRuleAction.spec.ts | 328 ++++--- .../settings/database-download.service.ts | 34 +- .../settings/dto's/cron.schedule.dto.ts | 2 +- .../settings/dto's/radarr-setting.dto.ts | 52 +- .../src/modules/settings/dto's/setting.dto.ts | 46 +- .../settings/dto's/sonarr-setting.dto.ts | 52 +- .../settings/dto's/update-setting.dto.ts | 4 +- .../entities/radarr_settings.entities.ts | 14 +- .../settings/entities/settings.entities.ts | 52 +- .../entities/sonarr_settings.entities.ts | 14 +- .../interfaces/dvr-settings.interface.ts | 32 +- .../media-server-switch.service.spec.ts | 289 +++--- .../settings/media-server-switch.service.ts | 178 ++-- .../settings/rule-migration.service.spec.ts | 440 +++++----- .../settings/rule-migration.service.ts | 264 +++--- .../settings/settings.controller.spec.ts | 81 +- .../modules/settings/settings.controller.ts | 118 +-- .../src/modules/settings/settings.module.ts | 44 +- .../src/modules/settings/settings.service.ts | 652 +++++++------- .../src/modules/stats/stats.controller.ts | 6 +- apps/server/src/modules/stats/stats.module.ts | 28 +- .../server/src/modules/stats/stats.service.ts | 478 +++++----- .../tasks/execution-lock.service.spec.ts | 90 +- .../modules/tasks/execution-lock.service.ts | 28 +- .../tasks/interfaces/status.interface.ts | 4 +- .../src/modules/tasks/status.service.ts | 6 +- .../src/modules/tasks/task.base.spec.ts | 164 ++-- apps/server/src/modules/tasks/task.base.ts | 80 +- .../src/modules/tasks/tasks.controller.ts | 12 +- apps/server/src/modules/tasks/tasks.module.ts | 12 +- .../src/modules/tasks/tasks.service.spec.ts | 124 +-- .../server/src/modules/tasks/tasks.service.ts | 96 +- apps/server/src/utils/__mocks__/delay.ts | 6 +- apps/server/src/utils/delay.ts | 10 +- .../test/jellyfin-sdk-runtime-smoke.cjs | 20 +- apps/server/test/jest.setup.ts | 8 +- apps/server/test/utils/data.ts | 90 +- apps/server/test/utils/servarr-mock.ts | 56 +- apps/ui/src/components/AddModal/index.tsx | 20 +- apps/ui/src/components/Calendar/index.tsx | 2 + .../CollectionDetail/CollectionInfo/index.tsx | 97 +- .../CollectionDetail/Exclusions/index.tsx | 84 +- .../Common/CommunityRuleModal/index.tsx | 57 +- .../Common/MediaCard/MediaModal/index.tsx | 1 - .../src/components/Common/MediaCard/index.tsx | 30 +- .../src/components/Common/SearchBar/index.tsx | 9 +- .../components/Common/ToggleButton/index.tsx | 6 +- apps/ui/src/components/Layout/index.tsx | 12 +- .../ui/src/components/Media/Content/index.tsx | 23 +- apps/ui/src/components/Media/index.tsx | 152 ++-- apps/ui/src/components/Overview/index.tsx | 16 +- .../Rule/RuleCreator/RuleInput/index.tsx | 310 ++++--- .../Rules/Rule/RuleCreator/index.tsx | 43 +- .../RuleGroup/AddModal/ArrAction/index.tsx | 56 +- .../ConfigureNotificationModal/index.tsx | 2 +- .../CreateNotificationModal/index.tsx | 9 +- .../ui/src/components/Settings/Plex/index.tsx | 66 +- .../src/components/Settings/Radarr/index.tsx | 2 +- .../src/components/Settings/Sonarr/index.tsx | 2 +- apps/ui/src/contexts/events-context.tsx | 21 +- apps/ui/src/pages/CollectionMediaPage.tsx | 83 +- apps/ui/src/pages/CollectionsListPage.tsx | 19 +- 255 files changed, 11961 insertions(+), 11911 deletions(-) diff --git a/apps/server/eslint.config.mjs b/apps/server/eslint.config.mjs index 05d01294..1f5a92e6 100644 --- a/apps/server/eslint.config.mjs +++ b/apps/server/eslint.config.mjs @@ -1,19 +1,19 @@ -import { FlatCompat } from '@eslint/eslintrc'; -import js from '@eslint/js'; -import tsParser from '@typescript-eslint/parser'; -import eslintConfigPrettier from 'eslint-config-prettier'; -import pluginJest from 'eslint-plugin-jest'; -import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { FlatCompat } from '@eslint/eslintrc' +import js from '@eslint/js' +import tsParser from '@typescript-eslint/parser' +import eslintConfigPrettier from 'eslint-config-prettier' +import pluginJest from 'eslint-plugin-jest' +import globals from 'globals' +import path from 'node:path' +import { fileURLToPath } from 'node:url' -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) const compat = new FlatCompat({ baseDirectory: __dirname, recommendedConfig: js.configs.recommended, allConfig: js.configs.all, -}); +}) export default [ { @@ -70,4 +70,4 @@ export default [ }, eslintConfigPrettier, -]; +] diff --git a/apps/server/prettier.config.mjs b/apps/server/prettier.config.mjs index 7f5ce460..3403812c 100644 --- a/apps/server/prettier.config.mjs +++ b/apps/server/prettier.config.mjs @@ -4,6 +4,7 @@ */ const config = { singleQuote: true, -}; + semi: false, +} -export default config; +export default config diff --git a/apps/server/src/app/app.controller.ts b/apps/server/src/app/app.controller.ts index 020c5bd3..4cf69e44 100644 --- a/apps/server/src/app/app.controller.ts +++ b/apps/server/src/app/app.controller.ts @@ -1,6 +1,6 @@ -import { Controller, Get } from '@nestjs/common'; -import { GitHubApiService } from '../modules/api/github-api/github-api.service'; -import { AppService } from './app.service'; +import { Controller, Get } from '@nestjs/common' +import { GitHubApiService } from '../modules/api/github-api/github-api.service' +import { AppService } from './app.service' @Controller('/api/app') export class AppController { @@ -11,12 +11,12 @@ export class AppController { @Get('/status') async getAppStatus() { - return JSON.stringify(await this.appService.getAppVersionStatus()); + return JSON.stringify(await this.appService.getAppVersionStatus()) } @Get('/timezone') async getAppTimezone() { - return Intl.DateTimeFormat().resolvedOptions().timeZone; + return Intl.DateTimeFormat().resolvedOptions().timeZone } @Get('/releases') @@ -25,7 +25,7 @@ export class AppController { 'maintainerr', 'maintainerr', 10, - ); - return releases || []; + ) + return releases || [] } } diff --git a/apps/server/src/app/app.module.ts b/apps/server/src/app/app.module.ts index 0f140280..2b69f163 100644 --- a/apps/server/src/app/app.module.ts +++ b/apps/server/src/app/app.module.ts @@ -1,34 +1,34 @@ -import { Module, OnModuleInit } from '@nestjs/common'; -import { APP_PIPE } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; -import { ServeStaticModule } from '@nestjs/serve-static'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { GracefulShutdownModule } from '@tygra/nestjs-graceful-shutdown'; -import { ZodValidationPipe } from 'nestjs-zod'; -import { join } from 'path'; -import { ExternalApiModule } from '../modules/api/external-api/external-api.module'; -import { GitHubApiModule } from '../modules/api/github-api/github-api.module'; -import { MediaServerFactory } from '../modules/api/media-server/media-server.factory'; -import { MediaServerModule } from '../modules/api/media-server/media-server.module'; -import { PlexApiModule } from '../modules/api/plex-api/plex-api.module'; -import { SeerrApiModule } from '../modules/api/seerr-api/seerr-api.module'; -import { SeerrApiService } from '../modules/api/seerr-api/seerr-api.service'; -import { ServarrApiModule } from '../modules/api/servarr-api/servarr-api.module'; -import { TautulliApiModule } from '../modules/api/tautulli-api/tautulli-api.module'; -import { TautulliApiService } from '../modules/api/tautulli-api/tautulli-api.service'; -import { TmdbApiModule } from '../modules/api/tmdb-api/tmdb.module'; -import { CollectionsModule } from '../modules/collections/collections.module'; -import { EventsModule } from '../modules/events/events.module'; -import { LogsModule } from '../modules/logging/logs.module'; -import { NotificationsModule } from '../modules/notifications/notifications.module'; -import { NotificationService } from '../modules/notifications/notifications.service'; -import { RulesModule } from '../modules/rules/rules.module'; -import { SettingsModule } from '../modules/settings/settings.module'; -import { SettingsService } from '../modules/settings/settings.service'; -import { StatsModule } from '../modules/stats/stats.module'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import ormConfig from './config/typeOrmConfig'; +import { Module, OnModuleInit } from '@nestjs/common' +import { APP_PIPE } from '@nestjs/core' +import { EventEmitterModule } from '@nestjs/event-emitter' +import { ServeStaticModule } from '@nestjs/serve-static' +import { TypeOrmModule } from '@nestjs/typeorm' +import { GracefulShutdownModule } from '@tygra/nestjs-graceful-shutdown' +import { ZodValidationPipe } from 'nestjs-zod' +import { join } from 'path' +import { ExternalApiModule } from '../modules/api/external-api/external-api.module' +import { GitHubApiModule } from '../modules/api/github-api/github-api.module' +import { MediaServerFactory } from '../modules/api/media-server/media-server.factory' +import { MediaServerModule } from '../modules/api/media-server/media-server.module' +import { PlexApiModule } from '../modules/api/plex-api/plex-api.module' +import { SeerrApiModule } from '../modules/api/seerr-api/seerr-api.module' +import { SeerrApiService } from '../modules/api/seerr-api/seerr-api.service' +import { ServarrApiModule } from '../modules/api/servarr-api/servarr-api.module' +import { TautulliApiModule } from '../modules/api/tautulli-api/tautulli-api.module' +import { TautulliApiService } from '../modules/api/tautulli-api/tautulli-api.service' +import { TmdbApiModule } from '../modules/api/tmdb-api/tmdb.module' +import { CollectionsModule } from '../modules/collections/collections.module' +import { EventsModule } from '../modules/events/events.module' +import { LogsModule } from '../modules/logging/logs.module' +import { NotificationsModule } from '../modules/notifications/notifications.module' +import { NotificationService } from '../modules/notifications/notifications.service' +import { RulesModule } from '../modules/rules/rules.module' +import { SettingsModule } from '../modules/settings/settings.module' +import { SettingsService } from '../modules/settings/settings.service' +import { StatsModule } from '../modules/stats/stats.module' +import { AppController } from './app.controller' +import { AppService } from './app.service' +import ormConfig from './config/typeOrmConfig' @Module({ imports: [ @@ -55,7 +55,7 @@ import ormConfig from './config/typeOrmConfig'; ServeStaticModule.forRootAsync({ useFactory: () => { if (process.env.NODE_ENV !== 'production') { - return []; + return [] } return [ @@ -64,7 +64,7 @@ import ormConfig from './config/typeOrmConfig'; serveRoot: process.env.BASE_PATH || undefined, exclude: ['/api/{*path}'], }, - ]; + ] }, }), ], @@ -87,15 +87,15 @@ export class AppModule implements OnModuleInit { ) {} async onModuleInit() { // Initialize modules requiring settings - await this.settings.init(); + await this.settings.init() // Initialize configured media server (Plex or Jellyfin) - await this.mediaServerFactory.initialize(); + await this.mediaServerFactory.initialize() - this.seerrApi.init(); - this.tautulliApi.init(); + this.seerrApi.init() + this.tautulliApi.init() // intialize notification agents - await this.notificationService.registerConfiguredAgents(); + await this.notificationService.registerConfiguredAgents() } } diff --git a/apps/server/src/app/app.service.ts b/apps/server/src/app/app.service.ts index 81a90a95..04df3376 100644 --- a/apps/server/src/app/app.service.ts +++ b/apps/server/src/app/app.service.ts @@ -1,7 +1,7 @@ -import { type VersionResponse } from '@maintainerr/contracts'; -import { Injectable } from '@nestjs/common'; -import { GitHubApiService } from '../modules/api/github-api/github-api.service'; -import { MaintainerrLogger } from '../modules/logging/logs.service'; +import { type VersionResponse } from '@maintainerr/contracts' +import { Injectable } from '@nestjs/common' +import { GitHubApiService } from '../modules/api/github-api/github-api.service' +import { MaintainerrLogger } from '../modules/logging/logs.service' @Injectable() export class AppService { @@ -9,27 +9,27 @@ export class AppService { private readonly githubApi: GitHubApiService, private readonly logger: MaintainerrLogger, ) { - logger.setContext(AppService.name); + logger.setContext(AppService.name) } async getAppVersionStatus(): Promise { try { const packageVersion = process.env.npm_package_version ? process.env.npm_package_version - : '0.0.1'; + : '0.0.1' const versionTag = process.env.VERSION_TAG ? process.env.VERSION_TAG - : 'develop'; + : 'develop' const calculatedVersion = versionTag !== 'stable' ? process.env.GIT_SHA ? `${versionTag}-${process.env.GIT_SHA.substring(0, 7)}` : `${versionTag}-` - : `${packageVersion}`; + : `${packageVersion}` - const local = process.env.NODE_ENV !== 'production'; + const local = process.env.NODE_ENV !== 'production' return { status: 1, @@ -39,15 +39,15 @@ export class AppService { packageVersion, versionTag, ), - }; + } } catch (err) { - this.logger.error(`Couldn't fetch app version status`, err); + this.logger.error(`Couldn't fetch app version status`, err) return { status: 0, version: '0.0.1', commitTag: '', updateAvailable: false, - }; + } } } @@ -56,20 +56,20 @@ export class AppService { const githubResp = await this.githubApi.getLatestRelease( 'Maintainerr', 'Maintainerr', - ); + ) if (githubResp && githubResp.tag_name) { const transformedLocalVersion = currentVersion .replace('v', '') - .replace('.', ''); + .replace('.', '') const transformedGithubVersion = githubResp.tag_name .replace('v', '') - .replace('.', ''); + .replace('.', '') - return transformedGithubVersion > transformedLocalVersion; + return transformedGithubVersion > transformedLocalVersion } - this.logger.warn(`Couldn't fetch latest release version from GitHub`); - return false; + this.logger.warn(`Couldn't fetch latest release version from GitHub`) + return false } else { // in case of develop, compare SHA's if (process.env.GIT_SHA) { @@ -77,12 +77,12 @@ export class AppService { 'Maintainerr', 'Maintainerr', 'main', - ); + ) if (githubResp && githubResp.sha) { - return githubResp.sha !== process.env.GIT_SHA; + return githubResp.sha !== process.env.GIT_SHA } } } - return false; + return false } } diff --git a/apps/server/src/app/config/typeOrmConfig.ts b/apps/server/src/app/config/typeOrmConfig.ts index 91aa56bc..39d8e686 100644 --- a/apps/server/src/app/config/typeOrmConfig.ts +++ b/apps/server/src/app/config/typeOrmConfig.ts @@ -1,4 +1,4 @@ -import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { TypeOrmModuleOptions } from '@nestjs/typeorm' const ormConfig: TypeOrmModuleOptions = { type: 'better-sqlite3', @@ -14,5 +14,5 @@ const ormConfig: TypeOrmModuleOptions = { : ['./dist/database/migrations/**/*{.js,.ts}'], autoLoadEntities: true, migrationsRun: true, -}; -export default ormConfig; +} +export default ormConfig diff --git a/apps/server/src/database/migrations/1674487252453-Initial_migration.ts b/apps/server/src/database/migrations/1674487252453-Initial_migration.ts index 136b9d94..44801d2d 100644 --- a/apps/server/src/database/migrations/1674487252453-Initial_migration.ts +++ b/apps/server/src/database/migrations/1674487252453-Initial_migration.ts @@ -1,7 +1,7 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class InitialMigration1674487252453 implements MigrationInterface { - name = 'InitialMigration1674487252453'; + name = 'InitialMigration1674487252453' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`CREATE TABLE IF NOT EXISTS "settings" ( @@ -26,20 +26,20 @@ export class InitialMigration1674487252453 implements MigrationInterface { "collection_handler_job_cron" varchar NOT NULL DEFAULT ('0 0-23/12 * * *'), "rules_handler_job_cron" varchar NOT NULL DEFAULT ('0 0-23/8 * * *'), PRIMARY KEY("id" AUTOINCREMENT) - );`); + );`) await queryRunner.query(`CREATE TABLE IF NOT EXISTS "community_rule_karma" ( "id" integer NOT NULL, "community_rule_id" integer NOT NULL, PRIMARY KEY("id" AUTOINCREMENT) - );`); + );`) await queryRunner.query(`CREATE TABLE IF NOT EXISTS "exclusion" ( "id" integer NOT NULL, "plexId" integer NOT NULL, "ruleGroupId" integer, PRIMARY KEY("id" AUTOINCREMENT) - );`); + );`) await queryRunner.query(`CREATE TABLE IF NOT EXISTS "collection_media" ( "id" integer NOT NULL, @@ -51,7 +51,7 @@ export class InitialMigration1674487252453 implements MigrationInterface { "isManual" boolean DEFAULT (0), CONSTRAINT "FK_604b0cd0f85150923289b7f2c19" FOREIGN KEY("collectionId") REFERENCES "collection"("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY("id" AUTOINCREMENT) - );`); + );`) await queryRunner.query(`CREATE TABLE IF NOT EXISTS "rules" ( "id" integer NOT NULL, @@ -61,7 +61,7 @@ export class InitialMigration1674487252453 implements MigrationInterface { "isActive" boolean NOT NULL DEFAULT (1), CONSTRAINT "FK_bb013935b8859281ad67e311d19" FOREIGN KEY("ruleGroupId") REFERENCES "rule_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY("id" AUTOINCREMENT) - );`); + );`) await queryRunner.query(`CREATE TABLE IF NOT EXISTS "rule_group" ( "id" integer NOT NULL, @@ -73,7 +73,7 @@ export class InitialMigration1674487252453 implements MigrationInterface { CONSTRAINT "FK_9c757efe456ec36319ef10e9648" FOREIGN KEY("collectionId") REFERENCES "collection"("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "REL_9c757efe456ec36319ef10e964" UNIQUE("collectionId"), PRIMARY KEY("id" AUTOINCREMENT) - );`); + );`) await queryRunner.query(`CREATE TABLE IF NOT EXISTS "collection" ( "id" integer NOT NULL, @@ -87,16 +87,16 @@ export class InitialMigration1674487252453 implements MigrationInterface { "deleteAfterDays" integer, "type" integer NOT NULL DEFAULT (1), PRIMARY KEY("id" AUTOINCREMENT) - );`); + );`) } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "settings";`); - await queryRunner.query(`DROP TABLE "community_rule_karma";`); - await queryRunner.query(`DROP TABLE "exclusion";`); - await queryRunner.query(`DROP TABLE "collection_media";`); - await queryRunner.query(`DROP TABLE "rules";`); - await queryRunner.query(`DROP TABLE "rule_group";`); - await queryRunner.query(`DROP TABLE "collection";`); + await queryRunner.query(`DROP TABLE "settings";`) + await queryRunner.query(`DROP TABLE "community_rule_karma";`) + await queryRunner.query(`DROP TABLE "exclusion";`) + await queryRunner.query(`DROP TABLE "collection_media";`) + await queryRunner.query(`DROP TABLE "rules";`) + await queryRunner.query(`DROP TABLE "rule_group";`) + await queryRunner.query(`DROP TABLE "collection";`) } } diff --git a/apps/server/src/database/migrations/1674489541784-Optional_manual_collection.ts b/apps/server/src/database/migrations/1674489541784-Optional_manual_collection.ts index 57c20cf6..41eaa60c 100644 --- a/apps/server/src/database/migrations/1674489541784-Optional_manual_collection.ts +++ b/apps/server/src/database/migrations/1674489541784-Optional_manual_collection.ts @@ -1,31 +1,31 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class OptionalManualCollection1674489541784 implements MigrationInterface { - name = 'OptionalManualCollection1674489541784'; + name = 'OptionalManualCollection1674489541784' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE collection ADD "manualCollection" boolean NOT NULL DEFAULT (0)`, - ); + ) await queryRunner.query( `ALTER TABLE collection ADD "manualCollectionName" varchar(255) DEFAULT NULL`, - ); + ) await queryRunner.query( `ALTER TABLE rule_group ADD "useRules" boolean NOT NULL DEFAULT (1)`, - ); + ) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE collection DROP COLUMN manualCollection;`, - ); + ) await queryRunner.query( `ALTER TABLE collection DROP COLUMN manualCollectionName;`, - ); + ) - await queryRunner.query(`ALTER TABLE rule_group DROP COLUMN useRules;`); + await queryRunner.query(`ALTER TABLE rule_group DROP COLUMN useRules;`) } } diff --git a/apps/server/src/database/migrations/1694102061641-Add_collection_chidren_and_data_type.ts b/apps/server/src/database/migrations/1694102061641-Add_collection_chidren_and_data_type.ts index e490ab42..1e5e7c42 100644 --- a/apps/server/src/database/migrations/1694102061641-Add_collection_chidren_and_data_type.ts +++ b/apps/server/src/database/migrations/1694102061641-Add_collection_chidren_and_data_type.ts @@ -1,13 +1,13 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class AddCollectionChidrenAndDataType1694102061641 implements MigrationInterface { - name = 'AddCollectionChidrenAndDataType1694102061641'; + name = 'AddCollectionChidrenAndDataType1694102061641' public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE rule_group ADD "dataType" integer`); + await queryRunner.query(`ALTER TABLE rule_group ADD "dataType" integer`) } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE rule_group DROP "dataType"`); + await queryRunner.query(`ALTER TABLE rule_group DROP "dataType"`) } } diff --git a/apps/server/src/database/migrations/1695046207528-Add_collection_listExclusion_column.ts b/apps/server/src/database/migrations/1695046207528-Add_collection_listExclusion_column.ts index d328639c..41818831 100644 --- a/apps/server/src/database/migrations/1695046207528-Add_collection_listExclusion_column.ts +++ b/apps/server/src/database/migrations/1695046207528-Add_collection_listExclusion_column.ts @@ -1,15 +1,15 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class AddCollectionListExclusionColumn1695046207528 implements MigrationInterface { - name = 'AddCollectionListExclusionColumn1695046207528'; + name = 'AddCollectionListExclusionColumn1695046207528' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE collection ADD "listExclusions" boolean NOT NULL default (0)`, - ); + ) } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE collection DROP "listExclusions"`); + await queryRunner.query(`ALTER TABLE collection DROP "listExclusions"`) } } diff --git a/apps/server/src/database/migrations/1695310572854-overseerr_force_remove_requests.ts b/apps/server/src/database/migrations/1695310572854-overseerr_force_remove_requests.ts index cba66c37..2ac45a45 100644 --- a/apps/server/src/database/migrations/1695310572854-overseerr_force_remove_requests.ts +++ b/apps/server/src/database/migrations/1695310572854-overseerr_force_remove_requests.ts @@ -1,15 +1,15 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class overseerrForceRemoveRequests1695310572854 implements MigrationInterface { - name = 'overseerrForceRemoveRequests1695310572854'; + name = 'overseerrForceRemoveRequests1695310572854' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE collection ADD "forceOverseerr" boolean NOT NULL default (0)`, - ); + ) } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE collection DROP "forceOverseerr"`); + await queryRunner.query(`ALTER TABLE collection DROP "forceOverseerr"`) } } diff --git a/apps/server/src/database/migrations/1702366607151-Exclusions_add_parent_field.ts b/apps/server/src/database/migrations/1702366607151-Exclusions_add_parent_field.ts index 7e0456a2..ab8ad539 100644 --- a/apps/server/src/database/migrations/1702366607151-Exclusions_add_parent_field.ts +++ b/apps/server/src/database/migrations/1702366607151-Exclusions_add_parent_field.ts @@ -1,13 +1,13 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class ExclusionsAddParentField1702366607151 implements MigrationInterface { - name = 'ExclusionsAddParentField1702366607151'; + name = 'ExclusionsAddParentField1702366607151' public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE exclusion ADD "parent" integer`); + await queryRunner.query(`ALTER TABLE exclusion ADD "parent" integer`) } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE exclusion DROP "parent"`); + await queryRunner.query(`ALTER TABLE exclusion DROP "parent"`) } } diff --git a/apps/server/src/database/migrations/1705399208460-Exclusions_add_type_field.ts b/apps/server/src/database/migrations/1705399208460-Exclusions_add_type_field.ts index ea955d46..8411dca1 100644 --- a/apps/server/src/database/migrations/1705399208460-Exclusions_add_type_field.ts +++ b/apps/server/src/database/migrations/1705399208460-Exclusions_add_type_field.ts @@ -1,15 +1,15 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class ExclusionsAddTypeField1705399208460 implements MigrationInterface { - name = 'ExclusionsAddTypeField1705399208460'; + name = 'ExclusionsAddTypeField1705399208460' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( 'ALTER TABLE exclusion ADD COLUMN "type" INTEGER CHECK("type" IS NULL OR "type" IN (1, 2, 3, 4)) DEFAULT NULL', - ); + ) } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE exclusion DROP "type"`); + await queryRunner.query(`ALTER TABLE exclusion DROP "type"`) } } diff --git a/apps/server/src/database/migrations/1705919105309-Collection_add_info_fields.ts b/apps/server/src/database/migrations/1705919105309-Collection_add_info_fields.ts index 814ad2dc..dc9654f0 100644 --- a/apps/server/src/database/migrations/1705919105309-Collection_add_info_fields.ts +++ b/apps/server/src/database/migrations/1705919105309-Collection_add_info_fields.ts @@ -1,27 +1,27 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class CollectionAddInfoFields1705919105309 implements MigrationInterface { - name = 'CollectionAddInfoFields1705919105309'; + name = 'CollectionAddInfoFields1705919105309' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( 'ALTER TABLE collection ADD COLUMN "addDate" datetime', - ); // for postgress: ALTER TABLE collection ADD COLUMN "addDate" timestamp with(out) time zone NOT NULL DEFAULT now() + ) // for postgress: ALTER TABLE collection ADD COLUMN "addDate" timestamp with(out) time zone NOT NULL DEFAULT now() await queryRunner.query( 'ALTER TABLE collection ADD COLUMN "handledMediaAmount" INTEGER NOT NULL DEFAULT 0', - ); + ) await queryRunner.query( 'ALTER TABLE collection ADD COLUMN "lastDurationInSeconds" INTEGER NOT NULL DEFAULT 0', - ); + ) } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE collection DROP "addDate"`); - await queryRunner.query(`ALTER TABLE collection DROP "handledMediaAmount"`); + await queryRunner.query(`ALTER TABLE collection DROP "addDate"`) + await queryRunner.query(`ALTER TABLE collection DROP "handledMediaAmount"`) await queryRunner.query( `ALTER TABLE collection DROP "lastDurationInSeconds"`, - ); + ) } } diff --git a/apps/server/src/database/migrations/1705930583532-Collection_Log_add.ts b/apps/server/src/database/migrations/1705930583532-Collection_Log_add.ts index e80e1944..a79008d6 100644 --- a/apps/server/src/database/migrations/1705930583532-Collection_Log_add.ts +++ b/apps/server/src/database/migrations/1705930583532-Collection_Log_add.ts @@ -1,7 +1,7 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class CollectionLogAdd1705930583532 implements MigrationInterface { - name = 'CollectionLogAdd1705930583532'; + name = 'CollectionLogAdd1705930583532' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` @@ -12,25 +12,25 @@ export class CollectionLogAdd1705930583532 implements MigrationInterface { "message" varchar NOT NULL, "type" integer NOT NULL ) - `); + `) await queryRunner.query(` CREATE INDEX "idx_collection_log_collection_id" ON "collection_log" ("collectionId") - `); + `) await queryRunner.query(` CREATE INDEX "idx_collection_media_collection_id" ON "collection_media" ("collectionId") - `); + `) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` DROP INDEX "idx_collection_log_collection_id" - `); + `) await queryRunner.query(` DROP INDEX "idx_collection_media_collection_id" - `); + `) await queryRunner.query(` DROP TABLE "collection_log" -`); +`) } } diff --git a/apps/server/src/database/migrations/1706016592389-Collection_add_keep_logs_months.ts b/apps/server/src/database/migrations/1706016592389-Collection_add_keep_logs_months.ts index d8f1f0dd..d75abb58 100644 --- a/apps/server/src/database/migrations/1706016592389-Collection_add_keep_logs_months.ts +++ b/apps/server/src/database/migrations/1706016592389-Collection_add_keep_logs_months.ts @@ -1,15 +1,15 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class CollectionAddKeepLogsMonths1706016592389 implements MigrationInterface { - name = 'CollectionAddKeepLogsMonths1706016592389'; + name = 'CollectionAddKeepLogsMonths1706016592389' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( 'ALTER TABLE collection ADD COLUMN "keepLogsForMonths" INTEGER NOT NULL DEFAULT 6', - ); + ) } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE collection DROP "keepLogsForMonths"`); + await queryRunner.query(`ALTER TABLE collection DROP "keepLogsForMonths"`) } } diff --git a/apps/server/src/database/migrations/1706275100801-Tasks_add_taskRunning_table.ts b/apps/server/src/database/migrations/1706275100801-Tasks_add_taskRunning_table.ts index bdfdab22..ba0f16aa 100644 --- a/apps/server/src/database/migrations/1706275100801-Tasks_add_taskRunning_table.ts +++ b/apps/server/src/database/migrations/1706275100801-Tasks_add_taskRunning_table.ts @@ -1,7 +1,7 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class TasksAddTaskRunningTable1706275100801 implements MigrationInterface { - name = 'TasksAddTaskRunningTable1706275100801'; + name = 'TasksAddTaskRunningTable1706275100801' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` @@ -11,12 +11,12 @@ export class TasksAddTaskRunningTable1706275100801 implements MigrationInterface "runningSince" datetime DEFAULT NULL, "running" boolean NOT NULL DEFAULT (0) ) - `); + `) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` DROP TABLE "task_running" - `); + `) } } diff --git a/apps/server/src/database/migrations/1727097172777-Add_Tautulli_settings.ts b/apps/server/src/database/migrations/1727097172777-Add_Tautulli_settings.ts index 70b596e0..d9f41ede 100644 --- a/apps/server/src/database/migrations/1727097172777-Add_Tautulli_settings.ts +++ b/apps/server/src/database/migrations/1727097172777-Add_Tautulli_settings.ts @@ -1,19 +1,19 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class AddTautulliSettings1727097172777 implements MigrationInterface { - name = 'AddTautulliSettings1727097172777'; + 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"`); + await queryRunner.query(`ALTER TABLE settings DROP "tautulli_url"`) + await queryRunner.query(`ALTER TABLE settings DROP "tautulli_api_key"`) } } diff --git a/apps/server/src/database/migrations/1727516980165-Add_Tautulli_watched_percent_override.ts b/apps/server/src/database/migrations/1727516980165-Add_Tautulli_watched_percent_override.ts index 92e16f87..e41e2b19 100644 --- a/apps/server/src/database/migrations/1727516980165-Add_Tautulli_watched_percent_override.ts +++ b/apps/server/src/database/migrations/1727516980165-Add_Tautulli_watched_percent_override.ts @@ -1,17 +1,17 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class AddTautulliWatchedPercentOverride1727516980165 implements MigrationInterface { - name = 'AddTautulliWatchedPercentOverride1727516980165'; + name = 'AddTautulliWatchedPercentOverride1727516980165' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( 'ALTER TABLE collection ADD COLUMN "tautulliWatchedPercentOverride" integer', - ); + ) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE collection DROP "tautulliWatchedPercentOverride"`, - ); + ) } } diff --git a/apps/server/src/database/migrations/1727693832830-Add_Notification_settings.ts b/apps/server/src/database/migrations/1727693832830-Add_Notification_settings.ts index 7c7cd9e3..9813151e 100644 --- a/apps/server/src/database/migrations/1727693832830-Add_Notification_settings.ts +++ b/apps/server/src/database/migrations/1727693832830-Add_Notification_settings.ts @@ -1,7 +1,7 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class AddNotificationSettings1727693832830 implements MigrationInterface { - name = 'AddNotificationSettings1727693832830'; + name = 'AddNotificationSettings1727693832830' public async up(queryRunner: QueryRunner): Promise { // Create notification table @@ -14,7 +14,7 @@ export class AddNotificationSettings1727693832830 implements MigrationInterface "types" TEXT, "options" TEXT NOT NULL ); - `); + `) // Create notification_rulegroup table with foreign key constraints directly await queryRunner.query(` @@ -25,12 +25,12 @@ export class AddNotificationSettings1727693832830 implements MigrationInterface FOREIGN KEY ("notificationId") REFERENCES "notification"("id") ON DELETE CASCADE, FOREIGN KEY ("rulegroupId") REFERENCES "rule_group"("id") ON DELETE CASCADE ); - `); + `) } public async down(queryRunner: QueryRunner): Promise { // Drop the tables in reverse order - await queryRunner.query(`DROP TABLE "notification_rulegroup"`); - await queryRunner.query(`DROP TABLE "notification"`); + await queryRunner.query(`DROP TABLE "notification_rulegroup"`) + await queryRunner.query(`DROP TABLE "notification"`) } } diff --git a/apps/server/src/database/migrations/1728503476668-Update_tautulli_URL.ts b/apps/server/src/database/migrations/1728503476668-Update_tautulli_URL.ts index 17f29bc1..a278b427 100644 --- a/apps/server/src/database/migrations/1728503476668-Update_tautulli_URL.ts +++ b/apps/server/src/database/migrations/1728503476668-Update_tautulli_URL.ts @@ -1,12 +1,12 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class UpdateTautulliURL1728503476668 implements MigrationInterface { - name = 'UpdateTautulliURL1728503476668'; + name = 'UpdateTautulliURL1728503476668' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `UPDATE settings SET tautulli_url = rtrim(tautulli_url, '/')`, - ); + ) } public async down(): Promise {} diff --git a/apps/server/src/database/migrations/1729032933189-Add_arr_settings.ts b/apps/server/src/database/migrations/1729032933189-Add_arr_settings.ts index 5f8ed386..d9e37c75 100644 --- a/apps/server/src/database/migrations/1729032933189-Add_arr_settings.ts +++ b/apps/server/src/database/migrations/1729032933189-Add_arr_settings.ts @@ -1,7 +1,7 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class AddArrSettings1729032933189 implements MigrationInterface { - name = 'AddArrSettings1729032933189'; + name = 'AddArrSettings1729032933189' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` @@ -12,12 +12,12 @@ export class AddArrSettings1729032933189 implements MigrationInterface { "apiKey" varchar, "isDefault" boolean NOT NULL DEFAULT (0) ) - `); + `) await queryRunner.query(` INSERT INTO "sonarr_settings" ("serverName", "url", "apiKey", "isDefault") SELECT 'Sonarr', "sonarr_url", "sonarr_api_key", 1 FROM "settings" WHERE "sonarr_url" IS NOT NULL AND "sonarr_api_key" IS NOT NULL - `); + `) await queryRunner.query(` CREATE TABLE "radarr_settings" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -26,20 +26,20 @@ export class AddArrSettings1729032933189 implements MigrationInterface { "apiKey" varchar, "isDefault" boolean NOT NULL DEFAULT (0) ) - `); + `) await queryRunner.query(` INSERT INTO "radarr_settings" ("serverName", "url", "apiKey", "isDefault") SELECT 'Radarr', "radarr_url", "radarr_api_key", 1 FROM "settings" WHERE "radarr_url" IS NOT NULL AND "radarr_api_key" IS NOT NULL - `); - await queryRunner.query('ALTER TABLE "settings" DROP COLUMN "radarr_url"'); + `) + await queryRunner.query('ALTER TABLE "settings" DROP COLUMN "radarr_url"') await queryRunner.query( 'ALTER TABLE "settings" DROP COLUMN "radarr_api_key"', - ); - await queryRunner.query('ALTER TABLE "settings" DROP COLUMN "sonarr_url"'); + ) + await queryRunner.query('ALTER TABLE "settings" DROP COLUMN "sonarr_url"') await queryRunner.query( 'ALTER TABLE "settings" DROP COLUMN "sonarr_api_key"', - ); + ) await queryRunner.query(` CREATE TABLE "temporary_collection" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -66,7 +66,7 @@ export class AddArrSettings1729032933189 implements MigrationInterface { CONSTRAINT "FK_7b354cc91e78c8e730465f14f69" FOREIGN KEY ("radarrSettingsId") REFERENCES "radarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_b638046ca16fca4108a7981fd8c" FOREIGN KEY ("sonarrSettingsId") REFERENCES "sonarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_collection"( "id", @@ -113,53 +113,53 @@ export class AddArrSettings1729032933189 implements MigrationInterface { NULL, NULL FROM "collection" - `); + `) await queryRunner.query(` DROP TABLE "collection" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_collection" RENAME TO "collection" - `); + `) await queryRunner.query(` UPDATE "collection" SET "sonarrSettingsId" = (SELECT "id" FROM "sonarr_settings" LIMIT 1) WHERE "type" IN (2, 3, 4) - `); + `) await queryRunner.query(` UPDATE "collection" SET "radarrSettingsId" = (SELECT "id" FROM "radarr_settings" LIMIT 1) WHERE "type" = 1 - `); + `) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( 'ALTER TABLE settings ADD COLUMN "radarr_url" varchar', - ); + ) await queryRunner.query( 'ALTER TABLE settings ADD COLUMN "radarr_api_key" varchar', - ); + ) await queryRunner.query( 'ALTER TABLE settings ADD COLUMN "sonarr_url" varchar', - ); + ) await queryRunner.query( 'ALTER TABLE settings ADD COLUMN "sonarr_api_key" varchar', - ); + ) await queryRunner.query( `UPDATE settings SET radarr_url = (SELECT url FROM radarr_settings WHERE isDefault = 1 LIMIT 1)`, - ); + ) await queryRunner.query( `UPDATE settings SET radarr_api_key = (SELECT apiKey FROM radarr_settings WHERE isDefault = 1 LIMIT 1)`, - ); + ) await queryRunner.query( `UPDATE settings SET sonarr_url = (SELECT url FROM sonarr_settings WHERE isDefault = 1 LIMIT 1)`, - ); + ) await queryRunner.query( `UPDATE settings SET sonarr_api_key = (SELECT apiKey FROM sonarr_settings WHERE isDefault = 1 LIMIT 1)`, - ); + ) await queryRunner.query(` DROP TABLE "radarr_settings" - `); + `) await queryRunner.query(` DROP TABLE "sonarr_settings" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_collection" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -182,7 +182,7 @@ export class AddArrSettings1729032933189 implements MigrationInterface { "keepLogsForMonths" integer NOT NULL DEFAULT (6), "tautulliWatchedPercentOverride" integer ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_collection"( "id", @@ -225,13 +225,13 @@ export class AddArrSettings1729032933189 implements MigrationInterface { "keepLogsForMonths", "tautulliWatchedPercentOverride" FROM "collection" - `); + `) await queryRunner.query(` DROP TABLE "collection" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_collection" RENAME TO "collection" - `); + `) } } diff --git a/apps/server/src/database/migrations/1732008945000-Notification_settings_aboutScale.ts b/apps/server/src/database/migrations/1732008945000-Notification_settings_aboutScale.ts index f87b221f..7f873d2d 100644 --- a/apps/server/src/database/migrations/1732008945000-Notification_settings_aboutScale.ts +++ b/apps/server/src/database/migrations/1732008945000-Notification_settings_aboutScale.ts @@ -1,17 +1,17 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class NotificationSettingsAboutScale1732008945000 implements MigrationInterface { - name = 'NotificationSettingsAboutScale1732008945000'; + name = 'NotificationSettingsAboutScale1732008945000' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE "notification" ADD COLUMN "aboutScale" INTEGER NOT NULL DEFAULT 3; - `); + `) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE "notification" DROP COLUMN "aboutScale"; - `); + `) } } diff --git a/apps/server/src/database/migrations/1733357722729-Remove-default-arr-servers.ts b/apps/server/src/database/migrations/1733357722729-Remove-default-arr-servers.ts index c29c8737..060ca29d 100644 --- a/apps/server/src/database/migrations/1733357722729-Remove-default-arr-servers.ts +++ b/apps/server/src/database/migrations/1733357722729-Remove-default-arr-servers.ts @@ -1,7 +1,7 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class RemoveDefaultArrServers1733357722729 implements MigrationInterface { - name = 'RemoveDefaultArrServers1733357722729'; + name = 'RemoveDefaultArrServers1733357722729' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` @@ -11,7 +11,7 @@ export class RemoveDefaultArrServers1733357722729 implements MigrationInterface "url" varchar, "apiKey" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_sonarr_settings"("id", "serverName", "url", "apiKey") SELECT "id", @@ -19,14 +19,14 @@ export class RemoveDefaultArrServers1733357722729 implements MigrationInterface "url", "apiKey" FROM "sonarr_settings" - `); + `) await queryRunner.query(` DROP TABLE "sonarr_settings" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_sonarr_settings" RENAME TO "sonarr_settings" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_radarr_settings" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -34,7 +34,7 @@ export class RemoveDefaultArrServers1733357722729 implements MigrationInterface "url" varchar, "apiKey" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_radarr_settings"("id", "serverName", "url", "apiKey") SELECT "id", @@ -42,14 +42,14 @@ export class RemoveDefaultArrServers1733357722729 implements MigrationInterface "url", "apiKey" FROM "radarr_settings" - `); + `) await queryRunner.query(` DROP TABLE "radarr_settings" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_radarr_settings" RENAME TO "radarr_settings" - `); + `) } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/apps/server/src/database/migrations/1733583643678-Collection_add_visibleOnRecommended_field.ts b/apps/server/src/database/migrations/1733583643678-Collection_add_visibleOnRecommended_field.ts index 5cbd2134..34c1695d 100644 --- a/apps/server/src/database/migrations/1733583643678-Collection_add_visibleOnRecommended_field.ts +++ b/apps/server/src/database/migrations/1733583643678-Collection_add_visibleOnRecommended_field.ts @@ -1,17 +1,17 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class CollectionAddVisibleOnRecommendedField1733583643678 implements MigrationInterface { - name = 'CollectionAddVisibleOnRecommendedField1733583643678'; + name = 'CollectionAddVisibleOnRecommendedField1733583643678' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE collection ADD COLUMN "visibleOnRecommended" boolean NOT NULL DEFAULT (0)`, - ); + ) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE collection DROP "visibleOnRecommended"`, - ); + ) } } diff --git a/apps/server/src/database/migrations/1740094282798-Add_log_settings.ts b/apps/server/src/database/migrations/1740094282798-Add_log_settings.ts index 01f666c8..aaa99daa 100644 --- a/apps/server/src/database/migrations/1740094282798-Add_log_settings.ts +++ b/apps/server/src/database/migrations/1740094282798-Add_log_settings.ts @@ -1,10 +1,10 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class AddLogSettings1740094282798 implements MigrationInterface { - name = 'AddLogSettings1740094282798'; + name = 'AddLogSettings1740094282798' public async up(queryRunner: QueryRunner): Promise { - const logLevel = process.env.DEBUG == 'true' ? 'debug' : 'info'; + const logLevel = process.env.DEBUG == 'true' ? 'debug' : 'info' await queryRunner.query(` CREATE TABLE "log_settings" ( @@ -13,19 +13,19 @@ export class AddLogSettings1740094282798 implements MigrationInterface { "max_size" integer NOT NULL DEFAULT (20), "max_files" integer NOT NULL DEFAULT (7) ) - `); + `) await queryRunner.query(` INSERT INTO "log_settings"("level", "max_size", "max_files") SELECT '${logLevel}', 20, 7 - `); + `) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` DROP TABLE "log_settings" - `); + `) } } diff --git a/apps/server/src/database/migrations/1740868281450-Add_jellyseerr_settings.ts b/apps/server/src/database/migrations/1740868281450-Add_jellyseerr_settings.ts index f7c4dd59..c5aa6007 100644 --- a/apps/server/src/database/migrations/1740868281450-Add_jellyseerr_settings.ts +++ b/apps/server/src/database/migrations/1740868281450-Add_jellyseerr_settings.ts @@ -1,19 +1,19 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class AddJellyseerrSettings1740868281450 implements MigrationInterface { - name = 'AddJellyseerrSettings1740868281450'; + name = 'AddJellyseerrSettings1740868281450' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( 'ALTER TABLE settings ADD COLUMN "jellyseerr_url" varchar', - ); + ) await queryRunner.query( 'ALTER TABLE settings ADD COLUMN "jellyseerr_api_key" varchar', - ); + ) } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE settings DROP "jellyseerr_url"`); - await queryRunner.query(`ALTER TABLE settings DROP "jellyseerr_api_key"`); + await queryRunner.query(`ALTER TABLE settings DROP "jellyseerr_url"`) + await queryRunner.query(`ALTER TABLE settings DROP "jellyseerr_api_key"`) } } diff --git a/apps/server/src/database/migrations/1741477145504-Add_collection_log_meta.ts b/apps/server/src/database/migrations/1741477145504-Add_collection_log_meta.ts index 70438c2f..10ef5f9c 100644 --- a/apps/server/src/database/migrations/1741477145504-Add_collection_log_meta.ts +++ b/apps/server/src/database/migrations/1741477145504-Add_collection_log_meta.ts @@ -1,15 +1,15 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class AddCollectionLogMeta1741477145504 implements MigrationInterface { - name = 'AddCollectionLogMeta1741477145504'; + name = 'AddCollectionLogMeta1741477145504' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( 'ALTER TABLE "collection_log" ADD COLUMN "meta" text', - ); + ) } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "collection_log" DROP "meta"`); + await queryRunner.query(`ALTER TABLE "collection_log" DROP "meta"`) } } diff --git a/apps/server/src/database/migrations/1743978127555-Remove-tautulli-trailing-slash.ts b/apps/server/src/database/migrations/1743978127555-Remove-tautulli-trailing-slash.ts index 1b3a7fd8..aba20ce0 100644 --- a/apps/server/src/database/migrations/1743978127555-Remove-tautulli-trailing-slash.ts +++ b/apps/server/src/database/migrations/1743978127555-Remove-tautulli-trailing-slash.ts @@ -1,12 +1,12 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class RemoveTautulliTrailingSlash1743978127555 implements MigrationInterface { - name = 'RemoveTautulliTrailingSlash1743978127555'; + name = 'RemoveTautulliTrailingSlash1743978127555' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `UPDATE settings SET tautulli_url = rtrim(tautulli_url, '/')`, - ); + ) } public async down(): Promise {} diff --git a/apps/server/src/database/migrations/1748211262582-Update_notification_column_types.ts b/apps/server/src/database/migrations/1748211262582-Update_notification_column_types.ts index 199e2b84..f1c8ec92 100644 --- a/apps/server/src/database/migrations/1748211262582-Update_notification_column_types.ts +++ b/apps/server/src/database/migrations/1748211262582-Update_notification_column_types.ts @@ -1,7 +1,7 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class UpdateNotificationColumnTypes1748211262582 implements MigrationInterface { - name = 'UpdateNotificationColumnTypes1748211262582'; + name = 'UpdateNotificationColumnTypes1748211262582' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` @@ -14,16 +14,16 @@ export class UpdateNotificationColumnTypes1748211262582 implements MigrationInte "options" json NOT NULL, "aboutScale" integer NOT NULL DEFAULT (3) ) - `); + `) // Get all records from the notification table const notifications = await queryRunner.query( 'SELECT * FROM "notification"', - ); + ) // Insert records into temporary table with parsed JSON for options for (const notification of notifications) { - const parsedOptions = JSON.parse(JSON.parse(notification.options)); + const parsedOptions = JSON.parse(JSON.parse(notification.options)) await queryRunner.query( `INSERT INTO "temporary_notification"( "id", "name", "agent", "enabled", "types", "options", "aboutScale" @@ -37,21 +37,21 @@ export class UpdateNotificationColumnTypes1748211262582 implements MigrationInte JSON.stringify(parsedOptions), notification.aboutScale, ], - ); + ) } - await queryRunner.query(`DROP TABLE "notification"`); + await queryRunner.query(`DROP TABLE "notification"`) await queryRunner.query(` ALTER TABLE "temporary_notification" RENAME TO "notification" - `); + `) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE "notification" RENAME TO "temporary_notification" - `); + `) await queryRunner.query(` CREATE TABLE "notification" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -62,12 +62,12 @@ export class UpdateNotificationColumnTypes1748211262582 implements MigrationInte "options" text NOT NULL, "aboutScale" integer NOT NULL DEFAULT (3) ) - `); + `) // Get all records from the temporary table const notifications = await queryRunner.query( 'SELECT * FROM "temporary_notification"', - ); + ) // Insert records back with stringified options for (const notification of notifications) { @@ -84,9 +84,9 @@ export class UpdateNotificationColumnTypes1748211262582 implements MigrationInte JSON.stringify(notification.options), notification.aboutScale, ], - ); + ) } - await queryRunner.query(`DROP TABLE "temporary_notification"`); + await queryRunner.query(`DROP TABLE "temporary_notification"`) } } diff --git a/apps/server/src/database/migrations/1763331885670-RemoveCacheImagesConfig.ts b/apps/server/src/database/migrations/1763331885670-RemoveCacheImagesConfig.ts index a0a0438d..3bb5d42f 100644 --- a/apps/server/src/database/migrations/1763331885670-RemoveCacheImagesConfig.ts +++ b/apps/server/src/database/migrations/1763331885670-RemoveCacheImagesConfig.ts @@ -1,7 +1,7 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class RemoveCacheImagesConfig1763331885670 implements MigrationInterface { - name = 'RemoveCacheImagesConfig1763331885670'; + name = 'RemoveCacheImagesConfig1763331885670' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` @@ -26,7 +26,7 @@ export class RemoveCacheImagesConfig1763331885670 implements MigrationInterface "jellyseerr_url" varchar, "jellyseerr_api_key" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_settings"( "id", @@ -69,14 +69,14 @@ export class RemoveCacheImagesConfig1763331885670 implements MigrationInterface "jellyseerr_url", "jellyseerr_api_key" FROM "settings" - `); + `) await queryRunner.query(` DROP TABLE "settings" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_settings" RENAME TO "settings" - `); + `) } public async down(queryRunner: QueryRunner): Promise { @@ -103,7 +103,7 @@ export class RemoveCacheImagesConfig1763331885670 implements MigrationInterface "jellyseerr_url" varchar, "jellyseerr_api_key" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_settings"( "id", @@ -148,13 +148,13 @@ export class RemoveCacheImagesConfig1763331885670 implements MigrationInterface "jellyseerr_url", "jellyseerr_api_key" FROM "settings" - `); + `) await queryRunner.query(` DROP TABLE "settings" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_settings" RENAME TO "settings" - `); + `) } } diff --git a/apps/server/src/database/migrations/1764603599097-Collection_add_sortTitle_field.ts b/apps/server/src/database/migrations/1764603599097-Collection_add_sortTitle_field.ts index 63ca4d4c..5b12e98e 100644 --- a/apps/server/src/database/migrations/1764603599097-Collection_add_sortTitle_field.ts +++ b/apps/server/src/database/migrations/1764603599097-Collection_add_sortTitle_field.ts @@ -1,15 +1,15 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class CollectionAddSortTitleField1764603599097 implements MigrationInterface { - name = 'CollectionAddSortTitleField1764603599097'; + name = 'CollectionAddSortTitleField1764603599097' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE collection ADD COLUMN "sortTitle" varchar`, - ); + ) } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE collection DROP "sortTitle"`); + await queryRunner.query(`ALTER TABLE collection DROP "sortTitle"`) } } diff --git a/apps/server/src/database/migrations/1764888122429-AddRuleHandlerCronSchedule.ts b/apps/server/src/database/migrations/1764888122429-AddRuleHandlerCronSchedule.ts index d825f21a..cf6a8da1 100644 --- a/apps/server/src/database/migrations/1764888122429-AddRuleHandlerCronSchedule.ts +++ b/apps/server/src/database/migrations/1764888122429-AddRuleHandlerCronSchedule.ts @@ -1,20 +1,20 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class AddRuleHandlerCronSchedule1764888122429 implements MigrationInterface { - name = 'AddRuleHandlerCronSchedule1764888122429'; + name = 'AddRuleHandlerCronSchedule1764888122429' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE "rule_group" ADD "ruleHandlerCronSchedule" varchar - `); + `) await queryRunner.query(` DELETE FROM "task_running" WHERE "name" = 'Rule Handler' - `); + `) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE "rule_group" DROP COLUMN "ruleHandlerCronSchedule" - `); + `) } } diff --git a/apps/server/src/database/migrations/1767576516009-JellyfinSupport.ts b/apps/server/src/database/migrations/1767576516009-JellyfinSupport.ts index 99d11ec0..b0bc0a4a 100644 --- a/apps/server/src/database/migrations/1767576516009-JellyfinSupport.ts +++ b/apps/server/src/database/migrations/1767576516009-JellyfinSupport.ts @@ -1,7 +1,7 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class JellyfinSupport1767576516009 implements MigrationInterface { - name = 'JellyfinSupport1767576516009'; + name = 'JellyfinSupport1767576516009' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` @@ -10,23 +10,23 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "rulegroupId" integer NOT NULL, PRIMARY KEY ("notificationId", "rulegroupId") ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_notification_rulegroup"("notificationId", "rulegroupId") SELECT "notificationId", "rulegroupId" FROM "notification_rulegroup" - `); + `) await queryRunner.query(` DROP TABLE "notification_rulegroup" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_notification_rulegroup" RENAME TO "notification_rulegroup" - `); + `) await queryRunner.query(` DROP INDEX "idx_collection_media_collection_id" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_collection_media" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -38,7 +38,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "isManual" boolean DEFAULT (0), CONSTRAINT "FK_604b0cd0f85150923289b7f2c19" FOREIGN KEY ("collectionId") REFERENCES "collection" ("id") ON DELETE CASCADE ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_collection_media"( "id", @@ -57,17 +57,17 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "image_path", "isManual" FROM "collection_media" - `); + `) await queryRunner.query(` DROP TABLE "collection_media" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_collection_media" RENAME TO "collection_media" - `); + `) await queryRunner.query(` CREATE INDEX "idx_collection_media_collection_id" ON "collection_media" ("collectionId") - `); + `) await queryRunner.query(` CREATE TABLE "temporary_collection" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -96,7 +96,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { CONSTRAINT "FK_b638046ca16fca4108a7981fd8c" FOREIGN KEY ("sonarrSettingsId") REFERENCES "sonarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_7b354cc91e78c8e730465f14f69" FOREIGN KEY ("radarrSettingsId") REFERENCES "radarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_collection"( "id", @@ -147,14 +147,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "sortTitle", CAST("plexId" AS TEXT) FROM "collection" - `); + `) await queryRunner.query(` DROP TABLE "collection" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_collection" RENAME TO "collection" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_exclusion" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -163,7 +163,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "type" integer DEFAULT (NULL), "mediaServerId" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_exclusion"("id", "ruleGroupId", "parent", "type", "mediaServerId") SELECT "id", @@ -172,14 +172,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "type", CAST("plexId" AS TEXT) FROM "exclusion" - `); + `) await queryRunner.query(` DROP TABLE "exclusion" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_exclusion" RENAME TO "exclusion" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_collection" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -209,7 +209,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { CONSTRAINT "FK_b638046ca16fca4108a7981fd8c" FOREIGN KEY ("sonarrSettingsId") REFERENCES "sonarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_7b354cc91e78c8e730465f14f69" FOREIGN KEY ("radarrSettingsId") REFERENCES "radarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_collection"( "id", @@ -260,14 +260,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "sortTitle", "mediaServerId" FROM "collection" - `); + `) await queryRunner.query(` DROP TABLE "collection" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_collection" RENAME TO "collection" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_settings" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -295,7 +295,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "jellyfin_user_id" varchar, "jellyfin_server_name" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_settings"( "id", @@ -338,14 +338,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "jellyseerr_url", "jellyseerr_api_key" FROM "settings" - `); + `) await queryRunner.query(` DROP TABLE "settings" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_settings" RENAME TO "settings" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_exclusion" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -354,7 +354,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "type" integer DEFAULT (NULL), "mediaServerId" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_exclusion"("id", "ruleGroupId", "parent", "type", "mediaServerId") SELECT "id", @@ -363,14 +363,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "type", "mediaServerId" FROM "exclusion" - `); + `) await queryRunner.query(` DROP TABLE "exclusion" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_exclusion" RENAME TO "exclusion" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_rule_group" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -385,7 +385,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { CONSTRAINT "REL_9c757efe456ec36319ef10e964" UNIQUE ("collectionId"), CONSTRAINT "FK_9c757efe456ec36319ef10e9648" FOREIGN KEY ("collectionId") REFERENCES "collection" ("id") ON DELETE CASCADE ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_rule_group"( "id", @@ -408,17 +408,17 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { CAST("dataType" AS TEXT), "ruleHandlerCronSchedule" FROM "rule_group" - `); + `) await queryRunner.query(` DROP TABLE "rule_group" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_rule_group" RENAME TO "rule_group" - `); + `) await queryRunner.query(` DROP INDEX "idx_collection_media_collection_id" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_collection_media" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -430,7 +430,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "isManual" boolean DEFAULT (0), CONSTRAINT "FK_604b0cd0f85150923289b7f2c19" FOREIGN KEY ("collectionId") REFERENCES "collection" ("id") ON DELETE CASCADE ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_collection_media"( "id", @@ -449,17 +449,17 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "image_path", "isManual" FROM "collection_media" - `); + `) await queryRunner.query(` DROP TABLE "collection_media" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_collection_media" RENAME TO "collection_media" - `); + `) await queryRunner.query(` CREATE INDEX "idx_collection_media_collection_id" ON "collection_media" ("collectionId") - `); + `) await queryRunner.query(` CREATE TABLE "temporary_collection" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -489,7 +489,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { CONSTRAINT "FK_b638046ca16fca4108a7981fd8c" FOREIGN KEY ("sonarrSettingsId") REFERENCES "sonarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_7b354cc91e78c8e730465f14f69" FOREIGN KEY ("radarrSettingsId") REFERENCES "radarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_collection"( "id", @@ -542,14 +542,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "mediaServerId", "mediaServerType" FROM "collection" - `); + `) await queryRunner.query(` DROP TABLE "collection" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_collection" RENAME TO "collection" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_settings" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -577,7 +577,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "jellyfin_user_id" varchar, "jellyfin_server_name" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_settings"( "id", @@ -630,14 +630,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "jellyfin_user_id", "jellyfin_server_name" FROM "settings" - `); + `) await queryRunner.query(` DROP TABLE "settings" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_settings" RENAME TO "settings" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_exclusion" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -646,7 +646,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "type" varchar, "mediaServerId" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_exclusion"( "id", @@ -661,23 +661,23 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { CAST("type" AS TEXT), "mediaServerId" FROM "exclusion" - `); + `) await queryRunner.query(` DROP TABLE "exclusion" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_exclusion" RENAME TO "exclusion" - `); + `) await queryRunner.query(` CREATE INDEX "IDX_2c70d3feb9b789062bfa14c6b9" ON "notification_rulegroup" ("rulegroupId") - `); + `) await queryRunner.query(` CREATE INDEX "IDX_dcc3ba7f814ebd3d47facad716" ON "notification_rulegroup" ("notificationId") - `); + `) await queryRunner.query(` DROP INDEX "idx_collection_log_collection_id" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_collection_log" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -688,7 +688,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "meta" text, CONSTRAINT "FK_c70b4409f8834d108a5e845365a" FOREIGN KEY ("collectionId") REFERENCES "collection" ("id") ON DELETE CASCADE ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_collection_log"( "id", @@ -705,23 +705,23 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "type", "meta" FROM "collection_log" - `); + `) await queryRunner.query(` DROP TABLE "collection_log" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_collection_log" RENAME TO "collection_log" - `); + `) await queryRunner.query(` CREATE INDEX "idx_collection_log_collection_id" ON "collection_log" ("collectionId") - `); + `) await queryRunner.query(` DROP INDEX "IDX_2c70d3feb9b789062bfa14c6b9" - `); + `) await queryRunner.query(` DROP INDEX "IDX_dcc3ba7f814ebd3d47facad716" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_notification_rulegroup" ( "notificationId" integer NOT NULL, @@ -730,68 +730,68 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { CONSTRAINT "FK_dcc3ba7f814ebd3d47facad7168" FOREIGN KEY ("notificationId") REFERENCES "notification" ("id") ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY ("notificationId", "rulegroupId") ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_notification_rulegroup"("notificationId", "rulegroupId") SELECT "notificationId", "rulegroupId" FROM "notification_rulegroup" - `); + `) await queryRunner.query(` DROP TABLE "notification_rulegroup" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_notification_rulegroup" RENAME TO "notification_rulegroup" - `); + `) await queryRunner.query(` CREATE INDEX "IDX_2c70d3feb9b789062bfa14c6b9" ON "notification_rulegroup" ("rulegroupId") - `); + `) await queryRunner.query(` CREATE INDEX "IDX_dcc3ba7f814ebd3d47facad716" ON "notification_rulegroup" ("notificationId") - `); + `) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` DROP INDEX "IDX_dcc3ba7f814ebd3d47facad716" - `); + `) await queryRunner.query(` DROP INDEX "IDX_2c70d3feb9b789062bfa14c6b9" - `); + `) await queryRunner.query(` ALTER TABLE "notification_rulegroup" RENAME TO "temporary_notification_rulegroup" - `); + `) await queryRunner.query(` CREATE TABLE "notification_rulegroup" ( "notificationId" integer NOT NULL, "rulegroupId" integer NOT NULL, PRIMARY KEY ("notificationId", "rulegroupId") ) - `); + `) await queryRunner.query(` INSERT INTO "notification_rulegroup"("notificationId", "rulegroupId") SELECT "notificationId", "rulegroupId" FROM "temporary_notification_rulegroup" - `); + `) await queryRunner.query(` DROP TABLE "temporary_notification_rulegroup" - `); + `) await queryRunner.query(` CREATE INDEX "IDX_dcc3ba7f814ebd3d47facad716" ON "notification_rulegroup" ("notificationId") - `); + `) await queryRunner.query(` CREATE INDEX "IDX_2c70d3feb9b789062bfa14c6b9" ON "notification_rulegroup" ("rulegroupId") - `); + `) await queryRunner.query(` DROP INDEX "idx_collection_log_collection_id" - `); + `) await queryRunner.query(` ALTER TABLE "collection_log" RENAME TO "temporary_collection_log" - `); + `) await queryRunner.query(` CREATE TABLE "collection_log" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -801,7 +801,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "type" integer NOT NULL, "meta" text ) - `); + `) await queryRunner.query(` INSERT INTO "collection_log"( "id", @@ -818,23 +818,23 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "type", "meta" FROM "temporary_collection_log" - `); + `) await queryRunner.query(` DROP TABLE "temporary_collection_log" - `); + `) await queryRunner.query(` CREATE INDEX "idx_collection_log_collection_id" ON "collection_log" ("collectionId") - `); + `) await queryRunner.query(` DROP INDEX "IDX_dcc3ba7f814ebd3d47facad716" - `); + `) await queryRunner.query(` DROP INDEX "IDX_2c70d3feb9b789062bfa14c6b9" - `); + `) await queryRunner.query(` ALTER TABLE "exclusion" RENAME TO "temporary_exclusion" - `); + `) await queryRunner.query(` CREATE TABLE "exclusion" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -843,7 +843,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "type" integer DEFAULT (NULL), "mediaServerId" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "exclusion"( "id", @@ -858,14 +858,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "type", "mediaServerId" FROM "temporary_exclusion" - `); + `) await queryRunner.query(` DROP TABLE "temporary_exclusion" - `); + `) await queryRunner.query(` ALTER TABLE "settings" RENAME TO "temporary_settings" - `); + `) await queryRunner.query(` CREATE TABLE "settings" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -893,7 +893,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "jellyfin_user_id" varchar, "jellyfin_server_name" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "settings"( "id", @@ -946,14 +946,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "jellyfin_user_id", "jellyfin_server_name" FROM "temporary_settings" - `); + `) await queryRunner.query(` DROP TABLE "temporary_settings" - `); + `) await queryRunner.query(` ALTER TABLE "collection" RENAME TO "temporary_collection" - `); + `) await queryRunner.query(` CREATE TABLE "collection" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -983,7 +983,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { CONSTRAINT "FK_b638046ca16fca4108a7981fd8c" FOREIGN KEY ("sonarrSettingsId") REFERENCES "sonarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_7b354cc91e78c8e730465f14f69" FOREIGN KEY ("radarrSettingsId") REFERENCES "radarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "collection"( "id", @@ -1036,17 +1036,17 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "mediaServerId", "mediaServerType" FROM "temporary_collection" - `); + `) await queryRunner.query(` DROP TABLE "temporary_collection" - `); + `) await queryRunner.query(` DROP INDEX "idx_collection_media_collection_id" - `); + `) await queryRunner.query(` ALTER TABLE "collection_media" RENAME TO "temporary_collection_media" - `); + `) await queryRunner.query(` CREATE TABLE "collection_media" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -1058,7 +1058,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "isManual" boolean DEFAULT (0), CONSTRAINT "FK_604b0cd0f85150923289b7f2c19" FOREIGN KEY ("collectionId") REFERENCES "collection" ("id") ON DELETE CASCADE ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "collection_media"( "id", @@ -1077,17 +1077,17 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "image_path", "isManual" FROM "temporary_collection_media" - `); + `) await queryRunner.query(` DROP TABLE "temporary_collection_media" - `); + `) await queryRunner.query(` CREATE INDEX "idx_collection_media_collection_id" ON "collection_media" ("collectionId") - `); + `) await queryRunner.query(` ALTER TABLE "rule_group" RENAME TO "temporary_rule_group" - `); + `) await queryRunner.query(` CREATE TABLE "rule_group" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -1102,7 +1102,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { CONSTRAINT "REL_9c757efe456ec36319ef10e964" UNIQUE ("collectionId"), CONSTRAINT "FK_9c757efe456ec36319ef10e9648" FOREIGN KEY ("collectionId") REFERENCES "collection" ("id") ON DELETE CASCADE ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "rule_group"( "id", @@ -1125,14 +1125,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "dataType", "ruleHandlerCronSchedule" FROM "temporary_rule_group" - `); + `) await queryRunner.query(` DROP TABLE "temporary_rule_group" - `); + `) await queryRunner.query(` ALTER TABLE "exclusion" RENAME TO "temporary_exclusion" - `); + `) await queryRunner.query(` CREATE TABLE "exclusion" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -1140,7 +1140,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "parent" integer, "type" integer DEFAULT (NULL) ) - `); + `) await queryRunner.query(` INSERT INTO "exclusion"("id", "ruleGroupId", "parent", "type") SELECT "id", @@ -1148,14 +1148,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "parent", "type" FROM "temporary_exclusion" - `); + `) await queryRunner.query(` DROP TABLE "temporary_exclusion" - `); + `) await queryRunner.query(` ALTER TABLE "settings" RENAME TO "temporary_settings" - `); + `) await queryRunner.query(` CREATE TABLE "settings" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -1178,7 +1178,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "jellyseerr_url" varchar, "jellyseerr_api_key" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "settings"( "id", @@ -1221,14 +1221,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "jellyseerr_url", "jellyseerr_api_key" FROM "temporary_settings" - `); + `) await queryRunner.query(` DROP TABLE "temporary_settings" - `); + `) await queryRunner.query(` ALTER TABLE "collection" RENAME TO "temporary_collection" - `); + `) await queryRunner.query(` CREATE TABLE "collection" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -1256,7 +1256,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { CONSTRAINT "FK_b638046ca16fca4108a7981fd8c" FOREIGN KEY ("sonarrSettingsId") REFERENCES "sonarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_7b354cc91e78c8e730465f14f69" FOREIGN KEY ("radarrSettingsId") REFERENCES "radarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "collection"( "id", @@ -1305,14 +1305,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "visibleOnRecommended", "sortTitle" FROM "temporary_collection" - `); + `) await queryRunner.query(` DROP TABLE "temporary_collection" - `); + `) await queryRunner.query(` ALTER TABLE "exclusion" RENAME TO "temporary_exclusion" - `); + `) await queryRunner.query(` CREATE TABLE "exclusion" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -1321,7 +1321,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "parent" integer, "type" integer DEFAULT (NULL) ) - `); + `) await queryRunner.query(` INSERT INTO "exclusion"("id", "ruleGroupId", "parent", "type") SELECT "id", @@ -1329,14 +1329,14 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "parent", "type" FROM "temporary_exclusion" - `); + `) await queryRunner.query(` DROP TABLE "temporary_exclusion" - `); + `) await queryRunner.query(` ALTER TABLE "collection" RENAME TO "temporary_collection" - `); + `) await queryRunner.query(` CREATE TABLE "collection" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -1365,7 +1365,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { CONSTRAINT "FK_b638046ca16fca4108a7981fd8c" FOREIGN KEY ("sonarrSettingsId") REFERENCES "sonarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_7b354cc91e78c8e730465f14f69" FOREIGN KEY ("radarrSettingsId") REFERENCES "radarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "collection"( "id", @@ -1414,17 +1414,17 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "visibleOnRecommended", "sortTitle" FROM "temporary_collection" - `); + `) await queryRunner.query(` DROP TABLE "temporary_collection" - `); + `) await queryRunner.query(` DROP INDEX "idx_collection_media_collection_id" - `); + `) await queryRunner.query(` ALTER TABLE "collection_media" RENAME TO "temporary_collection_media" - `); + `) await queryRunner.query(` CREATE TABLE "collection_media" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -1436,7 +1436,7 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "isManual" boolean DEFAULT (0), CONSTRAINT "FK_604b0cd0f85150923289b7f2c19" FOREIGN KEY ("collectionId") REFERENCES "collection" ("id") ON DELETE CASCADE ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "collection_media"( "id", @@ -1455,17 +1455,17 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { "image_path", "isManual" FROM "temporary_collection_media" - `); + `) await queryRunner.query(` DROP TABLE "temporary_collection_media" - `); + `) await queryRunner.query(` CREATE INDEX "idx_collection_media_collection_id" ON "collection_media" ("collectionId") - `); + `) await queryRunner.query(` ALTER TABLE "notification_rulegroup" RENAME TO "temporary_notification_rulegroup" - `); + `) await queryRunner.query(` CREATE TABLE "notification_rulegroup" ( "notificationId" integer NOT NULL, @@ -1474,15 +1474,15 @@ export class JellyfinSupport1767576516009 implements MigrationInterface { CONSTRAINT "FK_dcc3ba7f814ebd3d47facad7168" FOREIGN KEY ("notificationId") REFERENCES "notification" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("notificationId", "rulegroupId") ) - `); + `) await queryRunner.query(` INSERT INTO "notification_rulegroup"("notificationId", "rulegroupId") SELECT "notificationId", "rulegroupId" FROM "temporary_notification_rulegroup" - `); + `) await queryRunner.query(` DROP TABLE "temporary_notification_rulegroup" - `); + `) } } diff --git a/apps/server/src/database/migrations/1768058840535-RemoveTaskRunning.ts b/apps/server/src/database/migrations/1768058840535-RemoveTaskRunning.ts index 0d607c7a..a852b1e6 100644 --- a/apps/server/src/database/migrations/1768058840535-RemoveTaskRunning.ts +++ b/apps/server/src/database/migrations/1768058840535-RemoveTaskRunning.ts @@ -1,8 +1,8 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class RemoveTaskRunning1768058840535 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "task_running"`); + await queryRunner.query(`DROP TABLE "task_running"`) } public async down(): Promise {} diff --git a/apps/server/src/database/migrations/1768073131263-RemoveEmptyRules.ts b/apps/server/src/database/migrations/1768073131263-RemoveEmptyRules.ts index 132df145..3ef70cec 100644 --- a/apps/server/src/database/migrations/1768073131263-RemoveEmptyRules.ts +++ b/apps/server/src/database/migrations/1768073131263-RemoveEmptyRules.ts @@ -1,11 +1,11 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class RemoveEmptyRules1768073131263 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` DELETE FROM "rules" WHERE TRIM("ruleJson") = (char(34) || char(34)) - `); + `) } public async down(): Promise {} diff --git a/apps/server/src/database/migrations/1771699988985-AddCollectionTotalSizeBytes.ts b/apps/server/src/database/migrations/1771699988985-AddCollectionTotalSizeBytes.ts index 173e59c5..df32966e 100644 --- a/apps/server/src/database/migrations/1771699988985-AddCollectionTotalSizeBytes.ts +++ b/apps/server/src/database/migrations/1771699988985-AddCollectionTotalSizeBytes.ts @@ -1,7 +1,7 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class AddCollectionTotalSizeBytes1771699988985 implements MigrationInterface { - name = 'AddCollectionTotalSizeBytes1771699988985'; + name = 'AddCollectionTotalSizeBytes1771699988985' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` @@ -34,7 +34,7 @@ export class AddCollectionTotalSizeBytes1771699988985 implements MigrationInterf CONSTRAINT "FK_7b354cc91e78c8e730465f14f69" FOREIGN KEY ("radarrSettingsId") REFERENCES "radarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_b638046ca16fca4108a7981fd8c" FOREIGN KEY ("sonarrSettingsId") REFERENCES "sonarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_collection"( "id", @@ -87,21 +87,21 @@ export class AddCollectionTotalSizeBytes1771699988985 implements MigrationInterf "mediaServerId", "mediaServerType" FROM "collection" - `); + `) await queryRunner.query(` DROP TABLE "collection" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_collection" RENAME TO "collection" - `); + `) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE "collection" RENAME TO "temporary_collection" - `); + `) await queryRunner.query(` CREATE TABLE "collection" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -131,7 +131,7 @@ export class AddCollectionTotalSizeBytes1771699988985 implements MigrationInterf CONSTRAINT "FK_7b354cc91e78c8e730465f14f69" FOREIGN KEY ("radarrSettingsId") REFERENCES "radarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_b638046ca16fca4108a7981fd8c" FOREIGN KEY ("sonarrSettingsId") REFERENCES "sonarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "collection"( "id", @@ -184,9 +184,9 @@ export class AddCollectionTotalSizeBytes1771699988985 implements MigrationInterf "mediaServerId", "mediaServerType" FROM "temporary_collection" - `); + `) await queryRunner.query(` DROP TABLE "temporary_collection" - `); + `) } } diff --git a/apps/server/src/database/migrations/1771712257373-SeerrMigration.ts b/apps/server/src/database/migrations/1771712257373-SeerrMigration.ts index 7dbd9426..530a4647 100644 --- a/apps/server/src/database/migrations/1771712257373-SeerrMigration.ts +++ b/apps/server/src/database/migrations/1771712257373-SeerrMigration.ts @@ -1,7 +1,7 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm' export class SeerrMigration1771712257373 implements MigrationInterface { - name = 'SeerrMigration1771712257373'; + name = 'SeerrMigration1771712257373' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` @@ -34,7 +34,7 @@ export class SeerrMigration1771712257373 implements MigrationInterface { CONSTRAINT "FK_b638046ca16fca4108a7981fd8c" FOREIGN KEY ("sonarrSettingsId") REFERENCES "sonarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_7b354cc91e78c8e730465f14f69" FOREIGN KEY ("radarrSettingsId") REFERENCES "radarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_collection"( "id", @@ -89,14 +89,14 @@ export class SeerrMigration1771712257373 implements MigrationInterface { "mediaServerType", "totalSizeBytes" FROM "collection" - `); + `) await queryRunner.query(` DROP TABLE "collection" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_collection" RENAME TO "collection" - `); + `) await queryRunner.query(` CREATE TABLE "temporary_settings" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -122,7 +122,7 @@ export class SeerrMigration1771712257373 implements MigrationInterface { "seerr_url" varchar, "seerr_api_key" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "temporary_settings"( "id", @@ -171,32 +171,32 @@ export class SeerrMigration1771712257373 implements MigrationInterface { COALESCE("overseerr_url", "jellyseerr_url"), COALESCE("overseerr_api_key", "jellyseerr_api_key") FROM "settings" - `); + `) await queryRunner.query(` DROP TABLE "settings" - `); + `) await queryRunner.query(` ALTER TABLE "temporary_settings" RENAME TO "settings" - `); + `) // Migrate JELLYSEERR (application=5) to SEERR (application=3) in rule JSON await queryRunner.query(` UPDATE "rules" SET "ruleJson" = REPLACE("ruleJson", '"firstVal":[5,', '"firstVal":[3,') WHERE "ruleJson" LIKE '%"firstVal":[5,%' - `); + `) await queryRunner.query(` UPDATE "rules" SET "ruleJson" = REPLACE("ruleJson", '"lastVal":[5,', '"lastVal":[3,') WHERE "ruleJson" LIKE '%"lastVal":[5,%' - `); + `) } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE "settings" RENAME TO "temporary_settings" - `); + `) await queryRunner.query(` CREATE TABLE "settings" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -224,7 +224,7 @@ export class SeerrMigration1771712257373 implements MigrationInterface { "jellyfin_user_id" varchar, "jellyfin_server_name" varchar ) - `); + `) await queryRunner.query(` INSERT INTO "settings"( "id", @@ -273,14 +273,14 @@ export class SeerrMigration1771712257373 implements MigrationInterface { "jellyfin_user_id", "jellyfin_server_name" FROM "temporary_settings" - `); + `) await queryRunner.query(` DROP TABLE "temporary_settings" - `); + `) await queryRunner.query(` ALTER TABLE "collection" RENAME TO "temporary_collection" - `); + `) await queryRunner.query(` CREATE TABLE "collection" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -311,7 +311,7 @@ export class SeerrMigration1771712257373 implements MigrationInterface { CONSTRAINT "FK_b638046ca16fca4108a7981fd8c" FOREIGN KEY ("sonarrSettingsId") REFERENCES "sonarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_7b354cc91e78c8e730465f14f69" FOREIGN KEY ("radarrSettingsId") REFERENCES "radarr_settings" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION ) - `); + `) await queryRunner.query(` INSERT INTO "collection"( "id", @@ -366,9 +366,9 @@ export class SeerrMigration1771712257373 implements MigrationInterface { "mediaServerType", "totalSizeBytes" FROM "temporary_collection" - `); + `) await queryRunner.query(` DROP TABLE "temporary_collection" - `); + `) } } diff --git a/apps/server/src/datasource-config.ts b/apps/server/src/datasource-config.ts index 1eab63a2..e3f26d3f 100644 --- a/apps/server/src/datasource-config.ts +++ b/apps/server/src/datasource-config.ts @@ -1,4 +1,4 @@ -import { DataSource } from 'typeorm'; +import { DataSource } from 'typeorm' const datasource = new DataSource({ type: 'better-sqlite3', @@ -7,15 +7,15 @@ const datasource = new DataSource({ synchronize: false, migrationsTableName: 'migrations', migrations: ['./src/database/migrations/**/*.ts'], -}); +}) datasource .initialize() .then(() => { - console.log(`Data Source has been initialized`); + console.log(`Data Source has been initialized`) }) .catch((err) => { - console.error(`Data Source initialization error`, err); - }); + console.error(`Data Source initialization error`, err) + }) -export default datasource; +export default datasource diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 07e3dfad..f5d2b051 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,101 +1,101 @@ -import { Logger } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; -import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import { setupGracefulShutdown } from '@tygra/nestjs-graceful-shutdown'; -import * as fs from 'fs'; -import { cleanupOpenApiDoc } from 'nestjs-zod'; -import path from 'path'; -import { AppModule } from './app/app.module'; -import { MaintainerrLogger } from './modules/logging/logs.service'; +import { Logger } from '@nestjs/common' +import { NestFactory } from '@nestjs/core' +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' +import { setupGracefulShutdown } from '@tygra/nestjs-graceful-shutdown' +import * as fs from 'fs' +import { cleanupOpenApiDoc } from 'nestjs-zod' +import path from 'path' +import { AppModule } from './app/app.module' +import { MaintainerrLogger } from './modules/logging/logs.service' const dataDir = process.env.NODE_ENV === 'production' ? '/opt/data' - : path.join(__dirname, '../../../data'); + : path.join(__dirname, '../../../data') async function bootstrap() { const app = await NestFactory.create(AppModule, { bufferLogs: true, - }); + }) - setupGracefulShutdown({ app }); + setupGracefulShutdown({ app }) - const basePathEnv = process.env.BASE_PATH?.trim(); + const basePathEnv = process.env.BASE_PATH?.trim() if (basePathEnv && basePathEnv !== '/') { const normalizedBasePath = basePathEnv .replace(/\/+$/, '') - .replace(/^\/+/, ''); + .replace(/^\/+/, '') if (normalizedBasePath.length > 0) { - app.setGlobalPrefix(normalizedBasePath); + app.setGlobalPrefix(normalizedBasePath) } } - const config = new DocumentBuilder().setTitle('Maintainerr').build(); - const documentFactory = () => SwaggerModule.createDocument(app, config); - const document = documentFactory(); - cleanupOpenApiDoc(document); - SwaggerModule.setup('api/swagger', app, document); + const config = new DocumentBuilder().setTitle('Maintainerr').build() + const documentFactory = () => SwaggerModule.createDocument(app, config) + const document = documentFactory() + cleanupOpenApiDoc(document) + SwaggerModule.setup('api/swagger', app, document) - app.useLogger(await app.resolve(MaintainerrLogger)); - app.enableCors({ origin: true }); + app.useLogger(await app.resolve(MaintainerrLogger)) + app.enableCors({ origin: true }) - const apiPort = process.env.UI_PORT || 6246; - const apiHostname = process.env.UI_HOSTNAME || '0.0.0.0'; - await app.listen(apiPort, apiHostname); + const apiPort = process.env.UI_PORT || 6246 + const apiHostname = process.env.UI_HOSTNAME || '0.0.0.0' + await app.listen(apiPort, apiHostname) } function createDataDirectoryStructure() { try { // Check if data directory has read and write permissions - fs.accessSync(dataDir, fs.constants.R_OK | fs.constants.W_OK); + fs.accessSync(dataDir, fs.constants.R_OK | fs.constants.W_OK) // create logs dir - const dir = path.join(dataDir, 'logs'); + const dir = path.join(dataDir, 'logs') if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o777, - }); + }) } // if db already exists, check r/w permissions - const db = path.join(dataDir, 'maintainerr.sqlite'); + const db = path.join(dataDir, 'maintainerr.sqlite') if (fs.existsSync(db)) { - fs.accessSync(db, fs.constants.R_OK | fs.constants.W_OK); + fs.accessSync(db, fs.constants.R_OK | fs.constants.W_OK) } } catch (err) { console.warn( `THE CONTAINER NO LONGER OPERATES WITH PRIVILEGED USER PERMISSIONS. PLEASE UPDATE YOUR CONFIGURATION ACCORDINGLY: https://github.com/Maintainerr/Maintainerr/releases/tag/v2.0.0`, - ); + ) console.error( 'Could not create or access (files in) the data directory. Please make sure the necessary permissions are set', - ); - process.exit(1); + ) + process.exit(1) } } -createDataDirectoryStructure(); +createDataDirectoryStructure() bootstrap().catch((error) => { console.error( 'A fatal error occurred starting the server. This is likely a bug, please report this issue on GitHub.', { error }, - ); - process.exit(1); -}); + ) + process.exit(1) +}) process .on('unhandledRejection', (err) => { new Logger('main').error( 'An unhandledRejection has occurred. This is likely a bug, please report this issue on GitHub.', err, - ); + ) // We do not exit the process here as the error is unlikely to be fatal. }) .on('uncaughtException', (err) => { new Logger('main').error( 'The server has crashed because of an uncaughtException. This is likely a bug, please report this issue on GitHub.', err, - ); - process.exit(2); - }); + ) + process.exit(2) + }) diff --git a/apps/server/src/modules/actions/actions.module.ts b/apps/server/src/modules/actions/actions.module.ts index 6be34377..dc8dcb2f 100644 --- a/apps/server/src/modules/actions/actions.module.ts +++ b/apps/server/src/modules/actions/actions.module.ts @@ -1,10 +1,10 @@ -import { Module } from '@nestjs/common'; -import { MediaServerModule } from '../api/media-server/media-server.module'; -import { ServarrApiModule } from '../api/servarr-api/servarr-api.module'; -import { TmdbApiModule } from '../api/tmdb-api/tmdb.module'; -import { MediaIdFinder } from './media-id-finder'; -import { RadarrActionHandler } from './radarr-action-handler'; -import { SonarrActionHandler } from './sonarr-action-handler'; +import { Module } from '@nestjs/common' +import { MediaServerModule } from '../api/media-server/media-server.module' +import { ServarrApiModule } from '../api/servarr-api/servarr-api.module' +import { TmdbApiModule } from '../api/tmdb-api/tmdb.module' +import { MediaIdFinder } from './media-id-finder' +import { RadarrActionHandler } from './radarr-action-handler' +import { SonarrActionHandler } from './sonarr-action-handler' @Module({ imports: [MediaServerModule, TmdbApiModule, ServarrApiModule], diff --git a/apps/server/src/modules/actions/media-id-finder.ts b/apps/server/src/modules/actions/media-id-finder.ts index 5da85bd4..2b846bf4 100644 --- a/apps/server/src/modules/actions/media-id-finder.ts +++ b/apps/server/src/modules/actions/media-id-finder.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; -import { MediaServerFactory } from '../api/media-server/media-server.factory'; -import { TmdbIdService } from '../api/tmdb-api/tmdb-id.service'; -import { TmdbApiService } from '../api/tmdb-api/tmdb.service'; +import { Injectable } from '@nestjs/common' +import { MediaServerFactory } from '../api/media-server/media-server.factory' +import { TmdbIdService } from '../api/tmdb-api/tmdb-id.service' +import { TmdbApiService } from '../api/tmdb-api/tmdb.service' @Injectable() export class MediaIdFinder { @@ -15,38 +15,38 @@ export class MediaIdFinder { mediaServerId: string | number, tmdbId?: number | null, ) { - let tvdbid = undefined; + let tvdbid = undefined if (!tmdbId && mediaServerId) { tmdbId = ( await this.tmdbIdHelper.getTmdbIdFromMediaServerId( mediaServerId.toString(), ) - )?.id; + )?.id } const tmdbShow = tmdbId ? await this.tmdbApi.getTvShow({ tvId: tmdbId }) - : undefined; + : undefined if (!tmdbShow?.external_ids?.tvdb_id) { - const mediaServer = await this.mediaServerFactory.getService(); - let mediaData = await mediaServer.getMetadata(mediaServerId.toString()); + const mediaServer = await this.mediaServerFactory.getService() + let mediaData = await mediaServer.getMetadata(mediaServerId.toString()) // fetch correct record for seasons & episodes (go up to show level) mediaData = mediaData?.grandparentId ? await mediaServer.getMetadata(mediaData.grandparentId) : mediaData?.parentId ? await mediaServer.getMetadata(mediaData.parentId) - : mediaData; + : mediaData // Check providerIds for tvdb - const tvdbFromProviders = mediaData?.providerIds?.tvdb; + const tvdbFromProviders = mediaData?.providerIds?.tvdb if (tvdbFromProviders) { - tvdbid = tvdbFromProviders; + tvdbid = tvdbFromProviders } } else { - tvdbid = tmdbShow.external_ids.tvdb_id; + tvdbid = tmdbShow.external_ids.tvdb_id } - return tvdbid; + return tvdbid } } diff --git a/apps/server/src/modules/actions/radarr-action-handler.spec.ts b/apps/server/src/modules/actions/radarr-action-handler.spec.ts index ccbb8e1e..00f3452b 100644 --- a/apps/server/src/modules/actions/radarr-action-handler.spec.ts +++ b/apps/server/src/modules/actions/radarr-action-handler.spec.ts @@ -1,89 +1,87 @@ -import { Mocked } from '@suites/doubles.jest'; -import { TestBed } from '@suites/unit'; +import { Mocked } from '@suites/doubles.jest' +import { TestBed } from '@suites/unit' import { createCollection, createCollectionMedia, createRadarrMovie, -} from '../../../test/utils/data'; +} from '../../../test/utils/data' import { mockRadarrApi, validateNoRadarrActionsTaken, -} from '../../../test/utils/servarr-mock'; -import { MediaServerFactory } from '../api/media-server/media-server.factory'; -import { IMediaServerService } from '../api/media-server/media-server.interface'; -import { ServarrService } from '../api/servarr-api/servarr.service'; -import { TmdbIdService } from '../api/tmdb-api/tmdb-id.service'; -import { ServarrAction } from '../collections/interfaces/collection.interface'; -import { MaintainerrLogger } from '../logging/logs.service'; -import { RadarrActionHandler } from './radarr-action-handler'; +} from '../../../test/utils/servarr-mock' +import { MediaServerFactory } from '../api/media-server/media-server.factory' +import { IMediaServerService } from '../api/media-server/media-server.interface' +import { ServarrService } from '../api/servarr-api/servarr.service' +import { TmdbIdService } from '../api/tmdb-api/tmdb-id.service' +import { ServarrAction } from '../collections/interfaces/collection.interface' +import { MaintainerrLogger } from '../logging/logs.service' +import { RadarrActionHandler } from './radarr-action-handler' describe('RadarrActionHandler', () => { - let radarrActionHandler: RadarrActionHandler; - let mediaServerFactory: Mocked; - let mediaServer: Mocked; - let servarrService: Mocked; - let tmdbIdService: Mocked; - let logger: Mocked; + let radarrActionHandler: RadarrActionHandler + let mediaServerFactory: Mocked + let mediaServer: Mocked + let servarrService: Mocked + let tmdbIdService: Mocked + let logger: Mocked beforeEach(async () => { const { unit, unitRef } = - await TestBed.solitary(RadarrActionHandler).compile(); + await TestBed.solitary(RadarrActionHandler).compile() - radarrActionHandler = unit; - mediaServerFactory = unitRef.get(MediaServerFactory); - servarrService = unitRef.get(ServarrService); - tmdbIdService = unitRef.get(TmdbIdService); - logger = unitRef.get(MaintainerrLogger); + radarrActionHandler = unit + mediaServerFactory = unitRef.get(MediaServerFactory) + servarrService = unitRef.get(ServarrService) + tmdbIdService = unitRef.get(TmdbIdService) + logger = unitRef.get(MaintainerrLogger) // Setup mock for MediaServerFactory mediaServer = { getMetadata: jest.fn(), deleteFromDisk: jest.fn(), getLibraries: jest.fn(), - } as unknown as Mocked; - mediaServerFactory.getService.mockResolvedValue(mediaServer); - }); + } as unknown as Mocked + mediaServerFactory.getService.mockResolvedValue(mediaServer) + }) it('should do nothing when tmdbid failed lookup', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, radarrSettingsId: 1, type: 'movie', - }); + }) const collectionMedia = createCollectionMedia(collection, { tmdbId: undefined, - }); + }) - tmdbIdService.getTmdbIdFromMediaServerId.mockResolvedValue(undefined); + tmdbIdService.getTmdbIdFromMediaServerId.mockResolvedValue(undefined) - const mockedRadarrApi = mockRadarrApi(servarrService, logger); + const mockedRadarrApi = mockRadarrApi(servarrService, logger) - await radarrActionHandler.handleAction(collection, collectionMedia); + await radarrActionHandler.handleAction(collection, collectionMedia) - expect(tmdbIdService.getTmdbIdFromMediaServerId).toHaveBeenCalled(); - validateNoRadarrActionsTaken(mockedRadarrApi); - }); + expect(tmdbIdService.getTmdbIdFromMediaServerId).toHaveBeenCalled() + validateNoRadarrActionsTaken(mockedRadarrApi) + }) it('should do nothing when movie cannot be found and action is UNMONITOR', async () => { const collection = createCollection({ arrAction: ServarrAction.UNMONITOR, radarrSettingsId: 1, type: 'movie', - }); + }) const collectionMedia = createCollectionMedia(collection, { tmdbId: 1, - }); + }) - const mockedRadarrApi = mockRadarrApi(servarrService, logger); - jest - .spyOn(mockedRadarrApi, 'getMovieByTmdbId') - .mockResolvedValue(undefined); + const mockedRadarrApi = mockRadarrApi(servarrService, logger) + jest.spyOn(mockedRadarrApi, 'getMovieByTmdbId').mockResolvedValue(undefined) - await radarrActionHandler.handleAction(collection, collectionMedia); + await radarrActionHandler.handleAction(collection, collectionMedia) - expect(mockedRadarrApi.getMovieByTmdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - validateNoRadarrActionsTaken(mockedRadarrApi); - }); + expect(mockedRadarrApi.getMovieByTmdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + validateNoRadarrActionsTaken(mockedRadarrApi) + }) it.each([ { action: ServarrAction.DELETE, title: 'DELETE' }, @@ -98,26 +96,26 @@ describe('RadarrActionHandler', () => { arrAction: action, radarrSettingsId: 1, type: 'movie', - }); + }) const collectionMedia = createCollectionMedia(collection, { tmdbId: 1, - }); + }) - const mockedRadarrApi = mockRadarrApi(servarrService, logger); + const mockedRadarrApi = mockRadarrApi(servarrService, logger) jest .spyOn(mockedRadarrApi, 'getMovieByTmdbId') - .mockResolvedValue(createRadarrMovie({ id: 5 })); + .mockResolvedValue(createRadarrMovie({ id: 5 })) - await radarrActionHandler.handleAction(collection, collectionMedia); + await radarrActionHandler.handleAction(collection, collectionMedia) expect(mockedRadarrApi.deleteMovie).toHaveBeenCalledWith( 5, true, collection.listExclusions, - ); - expect(mockedRadarrApi.updateMovie).not.toHaveBeenCalled(); + ) + expect(mockedRadarrApi.updateMovie).not.toHaveBeenCalled() }, - ); + ) it.each([{ listExclusions: true }, { listExclusions: false }])( 'should unmonitor movie when action is UNMONITOR', @@ -127,25 +125,25 @@ describe('RadarrActionHandler', () => { radarrSettingsId: 1, type: 'movie', listExclusions, - }); + }) const collectionMedia = createCollectionMedia(collection, { tmdbId: 1, - }); + }) - const mockedRadarrApi = mockRadarrApi(servarrService, logger); + const mockedRadarrApi = mockRadarrApi(servarrService, logger) jest .spyOn(mockedRadarrApi, 'getMovieByTmdbId') - .mockResolvedValue(createRadarrMovie({ id: 5 })); + .mockResolvedValue(createRadarrMovie({ id: 5 })) - await radarrActionHandler.handleAction(collection, collectionMedia); + await radarrActionHandler.handleAction(collection, collectionMedia) expect(mockedRadarrApi.updateMovie).toHaveBeenCalledWith(5, { monitored: false, addImportExclusion: listExclusions, - }); - expect(mockedRadarrApi.deleteMovie).not.toHaveBeenCalled(); + }) + expect(mockedRadarrApi.deleteMovie).not.toHaveBeenCalled() }, - ); + ) it.each([{ listExclusions: true }, { listExclusions: false }])( 'should unmonitor and delete movie when action is UNMONITOR_DELETE_ALL', @@ -155,24 +153,24 @@ describe('RadarrActionHandler', () => { radarrSettingsId: 1, type: 'movie', listExclusions, - }); + }) const collectionMedia = createCollectionMedia(collection, { tmdbId: 1, - }); + }) - const mockedRadarrApi = mockRadarrApi(servarrService, logger); + const mockedRadarrApi = mockRadarrApi(servarrService, logger) jest .spyOn(mockedRadarrApi, 'getMovieByTmdbId') - .mockResolvedValue(createRadarrMovie({ id: 5 })); + .mockResolvedValue(createRadarrMovie({ id: 5 })) - await radarrActionHandler.handleAction(collection, collectionMedia); + await radarrActionHandler.handleAction(collection, collectionMedia) expect(mockedRadarrApi.updateMovie).toHaveBeenCalledWith(5, { deleteFiles: true, monitored: false, addImportExclusion: listExclusions, - }); - expect(mockedRadarrApi.deleteMovie).not.toHaveBeenCalled(); + }) + expect(mockedRadarrApi.deleteMovie).not.toHaveBeenCalled() }, - ); -}); + ) +}) diff --git a/apps/server/src/modules/actions/radarr-action-handler.ts b/apps/server/src/modules/actions/radarr-action-handler.ts index 03c9c7e0..a4db05a1 100644 --- a/apps/server/src/modules/actions/radarr-action-handler.ts +++ b/apps/server/src/modules/actions/radarr-action-handler.ts @@ -1,11 +1,11 @@ -import { Injectable } from '@nestjs/common'; -import { MediaServerFactory } from '../api/media-server/media-server.factory'; -import { ServarrService } from '../api/servarr-api/servarr.service'; -import { TmdbIdService } from '../api/tmdb-api/tmdb-id.service'; -import { Collection } from '../collections/entities/collection.entities'; -import { CollectionMedia } from '../collections/entities/collection_media.entities'; -import { ServarrAction } from '../collections/interfaces/collection.interface'; -import { MaintainerrLogger } from '../logging/logs.service'; +import { Injectable } from '@nestjs/common' +import { MediaServerFactory } from '../api/media-server/media-server.factory' +import { ServarrService } from '../api/servarr-api/servarr.service' +import { TmdbIdService } from '../api/tmdb-api/tmdb-id.service' +import { Collection } from '../collections/entities/collection.entities' +import { CollectionMedia } from '../collections/entities/collection_media.entities' +import { ServarrAction } from '../collections/interfaces/collection.interface' +import { MaintainerrLogger } from '../logging/logs.service' @Injectable() export class RadarrActionHandler { @@ -15,7 +15,7 @@ export class RadarrActionHandler { private readonly tmdbIdService: TmdbIdService, private readonly logger: MaintainerrLogger, ) { - logger.setContext(RadarrActionHandler.name); + logger.setContext(RadarrActionHandler.name) } public async handleAction( @@ -24,7 +24,7 @@ export class RadarrActionHandler { ): Promise { const radarrApiClient = await this.servarrApi.getRadarrApiClient( collection.radarrSettingsId, - ); + ) // find tmdbid const tmdbid = media.tmdbId @@ -33,10 +33,10 @@ export class RadarrActionHandler { await this.tmdbIdService.getTmdbIdFromMediaServerId( media.mediaServerId, ) - )?.id; + )?.id if (tmdbid) { - const radarrMedia = await radarrApiClient.getMovieByTmdbId(tmdbid); + const radarrMedia = await radarrApiClient.getMovieByTmdbId(tmdbid) if (radarrMedia?.id) { switch (collection.arrAction) { case ServarrAction.DELETE: @@ -45,48 +45,48 @@ export class RadarrActionHandler { radarrMedia.id, true, collection.listExclusions, - ); + ) this.logger.log( `Removed movie with tmdb id ${tmdbid} from filesystem & Radarr`, - ); - break; + ) + break case ServarrAction.UNMONITOR: await radarrApiClient.updateMovie(radarrMedia.id, { monitored: false, addImportExclusion: collection.listExclusions, - }); + }) this.logger.log( `Unmonitored movie with tmdb id ${tmdbid}${collection.listExclusions ? ' & added to import exclusion list' : ''} in Radarr`, - ); - break; + ) + break case ServarrAction.UNMONITOR_DELETE_ALL: await radarrApiClient.updateMovie(radarrMedia.id, { monitored: false, deleteFiles: true, addImportExclusion: collection.listExclusions, - }); + }) this.logger.log( `Unmonitored movie with tmdb id ${tmdbid}${collection.listExclusions ? ', added to import exclusion list' : ''} & removed files from filesystem in Radarr`, - ); - break; + ) + break } } else { if (collection.arrAction !== ServarrAction.UNMONITOR) { this.logger.log( `Couldn't find movie with tmdb id ${tmdbid} in Radarr, so no Radarr action was taken for movie with media server ID ${media.mediaServerId}. Attempting to remove from the filesystem via media server.`, - ); - const mediaServer = await this.mediaServerFactory.getService(); - await mediaServer.deleteFromDisk(media.mediaServerId); + ) + const mediaServer = await this.mediaServerFactory.getService() + await mediaServer.deleteFromDisk(media.mediaServerId) } else { this.logger.log( `Radarr unmonitor action was not possible, couldn't find movie with tmdb id ${tmdbid} in Radarr. No action was taken for movie with media server ID ${media.mediaServerId}`, - ); + ) } } } else { this.logger.log( `Couldn't find correct tmdb id. No action taken for movie with media server ID: ${media.mediaServerId}. Please check this movie manually`, - ); + ) } } } diff --git a/apps/server/src/modules/actions/sonarr-action-handler.spec.ts b/apps/server/src/modules/actions/sonarr-action-handler.spec.ts index 53ee64d8..0fd75950 100644 --- a/apps/server/src/modules/actions/sonarr-action-handler.spec.ts +++ b/apps/server/src/modules/actions/sonarr-action-handler.spec.ts @@ -1,53 +1,53 @@ -import { MediaItem, MediaItemType } from '@maintainerr/contracts'; -import { Mocked } from '@suites/doubles.jest'; -import { TestBed } from '@suites/unit'; +import { MediaItem, MediaItemType } from '@maintainerr/contracts' +import { Mocked } from '@suites/doubles.jest' +import { TestBed } from '@suites/unit' import { createCollection, createCollectionMediaWithMetadata, createSonarrSeries, -} from '../../../test/utils/data'; +} from '../../../test/utils/data' import { mockSonarrApi, validateNoSonarrActionsTaken, -} from '../../../test/utils/servarr-mock'; -import { MediaServerFactory } from '../api/media-server/media-server.factory'; -import { IMediaServerService } from '../api/media-server/media-server.interface'; -import { ServarrService } from '../api/servarr-api/servarr.service'; -import { ServarrAction } from '../collections/interfaces/collection.interface'; -import { MaintainerrLogger } from '../logging/logs.service'; -import { MediaIdFinder } from './media-id-finder'; -import { SonarrActionHandler } from './sonarr-action-handler'; +} from '../../../test/utils/servarr-mock' +import { MediaServerFactory } from '../api/media-server/media-server.factory' +import { IMediaServerService } from '../api/media-server/media-server.interface' +import { ServarrService } from '../api/servarr-api/servarr.service' +import { ServarrAction } from '../collections/interfaces/collection.interface' +import { MaintainerrLogger } from '../logging/logs.service' +import { MediaIdFinder } from './media-id-finder' +import { SonarrActionHandler } from './sonarr-action-handler' describe('SonarrActionHandler', () => { - let sonarrActionHandler: SonarrActionHandler; - let mediaServerFactory: Mocked; - let mediaServer: Mocked; - let servarrService: Mocked; - let mediaIdFinder: Mocked; - let logger: Mocked; + let sonarrActionHandler: SonarrActionHandler + let mediaServerFactory: Mocked + let mediaServer: Mocked + let servarrService: Mocked + let mediaIdFinder: Mocked + let logger: Mocked beforeEach(async () => { const { unit, unitRef } = - await TestBed.solitary(SonarrActionHandler).compile(); + await TestBed.solitary(SonarrActionHandler).compile() - sonarrActionHandler = unit; - mediaServerFactory = unitRef.get(MediaServerFactory); - servarrService = unitRef.get(ServarrService); - mediaIdFinder = unitRef.get(MediaIdFinder); - logger = unitRef.get(MaintainerrLogger); + sonarrActionHandler = unit + mediaServerFactory = unitRef.get(MediaServerFactory) + servarrService = unitRef.get(ServarrService) + mediaIdFinder = unitRef.get(MediaIdFinder) + logger = unitRef.get(MaintainerrLogger) // Setup media server mock mediaServer = { getMetadata: jest.fn(), deleteFromDisk: jest.fn(), - } as unknown as Mocked; - mediaServerFactory.getService.mockResolvedValue(mediaServer); - }); + } as unknown as Mocked + mediaServerFactory.getService.mockResolvedValue(mediaServer) + }) // Helper to setup media server mock for each test const mockMediaServerMetadata = (mediaData: MediaItem) => { - mediaServer.getMetadata.mockResolvedValue(mediaData); - }; + mediaServer.getMetadata.mockResolvedValue(mediaData) + } it.each([ { type: 'season', title: 'SEASONS' }, @@ -66,26 +66,26 @@ describe('SonarrActionHandler', () => { arrAction: ServarrAction.DELETE, sonarrSettingsId: 1, type: type as MediaItemType, - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId'); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId') - mediaIdFinder.findTvdbId.mockResolvedValue(undefined); + mediaIdFinder.findTvdbId.mockResolvedValue(undefined) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mockedSonarrApi.getSeriesByTvdbId).not.toHaveBeenCalled(); - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - validateNoSonarrActionsTaken(mockedSonarrApi); + expect(mockedSonarrApi.getSeriesByTvdbId).not.toHaveBeenCalled() + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + validateNoSonarrActionsTaken(mockedSonarrApi) }, - ); + ) it.each([ { type: 'season', title: 'SEASONS' }, @@ -104,28 +104,28 @@ describe('SonarrActionHandler', () => { arrAction: ServarrAction.UNMONITOR, sonarrSettingsId: 1, type: type as MediaItemType, - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const mockedSonarrApi = mockSonarrApi(servarrService, logger); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) jest .spyOn(mockedSonarrApi, 'getSeriesByTvdbId') - .mockResolvedValue(undefined); + .mockResolvedValue(undefined) - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - validateNoSonarrActionsTaken(mockedSonarrApi); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + validateNoSonarrActionsTaken(mockedSonarrApi) }, - ); + ) it.each([ { @@ -180,437 +180,437 @@ describe('SonarrActionHandler', () => { arrAction: action, sonarrSettingsId: 1, type: type as MediaItemType, - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const mockedSonarrApi = mockSonarrApi(servarrService, logger); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) jest .spyOn(mockedSonarrApi, 'getSeriesByTvdbId') - .mockResolvedValue(undefined); + .mockResolvedValue(undefined) - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).toHaveBeenCalled(); - validateNoSonarrActionsTaken(mockedSonarrApi); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).toHaveBeenCalled() + validateNoSonarrActionsTaken(mockedSonarrApi) }, - ); + ) it('should unmonitor season and delete episodes when type SEASONS and action DELETE', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, sonarrSettingsId: 1, type: 'season', - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const series = createSonarrSeries(); + const series = createSonarrSeries() - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series); - jest.spyOn(mockedSonarrApi, 'unmonitorSeasons').mockResolvedValue(series); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series) + jest.spyOn(mockedSonarrApi, 'unmonitorSeasons').mockResolvedValue(series) - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled(); - expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled(); - expect(mockedSonarrApi.delete).not.toHaveBeenCalled(); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled() + expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled() + expect(mockedSonarrApi.delete).not.toHaveBeenCalled() expect(mockedSonarrApi.unmonitorSeasons).toHaveBeenCalledWith( series.id, collectionMedia.mediaData.index, true, - ); - }); + ) + }) it('should unmonitor and delete episode when type EPISODES and action DELETE', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, sonarrSettingsId: 1, type: 'episode', - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const series = createSonarrSeries(); + const series = createSonarrSeries() - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series) - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - expect(mockedSonarrApi.unmonitorSeasons).not.toHaveBeenCalled(); - expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled(); - expect(mockedSonarrApi.delete).not.toHaveBeenCalled(); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + expect(mockedSonarrApi.unmonitorSeasons).not.toHaveBeenCalled() + expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled() + expect(mockedSonarrApi.delete).not.toHaveBeenCalled() expect(mockedSonarrApi.UnmonitorDeleteEpisodes).toHaveBeenCalledWith( series.id, collectionMedia.mediaData.parentIndex, [collectionMedia.mediaData.index], true, - ); - }); + ) + }) it('should delete show when type SHOWS and action DELETE', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, sonarrSettingsId: 1, type: 'show', - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const series = createSonarrSeries(); + const series = createSonarrSeries() - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series) - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - expect(mockedSonarrApi.unmonitorSeasons).not.toHaveBeenCalled(); - expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled(); - expect(mockedSonarrApi.delete).not.toHaveBeenCalled(); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + expect(mockedSonarrApi.unmonitorSeasons).not.toHaveBeenCalled() + expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled() + expect(mockedSonarrApi.delete).not.toHaveBeenCalled() expect(mockedSonarrApi.deleteShow).toHaveBeenCalledWith( series.id, true, collection.listExclusions, - ); - }); + ) + }) it('should unmonitor season and episodes when type SEASONS and action UNMONITOR', async () => { const collection = createCollection({ arrAction: ServarrAction.UNMONITOR, sonarrSettingsId: 1, type: 'season', - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const series = createSonarrSeries(); + const series = createSonarrSeries() - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series); - jest.spyOn(mockedSonarrApi, 'unmonitorSeasons').mockResolvedValue(series); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series) + jest.spyOn(mockedSonarrApi, 'unmonitorSeasons').mockResolvedValue(series) - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled(); - expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled(); - expect(mockedSonarrApi.delete).not.toHaveBeenCalled(); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled() + expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled() + expect(mockedSonarrApi.delete).not.toHaveBeenCalled() expect(mockedSonarrApi.unmonitorSeasons).toHaveBeenCalledWith( series.id, collectionMedia.mediaData.index, false, - ); - }); + ) + }) it('should unmonitor episode when type EPISODES and action UNMONITOR', async () => { const collection = createCollection({ arrAction: ServarrAction.UNMONITOR, sonarrSettingsId: 1, type: 'episode', - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const series = createSonarrSeries(); + const series = createSonarrSeries() - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series) - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - expect(mockedSonarrApi.unmonitorSeasons).not.toHaveBeenCalled(); - expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled(); - expect(mockedSonarrApi.delete).not.toHaveBeenCalled(); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + expect(mockedSonarrApi.unmonitorSeasons).not.toHaveBeenCalled() + expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled() + expect(mockedSonarrApi.delete).not.toHaveBeenCalled() expect(mockedSonarrApi.UnmonitorDeleteEpisodes).toHaveBeenCalledWith( series.id, collectionMedia.mediaData.parentIndex, [collectionMedia.mediaData.index], false, - ); - }); + ) + }) it('should unmonitor show, seasons and episodes when type SHOWS and action UNMONITOR', async () => { const collection = createCollection({ arrAction: ServarrAction.UNMONITOR, sonarrSettingsId: 1, type: 'show', - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const series = createSonarrSeries(); + const series = createSonarrSeries() - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series); - jest.spyOn(mockedSonarrApi, 'unmonitorSeasons').mockResolvedValue(series); - jest.spyOn(mockedSonarrApi, 'updateSeries').mockResolvedValue(); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series) + jest.spyOn(mockedSonarrApi, 'unmonitorSeasons').mockResolvedValue(series) + jest.spyOn(mockedSonarrApi, 'updateSeries').mockResolvedValue() - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled(); - expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled(); - expect(mockedSonarrApi.delete).not.toHaveBeenCalled(); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled() + expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled() + expect(mockedSonarrApi.delete).not.toHaveBeenCalled() expect(mockedSonarrApi.unmonitorSeasons).toHaveBeenCalledWith( series.id, 'all', false, - ); + ) expect(mockedSonarrApi.updateSeries).toHaveBeenCalledWith({ ...series, monitored: false, - }); - }); + }) + }) it('should do nothing for season when type SEASONS and action UNMONITOR_DELETE_ALL', async () => { const collection = createCollection({ arrAction: ServarrAction.UNMONITOR_DELETE_ALL, sonarrSettingsId: 1, type: 'season', - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const series = createSonarrSeries(); + const series = createSonarrSeries() - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series) - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - validateNoSonarrActionsTaken(mockedSonarrApi); - }); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + validateNoSonarrActionsTaken(mockedSonarrApi) + }) it('should do nothing for episode type EPISODES and action UNMONITOR_DELETE_ALL', async () => { const collection = createCollection({ arrAction: ServarrAction.UNMONITOR_DELETE_ALL, sonarrSettingsId: 1, type: 'episode', - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const series = createSonarrSeries(); + const series = createSonarrSeries() - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series) - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - validateNoSonarrActionsTaken(mockedSonarrApi); - }); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + validateNoSonarrActionsTaken(mockedSonarrApi) + }) it('should unmonitor show, seasons and episodes and delete all files when type SHOWS and action UNMONITOR_DELETE_ALL', async () => { const collection = createCollection({ arrAction: ServarrAction.UNMONITOR_DELETE_ALL, sonarrSettingsId: 1, type: 'show', - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const series = createSonarrSeries(); + const series = createSonarrSeries() - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series); - jest.spyOn(mockedSonarrApi, 'unmonitorSeasons').mockResolvedValue(series); - jest.spyOn(mockedSonarrApi, 'updateSeries').mockResolvedValue(); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series) + jest.spyOn(mockedSonarrApi, 'unmonitorSeasons').mockResolvedValue(series) + jest.spyOn(mockedSonarrApi, 'updateSeries').mockResolvedValue() - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled(); - expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled(); - expect(mockedSonarrApi.delete).not.toHaveBeenCalled(); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled() + expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled() + expect(mockedSonarrApi.delete).not.toHaveBeenCalled() expect(mockedSonarrApi.unmonitorSeasons).toHaveBeenCalledWith( series.id, 'all', true, - ); + ) expect(mockedSonarrApi.updateSeries).toHaveBeenCalledWith({ ...series, monitored: false, - }); - }); + }) + }) it('should ummonitor and delete existing episodes, leaving season monitored, when type SEASONS and action UNMONITOR_DELETE_EXISTING', async () => { const collection = createCollection({ arrAction: ServarrAction.UNMONITOR_DELETE_EXISTING, sonarrSettingsId: 1, type: 'season', - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const series = createSonarrSeries(); + const series = createSonarrSeries() - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series); - jest.spyOn(mockedSonarrApi, 'unmonitorSeasons').mockResolvedValue(series); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series) + jest.spyOn(mockedSonarrApi, 'unmonitorSeasons').mockResolvedValue(series) - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled(); - expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled(); - expect(mockedSonarrApi.delete).not.toHaveBeenCalled(); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled() + expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled() + expect(mockedSonarrApi.delete).not.toHaveBeenCalled() expect(mockedSonarrApi.unmonitorSeasons).toHaveBeenCalledWith( series.id, collectionMedia.mediaData.index, true, true, - ); - }); + ) + }) it('should do nothing for episode when type EPISODES and action UNMONITOR_DELETE_EXISTING', async () => { const collection = createCollection({ arrAction: ServarrAction.UNMONITOR_DELETE_EXISTING, sonarrSettingsId: 1, type: 'episode', - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const series = createSonarrSeries(); + const series = createSonarrSeries() - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series) - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - validateNoSonarrActionsTaken(mockedSonarrApi); - }); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + validateNoSonarrActionsTaken(mockedSonarrApi) + }) it('should unmonitor show, unmonitor and delete existing episodes and leave season monitored, when type SHOWS and action UNMONITOR_DELETE_EXISTING', async () => { const collection = createCollection({ arrAction: ServarrAction.UNMONITOR_DELETE_EXISTING, sonarrSettingsId: 1, type: 'show', - }); + }) const collectionMedia = createCollectionMediaWithMetadata(collection, { tmdbId: 1, - }); + }) - mockMediaServerMetadata(collectionMedia.mediaData); + mockMediaServerMetadata(collectionMedia.mediaData) - const series = createSonarrSeries(); + const series = createSonarrSeries() - const mockedSonarrApi = mockSonarrApi(servarrService, logger); - jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series); - jest.spyOn(mockedSonarrApi, 'unmonitorSeasons').mockResolvedValue(series); - jest.spyOn(mockedSonarrApi, 'updateSeries').mockResolvedValue(); + const mockedSonarrApi = mockSonarrApi(servarrService, logger) + jest.spyOn(mockedSonarrApi, 'getSeriesByTvdbId').mockResolvedValue(series) + jest.spyOn(mockedSonarrApi, 'unmonitorSeasons').mockResolvedValue(series) + jest.spyOn(mockedSonarrApi, 'updateSeries').mockResolvedValue() - mediaIdFinder.findTvdbId.mockResolvedValue(1); + mediaIdFinder.findTvdbId.mockResolvedValue(1) - await sonarrActionHandler.handleAction(collection, collectionMedia); + await sonarrActionHandler.handleAction(collection, collectionMedia) - expect(mediaIdFinder.findTvdbId).toHaveBeenCalled(); - expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled(); - expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled(); - expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled(); - expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled(); - expect(mockedSonarrApi.delete).not.toHaveBeenCalled(); + expect(mediaIdFinder.findTvdbId).toHaveBeenCalled() + expect(mockedSonarrApi.getSeriesByTvdbId).toHaveBeenCalled() + expect(mediaServer.deleteFromDisk).not.toHaveBeenCalled() + expect(mockedSonarrApi.deleteShow).not.toHaveBeenCalled() + expect(mockedSonarrApi.UnmonitorDeleteEpisodes).not.toHaveBeenCalled() + expect(mockedSonarrApi.delete).not.toHaveBeenCalled() expect(mockedSonarrApi.unmonitorSeasons).toHaveBeenCalledWith( series.id, 'existing', true, - ); + ) expect(mockedSonarrApi.updateSeries).toHaveBeenCalledWith({ ...series, monitored: false, - }); - }); -}); + }) + }) +}) diff --git a/apps/server/src/modules/actions/sonarr-action-handler.ts b/apps/server/src/modules/actions/sonarr-action-handler.ts index 3fa43d02..24c37fc2 100644 --- a/apps/server/src/modules/actions/sonarr-action-handler.ts +++ b/apps/server/src/modules/actions/sonarr-action-handler.ts @@ -1,13 +1,13 @@ -import { MediaItem } from '@maintainerr/contracts'; -import { Injectable } from '@nestjs/common'; -import { MediaServerFactory } from '../api/media-server/media-server.factory'; -import { ServarrService } from '../api/servarr-api/servarr.service'; -import { TmdbIdService } from '../api/tmdb-api/tmdb-id.service'; -import { Collection } from '../collections/entities/collection.entities'; -import { CollectionMedia } from '../collections/entities/collection_media.entities'; -import { ServarrAction } from '../collections/interfaces/collection.interface'; -import { MaintainerrLogger } from '../logging/logs.service'; -import { MediaIdFinder } from './media-id-finder'; +import { MediaItem } from '@maintainerr/contracts' +import { Injectable } from '@nestjs/common' +import { MediaServerFactory } from '../api/media-server/media-server.factory' +import { ServarrService } from '../api/servarr-api/servarr.service' +import { TmdbIdService } from '../api/tmdb-api/tmdb-id.service' +import { Collection } from '../collections/entities/collection.entities' +import { CollectionMedia } from '../collections/entities/collection_media.entities' +import { ServarrAction } from '../collections/interfaces/collection.interface' +import { MaintainerrLogger } from '../logging/logs.service' +import { MediaIdFinder } from './media-id-finder' @Injectable() export class SonarrActionHandler { @@ -18,87 +18,87 @@ export class SonarrActionHandler { private readonly logger: MaintainerrLogger, private readonly mediaServerFactory: MediaServerFactory, ) { - logger.setContext(SonarrActionHandler.name); + logger.setContext(SonarrActionHandler.name) } public async handleAction( collection: Collection, media: CollectionMedia, ): Promise { - const mediaServer = await this.mediaServerFactory.getService(); + const mediaServer = await this.mediaServerFactory.getService() const sonarrApiClient = await this.servarrApi.getSonarrApiClient( collection.sonarrSettingsId, - ); + ) - let mediaData: MediaItem | undefined = undefined; + let mediaData: MediaItem | undefined = undefined // get the tvdb id - let tvdbId: number | undefined = undefined; + let tvdbId: number | undefined = undefined switch (collection.type) { case 'season': - mediaData = await mediaServer.getMetadata(media.mediaServerId); + mediaData = await mediaServer.getMetadata(media.mediaServerId) tvdbId = await this.mediaIdFinder.findTvdbId( mediaData?.parentId, media.tmdbId, - ); + ) media.tmdbId = media.tmdbId ? media.tmdbId : ( await this.tmdbIdService.getTmdbIdFromMediaServerId( mediaData?.parentId, ) - )?.id; - break; + )?.id + break case 'episode': - mediaData = await mediaServer.getMetadata(media.mediaServerId); + mediaData = await mediaServer.getMetadata(media.mediaServerId) tvdbId = await this.mediaIdFinder.findTvdbId( mediaData?.grandparentId, media.tmdbId, - ); + ) media.tmdbId = media.tmdbId ? media.tmdbId : ( await this.tmdbIdService.getTmdbIdFromMediaServerId( mediaData?.grandparentId, ) - )?.id; - break; + )?.id + break default: tvdbId = await this.mediaIdFinder.findTvdbId( media.mediaServerId, media.tmdbId, - ); + ) media.tmdbId = media.tmdbId ? media.tmdbId : ( await this.tmdbIdService.getTmdbIdFromMediaServerId( media.mediaServerId, ) - )?.id; - break; + )?.id + break } if (!tvdbId) { this.logger.log( `Couldn't find correct tvdb id. No action was taken for show: https://www.themoviedb.org/tv/${media.tmdbId}. Please check this show manually`, - ); - return; + ) + return } - let sonarrMedia = await sonarrApiClient.getSeriesByTvdbId(tvdbId); + let sonarrMedia = await sonarrApiClient.getSeriesByTvdbId(tvdbId) if (!sonarrMedia?.id) { if (collection.arrAction !== ServarrAction.UNMONITOR) { this.logger.log( `Couldn't find correct tvdb id. No Sonarr action was taken for show: https://www.themoviedb.org/tv/${media.tmdbId}. Attempting to remove from the filesystem via media server.`, - ); - await mediaServer.deleteFromDisk(media.mediaServerId); + ) + await mediaServer.deleteFromDisk(media.mediaServerId) } else { this.logger.log( `Couldn't find correct tvdb id. No unmonitor action was taken for show: https://www.themoviedb.org/tv/${media.tmdbId}`, - ); + ) } - return; + return } switch (collection.arrAction) { @@ -109,32 +109,32 @@ export class SonarrActionHandler { sonarrMedia.id, mediaData?.index, true, - ); + ) this.logger.log( `[Sonarr] Removed season ${mediaData?.index} from show '${sonarrMedia.title}'`, - ); - break; + ) + break case 'episode': await sonarrApiClient.UnmonitorDeleteEpisodes( sonarrMedia.id, mediaData?.parentIndex, [mediaData?.index], true, - ); + ) this.logger.log( `[Sonarr] Removed season ${mediaData?.parentIndex} episode ${mediaData?.index} from show '${sonarrMedia.title}'`, - ); - break; + ) + break default: await sonarrApiClient.deleteShow( sonarrMedia.id, true, collection.listExclusions, - ); - this.logger.log(`Removed show ${sonarrMedia.title}' from Sonarr`); - break; + ) + this.logger.log(`Removed show ${sonarrMedia.title}' from Sonarr`) + break } - break; + break case ServarrAction.UNMONITOR: switch (collection.type) { case 'season': @@ -142,41 +142,41 @@ export class SonarrActionHandler { sonarrMedia.id, mediaData?.index, false, - ); + ) this.logger.log( `[Sonarr] Unmonitored season ${mediaData?.index} from show '${sonarrMedia.title}'`, - ); - break; + ) + break case 'episode': await sonarrApiClient.UnmonitorDeleteEpisodes( sonarrMedia.id, mediaData?.parentIndex, [mediaData?.index], false, - ); + ) this.logger.log( `[Sonarr] Unmonitored season ${mediaData?.parentIndex} episode ${mediaData?.index} from show '${sonarrMedia.title}'`, - ); - break; + ) + break default: sonarrMedia = await sonarrApiClient.unmonitorSeasons( sonarrMedia.id, 'all', false, - ); + ) if (sonarrMedia) { // unmonitor show - sonarrMedia.monitored = false; - await sonarrApiClient.updateSeries(sonarrMedia); + sonarrMedia.monitored = false + await sonarrApiClient.updateSeries(sonarrMedia) this.logger.log( `[Sonarr] Unmonitored show '${sonarrMedia.title}'`, - ); + ) } - break; + break } - break; + break case ServarrAction.UNMONITOR_DELETE_ALL: switch (collection.type) { case 'show': @@ -184,25 +184,25 @@ export class SonarrActionHandler { sonarrMedia.id, 'all', true, - ); + ) if (sonarrMedia) { // unmonitor show - sonarrMedia.monitored = false; - await sonarrApiClient.updateSeries(sonarrMedia); + sonarrMedia.monitored = false + await sonarrApiClient.updateSeries(sonarrMedia) this.logger.log( `[Sonarr] Unmonitored show '${sonarrMedia.title}' and removed all episodes`, - ); + ) } - break; + break default: this.logger.warn( `[Sonarr] UNMONITOR_DELETE_ALL is not supported for type: ${collection.type}`, - ); - break; + ) + break } - break; + break case ServarrAction.UNMONITOR_DELETE_EXISTING: switch (collection.type) { case 'season': @@ -211,35 +211,35 @@ export class SonarrActionHandler { mediaData?.index, true, true, - ); + ) this.logger.log( `[Sonarr] Removed exisiting episodes from season ${mediaData?.index} from show '${sonarrMedia.title}'`, - ); - break; + ) + break case 'show': sonarrMedia = await sonarrApiClient.unmonitorSeasons( sonarrMedia.id, 'existing', true, - ); + ) if (sonarrMedia) { // unmonitor show - sonarrMedia.monitored = false; - await sonarrApiClient.updateSeries(sonarrMedia); + sonarrMedia.monitored = false + await sonarrApiClient.updateSeries(sonarrMedia) this.logger.log( `[Sonarr] Unmonitored show '${sonarrMedia.title}' and Removed exisiting episodes`, - ); + ) } - break; + break default: this.logger.warn( `[Sonarr] UNMONITOR_DELETE_EXISTING is not supported for type: ${collection.type}`, - ); - break; + ) + break } - break; + break } } } diff --git a/apps/server/src/modules/api/external-api/external-api.module.ts b/apps/server/src/modules/api/external-api/external-api.module.ts index 694c2a0d..bccc8da4 100644 --- a/apps/server/src/modules/api/external-api/external-api.module.ts +++ b/apps/server/src/modules/api/external-api/external-api.module.ts @@ -1,5 +1,5 @@ -import { Module } from '@nestjs/common'; -import { ExternalApiService } from './external-api.service'; +import { Module } from '@nestjs/common' +import { ExternalApiService } from './external-api.service' @Module({ imports: [], diff --git a/apps/server/src/modules/api/external-api/external-api.service.ts b/apps/server/src/modules/api/external-api/external-api.service.ts index c8f6cd80..06c619b2 100644 --- a/apps/server/src/modules/api/external-api/external-api.service.ts +++ b/apps/server/src/modules/api/external-api/external-api.service.ts @@ -1,23 +1,23 @@ -import axios, { AxiosError, AxiosInstance, RawAxiosRequestConfig } from 'axios'; -import axiosRetry from 'axios-retry'; -import NodeCache from 'node-cache'; -import { MaintainerrLogger } from '../../logging/logs.service'; +import axios, { AxiosError, AxiosInstance, RawAxiosRequestConfig } from 'axios' +import axiosRetry from 'axios-retry' +import NodeCache from 'node-cache' +import { MaintainerrLogger } from '../../logging/logs.service' // 20 minute default TTL (in seconds) -const DEFAULT_TTL = 1200; +const DEFAULT_TTL = 1200 // 10 seconds default rolling buffer (in ms) -const DEFAULT_ROLLING_BUFFER = 10000; +const DEFAULT_ROLLING_BUFFER = 10000 interface ExternalAPIOptions { - nodeCache?: NodeCache; - headers?: Record; + nodeCache?: NodeCache + headers?: Record } export class ExternalApiService { - protected axios: AxiosInstance; - private baseUrl: string; - private cache?: NodeCache; + protected axios: AxiosInstance + private baseUrl: string + private cache?: NodeCache constructor( baseUrl: string, @@ -34,19 +34,19 @@ export class ExternalApiService { Accept: 'application/json', ...options.headers, }, - }); + }) axiosRetry(this.axios, { retries: 3, retryDelay: axiosRetry.exponentialDelay, onRetry: (retryCount, error, requestConfig) => { - const url = this.axios.getUri(requestConfig); + const url = this.axios.getUri(requestConfig) this.logger.debug( `Retry ${retryCount}/3 ${requestConfig.method.toUpperCase()} ${url}: ${error.message}`, - ); + ) }, - }); - this.baseUrl = baseUrl; - this.cache = options.nodeCache; + }) + this.baseUrl = baseUrl + this.cache = options.nodeCache } public async get( @@ -55,22 +55,22 @@ export class ExternalApiService { ttl?: number, ): Promise { try { - const cacheKey = this.serializeCacheKey(endpoint, config?.params); - const cachedItem = this.cache?.get(cacheKey); + const cacheKey = this.serializeCacheKey(endpoint, config?.params) + const cachedItem = this.cache?.get(cacheKey) if (cachedItem) { - return cachedItem; + return cachedItem } - const response = await this.axios.get(endpoint, config); + const response = await this.axios.get(endpoint, config) if (this.cache) { - this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); + this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL) } - return response.data; + return response.data } catch (err) { - const url = this.axios.getUri({ ...config, url: endpoint }); - this.logger.debug(`GET ${url} failed: ${err}`); - return undefined; + const url = this.axios.getUri({ ...config, url: endpoint }) + this.logger.debug(`GET ${url} failed: ${err}`) + return undefined } } @@ -79,11 +79,11 @@ export class ExternalApiService { config?: RawAxiosRequestConfig, ): Promise { try { - return (await this.axios.get(endpoint, config)).data; + return (await this.axios.get(endpoint, config)).data } catch (err) { - const url = this.axios.getUri({ ...config, url: endpoint }); - this.logger.debug(`GET ${url} failed: ${err}`); - return undefined; + const url = this.axios.getUri({ ...config, url: endpoint }) + this.logger.debug(`GET ${url} failed: ${err}`) + return undefined } } @@ -91,7 +91,7 @@ export class ExternalApiService { endpoint: string, config?: RawAxiosRequestConfig, ) { - return this.axios.get(endpoint, config); + return this.axios.get(endpoint, config) } public async delete( @@ -99,12 +99,12 @@ export class ExternalApiService { config?: RawAxiosRequestConfig, ): Promise { try { - const response = await this.axios.delete(endpoint, config); - return response.data; + const response = await this.axios.delete(endpoint, config) + return response.data } catch (err) { - const url = this.axios.getUri({ ...config, url: endpoint }); - this.logger.debug(`DELETE ${url} failed: ${err}`); - return undefined; + const url = this.axios.getUri({ ...config, url: endpoint }) + this.logger.debug(`DELETE ${url} failed: ${err}`) + return undefined } } @@ -114,12 +114,12 @@ export class ExternalApiService { config?: RawAxiosRequestConfig, ): Promise { try { - const response = await this.axios.put(endpoint, data, config); - return response.data; + const response = await this.axios.put(endpoint, data, config) + return response.data } catch (err) { - const url = this.axios.getUri({ ...config, url: endpoint }); - this.logger.debug(`PUT ${url} failed: ${err}`); - return undefined; + const url = this.axios.getUri({ ...config, url: endpoint }) + this.logger.debug(`PUT ${url} failed: ${err}`) + return undefined } } @@ -129,12 +129,12 @@ export class ExternalApiService { config?: RawAxiosRequestConfig, ): Promise { try { - const response = await this.axios.post(endpoint, data, config); - return response.data; + const response = await this.axios.post(endpoint, data, config) + return response.data } catch (err) { - const url = this.axios.getUri({ ...config, url: endpoint }); - this.logger.debug(`POST ${url} failed: ${err}`); - return undefined; + const url = this.axios.getUri({ ...config, url: endpoint }) + this.logger.debug(`POST ${url} failed: ${err}`) + return undefined } } @@ -144,11 +144,11 @@ export class ExternalApiService { ttl?: number, ): Promise { try { - const cacheKey = this.serializeCacheKey(endpoint, config?.params); - const cachedItem = this.cache?.get(cacheKey); + const cacheKey = this.serializeCacheKey(endpoint, config?.params) + const cachedItem = this.cache?.get(cacheKey) if (cachedItem) { - const keyTtl = this.cache?.getTtl(cacheKey) ?? 0; + const keyTtl = this.cache?.getTtl(cacheKey) ?? 0 // If the item has passed our rolling check, fetch again in background if ( @@ -156,23 +156,23 @@ export class ExternalApiService { Date.now() - DEFAULT_ROLLING_BUFFER ) { void this.axios.get(endpoint, config).then((response) => { - this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); - }); + this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL) + }) } - return cachedItem; + return cachedItem } - const response = await this.axios.get(endpoint, config); + const response = await this.axios.get(endpoint, config) if (this.cache) { - this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); + this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL) } - return response.data; + return response.data } catch (err) { - const url = this.axios.getUri({ ...config, url: endpoint }); - this.logger.debug(`GET ${url} failed: ${err}`); - return undefined; + const url = this.axios.getUri({ ...config, url: endpoint }) + this.logger.debug(`GET ${url} failed: ${err}`) + return undefined } } @@ -182,65 +182,65 @@ export class ExternalApiService { config?: RawAxiosRequestConfig, ttl?: number, ): Promise { - const url = this.axios.getUri({ ...config, url: endpoint }); + const url = this.axios.getUri({ ...config, url: endpoint }) try { const cacheKey = this.serializeCacheKey( endpoint + data ? data.replace(/\s/g, '').trim() : '', config?.params, - ); - const cachedItem = this.cache?.get(cacheKey); + ) + const cachedItem = this.cache?.get(cacheKey) if (cachedItem) { - const keyTtl = this.cache?.getTtl(cacheKey) ?? 0; + const keyTtl = this.cache?.getTtl(cacheKey) ?? 0 // If the item has passed our rolling check, fetch again in background if (keyTtl < Date.now() - DEFAULT_ROLLING_BUFFER) { this.axios .post(endpoint, data, config) .then((response) => { - this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); + this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL) }) .catch((err: AxiosError) => { if (err.response?.status === 429) { const retryAfter = - err.response.headers['retry-after'] || 'unknown'; + err.response.headers['retry-after'] || 'unknown' this.logger.warn( `${url} Rate limit hit. Retry after: ${retryAfter} seconds.`, - ); + ) } else { - this.logger.warn(`POST ${url} failed: ${err.message}`); - this.logger.debug(err); + this.logger.warn(`POST ${url} failed: ${err.message}`) + this.logger.debug(err) } - }); + }) } - return cachedItem; + return cachedItem } const response = await this.axios .post(endpoint, data, config) .catch((err: AxiosError) => { if (err.response?.status === 429) { - const retryAfter = err.response.headers['retry-after'] || 'unknown'; + const retryAfter = err.response.headers['retry-after'] || 'unknown' this.logger.warn( `${url} Rate limit hit. Retry after: ${retryAfter} seconds.`, - ); + ) } else { - this.logger.warn(`POST ${url} failed: ${err.message}`); - this.logger.debug(err); + this.logger.warn(`POST ${url} failed: ${err.message}`) + this.logger.debug(err) } - return undefined; - }); + return undefined + }) if (this.cache) { - this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); + this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL) } - return response.data; + return response.data } catch (err: any) { - this.logger.warn(`POST ${url} failed: ${err.message}`); - this.logger.debug(err); - return undefined; + this.logger.warn(`POST ${url} failed: ${err.message}`) + this.logger.debug(err) + return undefined } } @@ -250,13 +250,13 @@ export class ExternalApiService { ) { try { if (!params) { - return `${this.baseUrl}${endpoint}`; + return `${this.baseUrl}${endpoint}` } - return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`; + return `${this.baseUrl}${endpoint}${JSON.stringify(params)}` } catch (err) { - this.logger.debug(`Failed serializing cache key: ${err}`); - return undefined; + this.logger.debug(`Failed serializing cache key: ${err}`) + return undefined } } } diff --git a/apps/server/src/modules/api/github-api/github-api.module.ts b/apps/server/src/modules/api/github-api/github-api.module.ts index 90b099c7..8c4e51f8 100644 --- a/apps/server/src/modules/api/github-api/github-api.module.ts +++ b/apps/server/src/modules/api/github-api/github-api.module.ts @@ -1,5 +1,5 @@ -import { Module } from '@nestjs/common'; -import { GitHubApiService } from './github-api.service'; +import { Module } from '@nestjs/common' +import { GitHubApiService } from './github-api.service' @Module({ imports: [], diff --git a/apps/server/src/modules/api/github-api/github-api.service.ts b/apps/server/src/modules/api/github-api/github-api.service.ts index a4018acf..eb5025d6 100644 --- a/apps/server/src/modules/api/github-api/github-api.service.ts +++ b/apps/server/src/modules/api/github-api/github-api.service.ts @@ -1,32 +1,32 @@ -import { Injectable } from '@nestjs/common'; -import { throttling } from '@octokit/plugin-throttling'; -import { Octokit } from 'octokit'; -import { MaintainerrLogger } from '../../logging/logs.service'; -import cacheManager from '../lib/cache'; +import { Injectable } from '@nestjs/common' +import { throttling } from '@octokit/plugin-throttling' +import { Octokit } from 'octokit' +import { MaintainerrLogger } from '../../logging/logs.service' +import cacheManager from '../lib/cache' export interface GitHubRelease { - tag_name: string; - name: string; - body: string; - html_url: string; - created_at: string; - published_at: string; + tag_name: string + name: string + body: string + html_url: string + created_at: string + published_at: string } export interface GitHubCommit { - sha: string; + sha: string } @Injectable() export class GitHubApiService { - private octokit: Octokit; - private cache = cacheManager.getCache('github'); + private octokit: Octokit + private cache = cacheManager.getCache('github') constructor(private readonly logger: MaintainerrLogger) { - logger.setContext(GitHubApiService.name); + logger.setContext(GitHubApiService.name) // Create Octokit instance with throttling plugin - const OctokitWithPlugins = Octokit.plugin(throttling); + const OctokitWithPlugins = Octokit.plugin(throttling) const octokitOptions: ConstructorParameters[0] = { @@ -34,43 +34,43 @@ export class GitHubApiService { onRateLimit: (retryAfter, options, octokit, retryCount) => { logger.warn( `Request quota exhausted for ${options.method} ${options.url}`, - ); + ) if (retryAfter && retryAfter > 10) { logger.error( `Aborting retry for ${options.method} ${options.url} due to long wait time of ${retryAfter} seconds`, - ); - return false; + ) + return false } // Retry the first time, then give up if (retryCount < 1) { - logger.log(`Retrying after ${retryAfter} seconds`); - return true; + logger.log(`Retrying after ${retryAfter} seconds`) + return true } logger.warn( `Rate limit retry exhausted for ${options.method} ${options.url}`, - ); - return false; + ) + return false }, onSecondaryRateLimit: (retryAfter, options) => { logger.warn( `Secondary rate limit detected for ${options.method} ${options.url}`, - ); + ) // Don't retry on secondary rate limits - return false; + return false }, }, - }; + } // Add GitHub PAT if provided via environment variable if (process.env.GITHUB_TOKEN) { - octokitOptions.auth = process.env.GITHUB_TOKEN; - logger.log('GitHub API authentication configured with provided token'); + octokitOptions.auth = process.env.GITHUB_TOKEN + logger.log('GitHub API authentication configured with provided token') } - this.octokit = new OctokitWithPlugins(octokitOptions); + this.octokit = new OctokitWithPlugins(octokitOptions) } /** @@ -83,27 +83,27 @@ export class GitHubApiService { owner: string, repo: string, ): Promise { - const cacheKey = `release:${owner}/${repo}:latest`; - const cached = this.cache?.data.get(cacheKey); + const cacheKey = `release:${owner}/${repo}:latest` + const cached = this.cache?.data.get(cacheKey) if (cached) { - return cached; + return cached } try { const response = await this.octokit.rest.repos.getLatestRelease({ owner, repo, - }); - const release = response.data as GitHubRelease; + }) + const release = response.data as GitHubRelease - this.cache?.data.set(cacheKey, release); + this.cache?.data.set(cacheKey, release) - return release; + return release } catch (err) { this.logger.debug( `Failed to fetch latest release for ${owner}/${repo}: ${err.message}`, - ); - return undefined; + ) + return undefined } } @@ -119,10 +119,10 @@ export class GitHubApiService { repo: string, ref: string, ): Promise { - const cacheKey = `commit:${owner}/${repo}:${ref}`; - const cached = this.cache?.data.get(cacheKey); + const cacheKey = `commit:${owner}/${repo}:${ref}` + const cached = this.cache?.data.get(cacheKey) if (cached) { - return cached; + return cached } try { @@ -130,17 +130,17 @@ export class GitHubApiService { owner, repo, ref, - }); - const commit = { sha: response.data.sha }; + }) + const commit = { sha: response.data.sha } - this.cache?.data.set(cacheKey, commit); + this.cache?.data.set(cacheKey, commit) - return commit; + return commit } catch (err) { this.logger.debug( `Failed to fetch commit ${ref} for ${owner}/${repo}: ${err.message}`, - ); - return undefined; + ) + return undefined } } @@ -156,10 +156,10 @@ export class GitHubApiService { repo: string, perPage: number = 10, ): Promise { - const cacheKey = `releases:${owner}/${repo}:${perPage}`; - const cached = this.cache?.data.get(cacheKey); + const cacheKey = `releases:${owner}/${repo}:${perPage}` + const cached = this.cache?.data.get(cacheKey) if (cached) { - return cached; + return cached } try { @@ -167,17 +167,17 @@ export class GitHubApiService { owner, repo, per_page: perPage, - }); - const releases = response.data as GitHubRelease[]; + }) + const releases = response.data as GitHubRelease[] - this.cache?.data.set(cacheKey, releases); + this.cache?.data.set(cacheKey, releases) - return releases; + return releases } catch (err) { this.logger.debug( `Failed to fetch releases for ${owner}/${repo}: ${err.message}`, - ); - return undefined; + ) + return undefined } } } diff --git a/apps/server/src/modules/api/internal-api/helpers/internal-api.helper.ts b/apps/server/src/modules/api/internal-api/helpers/internal-api.helper.ts index 43be83c7..272008dd 100644 --- a/apps/server/src/modules/api/internal-api/helpers/internal-api.helper.ts +++ b/apps/server/src/modules/api/internal-api/helpers/internal-api.helper.ts @@ -1,5 +1,5 @@ -import { MaintainerrLogger } from '../../../logging/logs.service'; -import { ExternalApiService } from '../../external-api/external-api.service'; +import { MaintainerrLogger } from '../../../logging/logs.service' +import { ExternalApiService } from '../../external-api/external-api.service' export class InternalApi extends ExternalApiService { constructor( @@ -7,12 +7,12 @@ export class InternalApi extends ExternalApiService { url, apiKey, }: { - url: string; - apiKey: string; + url: string + apiKey: string }, protected readonly logger: MaintainerrLogger, ) { - logger.setContext(InternalApi.name); + logger.setContext(InternalApi.name) super( url, { @@ -21,6 +21,6 @@ export class InternalApi extends ExternalApiService { }, }, logger, - ); + ) } } diff --git a/apps/server/src/modules/api/internal-api/internal-api.controller.ts b/apps/server/src/modules/api/internal-api/internal-api.controller.ts index 8fcddd23..a1ec5f90 100644 --- a/apps/server/src/modules/api/internal-api/internal-api.controller.ts +++ b/apps/server/src/modules/api/internal-api/internal-api.controller.ts @@ -1,5 +1,5 @@ -import { Controller } from '@nestjs/common'; -import { InternalApiService } from './internal-api.service'; +import { Controller } from '@nestjs/common' +import { InternalApiService } from './internal-api.service' @Controller('api/maintainerr') export class InternalApiController { diff --git a/apps/server/src/modules/api/internal-api/internal-api.module.ts b/apps/server/src/modules/api/internal-api/internal-api.module.ts index 243745bf..5a315b3e 100644 --- a/apps/server/src/modules/api/internal-api/internal-api.module.ts +++ b/apps/server/src/modules/api/internal-api/internal-api.module.ts @@ -1,7 +1,7 @@ -import { Module } from '@nestjs/common'; -import { ExternalApiModule } from '../external-api/external-api.module'; -import { InternalApiService } from './internal-api.service'; -import { InternalApiController } from './internal-api.controller'; +import { Module } from '@nestjs/common' +import { ExternalApiModule } from '../external-api/external-api.module' +import { InternalApiService } from './internal-api.service' +import { InternalApiController } from './internal-api.controller' @Module({ imports: [ExternalApiModule], diff --git a/apps/server/src/modules/api/internal-api/internal-api.service.ts b/apps/server/src/modules/api/internal-api/internal-api.service.ts index 53ba31c8..0f6a3fea 100644 --- a/apps/server/src/modules/api/internal-api/internal-api.service.ts +++ b/apps/server/src/modules/api/internal-api/internal-api.service.ts @@ -1,11 +1,11 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { SettingsService } from '../../../modules/settings/settings.service'; -import { MaintainerrLogger } from '../../logging/logs.service'; -import { InternalApi } from './helpers/internal-api.helper'; +import { forwardRef, Inject, Injectable } from '@nestjs/common' +import { SettingsService } from '../../../modules/settings/settings.service' +import { MaintainerrLogger } from '../../logging/logs.service' +import { InternalApi } from './helpers/internal-api.helper' @Injectable() export class InternalApiService { - private api: InternalApi; + private api: InternalApi constructor( @Inject(forwardRef(() => SettingsService)) @@ -14,7 +14,7 @@ export class InternalApiService { ) {} public init() { - const apiPort = process.env.UI_PORT || 6246; + const apiPort = process.env.UI_PORT || 6246 this.api = new InternalApi( { @@ -22,10 +22,10 @@ export class InternalApiService { apiKey: `${this.settings.apikey}`, }, this.logger, - ); + ) } public getApi() { - return this.api; + return this.api } } diff --git a/apps/server/src/modules/api/lib/cache.ts b/apps/server/src/modules/api/lib/cache.ts index b57fdacf..74646ab9 100644 --- a/apps/server/src/modules/api/lib/cache.ts +++ b/apps/server/src/modules/api/lib/cache.ts @@ -1,4 +1,4 @@ -import NodeCache from 'node-cache'; +import NodeCache from 'node-cache' type AvailableCacheIds = | 'tmdb' @@ -8,23 +8,23 @@ type AvailableCacheIds = | 'plexcommunity' | 'tautulli' | 'github' - | 'jellyfin'; + | 'jellyfin' -type CacheType = AvailableCacheIds | 'radarr' | 'sonarr'; +type CacheType = AvailableCacheIds | 'radarr' | 'sonarr' -const DEFAULT_TTL = 300; // 5 min -const DEFAULT_CHECK_PERIOD = 120; // 2 min +const DEFAULT_TTL = 300 // 5 min +const DEFAULT_CHECK_PERIOD = 120 // 2 min type CacheOptions = { - stdTtl?: number; - checkPeriod?: number; -}; + stdTtl?: number + checkPeriod?: number +} export class Cache { - public id: string; - public data: NodeCache; - public name: string; - public type?: CacheType; + public id: string + public data: NodeCache + public name: string + public type?: CacheType constructor( id: string, @@ -32,21 +32,21 @@ export class Cache { type: CacheType, options: CacheOptions = {}, ) { - this.id = id; - this.name = name; - this.type = type; + this.id = id + this.name = name + this.type = type this.data = new NodeCache({ stdTTL: options.stdTtl ?? DEFAULT_TTL, checkperiod: options.checkPeriod ?? DEFAULT_CHECK_PERIOD, - }); + }) } public getStats() { - return this.data.getStats(); + return this.data.getStats() } public flush(): void { - this.data.flushAll(); + this.data.flushAll() } } @@ -70,7 +70,7 @@ class CacheManager { checkPeriod: 60 * 60, // Check every hour }), jellyfin: new Cache('jellyfin', 'Jellyfin API', 'jellyfin'), - }; + } public createCache( id: string, @@ -79,33 +79,33 @@ class CacheManager { options?: CacheOptions, ): Cache { if (this.availableCaches[id]) { - throw new Error(`Cache with id ${id} already exists.`); + throw new Error(`Cache with id ${id} already exists.`) } - return (this.availableCaches[id] = new Cache(id, name, type, options)); + return (this.availableCaches[id] = new Cache(id, name, type, options)) } public getCache(id: string): Cache | undefined { - return this.availableCaches[id]; + return this.availableCaches[id] } public getCachesByType(type: CacheType): Cache[] { return Object.values(this.availableCaches).filter( (cache) => cache.type === type, - ); + ) } public getAllCaches(): Record { - return this.availableCaches; + return this.availableCaches } public flushAll(): void { for (const [, value] of Object.entries(this.getAllCaches())) { - value.flush(); + value.flush() } } } -const cacheManager = new CacheManager(); +const cacheManager = new CacheManager() -export default cacheManager; +export default cacheManager diff --git a/apps/server/src/modules/api/lib/plexApi.ts b/apps/server/src/modules/api/lib/plexApi.ts index c91001c7..e5e18185 100644 --- a/apps/server/src/modules/api/lib/plexApi.ts +++ b/apps/server/src/modules/api/lib/plexApi.ts @@ -1,34 +1,34 @@ -import { Logger } from '@nestjs/common'; -import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'; -import axiosRetry from 'axios-retry'; -import { PlexLibraryResponse } from '../plex-api/interfaces/library.interfaces'; -import cacheManager, { Cache } from './cache'; +import { Logger } from '@nestjs/common' +import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios' +import axiosRetry from 'axios-retry' +import { PlexLibraryResponse } from '../plex-api/interfaces/library.interfaces' +import cacheManager, { Cache } from './cache' type PlexApiOptions = { - hostname: string; - port: number; - https?: boolean; - token: string; - timeout?: number; -}; + hostname: string + port: number + https?: boolean + token: string + timeout?: number +} type RequestOptions = { - uri: string; - extraHeaders?: Record; -}; + uri: string + extraHeaders?: Record +} class PlexApi { - private logger = new Logger(PlexApi.name); - private cache: Cache; - private options: PlexApiOptions; - private axios: AxiosInstance; + private logger = new Logger(PlexApi.name) + private cache: Cache + private options: PlexApiOptions + private axios: AxiosInstance constructor(options: PlexApiOptions) { - this.options = options; - this.cache = cacheManager.getCache('plexguid'); + this.options = options + this.cache = cacheManager.getCache('plexguid') const baseURL = - this.getServerScheme() + options.hostname + ':' + options.port; + this.getServerScheme() + options.hostname + ':' + options.port this.axios = axios.create({ baseURL, @@ -37,17 +37,17 @@ class PlexApi { Accept: 'application/json', 'X-Plex-Token': this.options.token, }, - }); + }) axiosRetry(this.axios, { retries: 3, retryDelay: axiosRetry.exponentialDelay, onRetry: (_, error, requestConfig) => { - const url = this.axios.getUri(requestConfig); + const url = this.axios.getUri(requestConfig) this.logger.debug( `Retrying ${requestConfig.method.toUpperCase()} ${url}: ${error}`, - ); + ) }, - }); + }) } async query( @@ -57,17 +57,17 @@ class PlexApi { if (typeof options === 'string') { options = { uri: options, - }; + } } - const cacheKey = this.serializeCacheKey(options); + const cacheKey = this.serializeCacheKey(options) if (useCache && this.cache.data.has(cacheKey)) { - return this.cache.data.get(cacheKey); + return this.cache.data.get(cacheKey) } else { - const response = await this.getQuery(options); - if (useCache && response) this.cache.data.set(cacheKey, response); - return response; + const response = await this.getQuery(options) + if (useCache && response) this.cache.data.set(cacheKey, response) + return response } } @@ -83,10 +83,10 @@ class PlexApi { useCache: boolean = true, ): Promise { // vars - let result = undefined; - let next = true; - let page = 0; - const size = 120; + let result = undefined + let next = true + let page = 0 + const size = 120 options = { ...options, extraHeaders: { @@ -94,56 +94,56 @@ class PlexApi { 'X-Plex-Container-Start': `${page}`, 'X-Plex-Container-Size': `${size}`, }, - }; + } // loop responses while (next) { - const query: PlexLibraryResponse = await this.query(options, useCache); + const query: PlexLibraryResponse = await this.query(options, useCache) if (result === undefined) { // if first response, replace result - result = query; + result = query } else { // if next response, add to previous result - const items = this.getDataValue(query.MediaContainer); + const items = this.getDataValue(query.MediaContainer) // if response is an array if (items) { - this.appendToData(result.MediaContainer, items as any[]); + this.appendToData(result.MediaContainer, items as any[]) } } // fetch all if more than 120 if (query?.MediaContainer?.totalSize > size * (page + 1)) { - options.extraHeaders['X-Plex-Container-Start'] = `${size * (page + 1)}`; - page++; + options.extraHeaders['X-Plex-Container-Start'] = `${size * (page + 1)}` + page++ } else { - next = false; + next = false } } - return result as unknown as T; + return result as unknown as T } private getQuery(options: RequestOptions) { - return this._request('GET', options); + return this._request('GET', options) } deleteQuery(options: RequestOptions) { - return this._request('DELETE', options); + return this._request('DELETE', options) } postQuery(options: RequestOptions) { - return this._request('POST', options); + return this._request('POST', options) } putQuery(options: RequestOptions) { - return this._request('PUT', options); + return this._request('PUT', options) } private getServerScheme() { if (this.options.https != null) { - return this.options.https ? 'https://' : 'http://'; + return this.options.https ? 'https://' : 'http://' } - return this.options.port === 443 ? 'https://' : 'http://'; + return this.options.port === 443 ? 'https://' : 'http://' } private async _request(method: string, options: RequestOptions) { @@ -151,50 +151,50 @@ class PlexApi { url: options.uri, method, headers: options.extraHeaders, - }; + } try { - const response = await this.axios.request(requestConfig); - return response.data as T; + const response = await this.axios.request(requestConfig) + return response.data as T } catch (err) { - const url = this.axios.getUri(requestConfig); + const url = this.axios.getUri(requestConfig) if (err instanceof AxiosError) { if (err.response?.status === 403) { throw new Error( `${requestConfig.method} ${url} failed: Plex Server denied request due to lack of managed user permissions! In case of a delete request, delete content must be allowed in plex-media-server options.`, { cause: err }, - ); + ) } else if (err.response?.status === 401) { throw new Error( `${requestConfig.method} ${url} failed: Plex Server denied request`, { cause: err }, - ); + ) } else if (err.response?.status) { throw new Error( `${requestConfig.method} ${url} failed with exception: Plex Server didnt respond with a valid 2xx status code, response code: ${err.response?.status}`, { cause: err }, - ); + ) } else { throw new Error( `${requestConfig.method} ${url} failed with exception: ${err}`, { cause: err }, - ); + ) } } else { throw new Error( `${requestConfig.method} ${url} failed with exception: ${err}${err.cause?.code ? `, error code: ${err.cause.code}` : ''}`, { cause: err }, - ); + ) } } } private serializeCacheKey(params: Record) { try { - return `${JSON.stringify(params)}`; + return `${JSON.stringify(params)}` } catch (err) { - return undefined; + return undefined } } @@ -205,16 +205,16 @@ class PlexApi { * @returns {T | undefined} - The first array value found in the object, or undefined if no array value is found. */ private getDataValue(obj: Record): T | undefined { - const keys = Object.keys(obj); + const keys = Object.keys(obj) // Find the first key that has an array value - const arrayKey = keys.find((key) => Array.isArray(obj[key])); + const arrayKey = keys.find((key) => Array.isArray(obj[key])) // If a key with an array value is found, return the corresponding value if (arrayKey !== undefined) { - return obj[arrayKey]; + return obj[arrayKey] } else { - return undefined; // No key with an array value found + return undefined // No key with an array value found } } @@ -229,21 +229,21 @@ class PlexApi { obj: Record, newItem: T[], ): Record { - const keys = Object.keys(obj); + const keys = Object.keys(obj) // Find the first key that has an array value - const arrayKey = keys.find((key) => Array.isArray(obj[key])); + const arrayKey = keys.find((key) => Array.isArray(obj[key])) if (arrayKey !== undefined) { - const arrayValue = obj[arrayKey]; + const arrayValue = obj[arrayKey] // Ensure that the value is an array if (Array.isArray(arrayValue)) { // If it's an array, append the new item - obj[arrayKey] = [...arrayValue, ...newItem] as T; + obj[arrayKey] = [...arrayValue, ...newItem] as T } } - return obj; + return obj } public async getStatus(): Promise { @@ -251,12 +251,12 @@ class PlexApi { const status: { MediaContainer: any } = await this.query( { uri: `/` }, false, - ); - return status?.MediaContainer ? true : false; + ) + return status?.MediaContainer ? true : false } catch (err) { - return false; + return false } } } -export default PlexApi; +export default PlexApi diff --git a/apps/server/src/modules/api/lib/plexCommunityApi.ts b/apps/server/src/modules/api/lib/plexCommunityApi.ts index 904d434d..de3e14cc 100644 --- a/apps/server/src/modules/api/lib/plexCommunityApi.ts +++ b/apps/server/src/modules/api/lib/plexCommunityApi.ts @@ -1,61 +1,61 @@ -import { MaintainerrLogger } from '../../logging/logs.service'; -import { ExternalApiService } from '../external-api/external-api.service'; -import cacheManager from './cache'; +import { MaintainerrLogger } from '../../logging/logs.service' +import { ExternalApiService } from '../external-api/external-api.service' +import cacheManager from './cache' export interface GraphQLQuery { - query: string; + query: string variables: { - uuid: string; - first: number; - after?: string; - skipUserState?: boolean; - }; + uuid: string + first: number + after?: string + skipUserState?: boolean + } } export interface PlexCommunityErrorResponse { errors: { - message: string; - }[]; - data: null; + message: string + }[] + data: null } export interface PlexCommunityWatchListResponse { data: { user: { watchlist: { - nodes: PlexCommunityWatchList[]; + nodes: PlexCommunityWatchList[] pageInfo: { - endCursor: string | null; - hasNextPage: boolean; - }; - }; - }; - }; - errors?: never; + endCursor: string | null + hasNextPage: boolean + } + } + } + } + errors?: never } export interface PlexCommunityWatchList { - id: string; - key: string; - title: string; - type: string; + id: string + key: string + title: string + type: string } export interface PlexCommunityWatchHistory { - id: string; - key: string; - title: string; - type: string; + id: string + key: string + title: string + type: string } export class PlexCommunityApi extends ExternalApiService { - private authToken: string; + private authToken: string constructor( authToken: string, protected readonly logger: MaintainerrLogger, ) { - logger.setContext(PlexCommunityApi.name); + logger.setContext(PlexCommunityApi.name) super('https://community.plex.tv/api', {}, logger, { headers: { 'X-Plex-Token': authToken, @@ -63,15 +63,15 @@ export class PlexCommunityApi extends ExternalApiService { Accept: 'application/json', }, nodeCache: cacheManager.getCache('plexcommunity').data, - }); - this.authToken = authToken; + }) + this.authToken = authToken } public async query( query: GraphQLQuery, ): Promise { - return await this.postRolling('/', JSON.stringify(query)); + return await this.postRolling('/', JSON.stringify(query)) } } -export default PlexCommunityApi; +export default PlexCommunityApi diff --git a/apps/server/src/modules/api/lib/plextvApi.ts b/apps/server/src/modules/api/lib/plextvApi.ts index 86dd9231..d45778b7 100644 --- a/apps/server/src/modules/api/lib/plextvApi.ts +++ b/apps/server/src/modules/api/lib/plextvApi.ts @@ -1,139 +1,139 @@ -import xml2js, { parseStringPromise } from 'xml2js'; -import { PlexDevice } from '../../api/plex-api/interfaces/server.interface'; -import { MaintainerrLogger } from '../../logging/logs.service'; -import { ExternalApiService } from '../external-api/external-api.service'; -import cacheManager from './cache'; +import xml2js, { parseStringPromise } from 'xml2js' +import { PlexDevice } from '../../api/plex-api/interfaces/server.interface' +import { MaintainerrLogger } from '../../logging/logs.service' +import { ExternalApiService } from '../external-api/external-api.service' +import cacheManager from './cache' interface PlexAccountResponse { - user: PlexUser; + user: PlexUser } export interface PlexUser { - id: number; - uuid: string; - email: string; - joined_at: string; - username: string; - title: string; - thumb: string; - hasPassword: boolean; - authToken: string; + id: number + uuid: string + email: string + joined_at: string + username: string + title: string + thumb: string + hasPassword: boolean + authToken: string subscription: { - active: boolean; - status: string; - plan: string; - features: string[]; - }; + active: boolean + status: string + plan: string + features: string[] + } roles: { - roles: string[]; - }; - entitlements: string[]; + roles: string[] + } + entitlements: string[] } interface ConnectionResponse { $: { - protocol: string; - address: string; - port: string; - uri: string; - local: string; - }; + protocol: string + address: string + port: string + uri: string + local: string + } } interface DeviceResponse { $: { - name: string; - product: string; - productVersion: string; - platform: string; - platformVersion: string; - device: string; - clientIdentifier: string; - createdAt: string; - lastSeenAt: string; - provides: string; - owned: string; - accessToken?: string; - publicAddress?: string; - httpsRequired?: string; - synced?: string; - relay?: string; - dnsRebindingProtection?: string; - natLoopbackSupported?: string; - publicAddressMatches?: string; - presence?: string; - ownerID?: string; - home?: string; - sourceTitle?: string; - }; - Connection: ConnectionResponse[]; + name: string + product: string + productVersion: string + platform: string + platformVersion: string + device: string + clientIdentifier: string + createdAt: string + lastSeenAt: string + provides: string + owned: string + accessToken?: string + publicAddress?: string + httpsRequired?: string + synced?: string + relay?: string + dnsRebindingProtection?: string + natLoopbackSupported?: string + publicAddressMatches?: string + presence?: string + ownerID?: string + home?: string + sourceTitle?: string + } + Connection: ConnectionResponse[] } interface ServerResponse { $: { - id: string; - serverId: string; - machineIdentifier: string; - name: string; - lastSeenAt: string; - numLibraries: string; - owned: string; - }; + id: string + serverId: string + machineIdentifier: string + name: string + lastSeenAt: string + numLibraries: string + owned: string + } } interface UsersResponse { MediaContainer: { User: { $: { - id: string; - title: string; - username: string; - email: string; - thumb: string; - }; - Server: ServerResponse[]; - }[]; - }; + id: string + title: string + username: string + email: string + thumb: string + } + Server: ServerResponse[] + }[] + } } interface WatchlistResponse { MediaContainer: { - totalSize: number; + totalSize: number Metadata?: { - ratingKey: string; - }[]; - }; + ratingKey: string + }[] + } } interface MetadataResponse { MediaContainer: { Metadata: { - ratingKey: string; - type: 'movie' | 'show'; - title: string; + ratingKey: string + type: 'movie' | 'show' + title: string Guid: { - id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`; - }[]; - }[]; - }; + id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}` + }[] + }[] + } } export interface PlexWatchlistItem { - ratingKey: string; - tmdbId: number; - tvdbId?: number; - type: 'movie' | 'show'; - title: string; + ratingKey: string + tmdbId: number + tvdbId?: number + type: 'movie' | 'show' + title: string } export class PlexTvApi extends ExternalApiService { - private authToken: string; + private authToken: string constructor( authToken: string, protected readonly logger: MaintainerrLogger, ) { - logger.setContext(PlexTvApi.name); + logger.setContext(PlexTvApi.name) super('https://plex.tv', {}, logger, { headers: { 'X-Plex-Token': authToken, @@ -141,22 +141,20 @@ export class PlexTvApi extends ExternalApiService { Accept: 'application/json', }, nodeCache: cacheManager.getCache('plextv').data, - }); - this.authToken = authToken; + }) + this.authToken = authToken } public async getUser(): Promise { try { - const account = await this.get( - '/users/account.json', - ); + const account = await this.get('/users/account.json') - return account.user; + return account.user } catch (e) { this.logger.error( `Something went wrong while getting the account from plex.tv: ${e.message}`, - ); - throw new Error('Invalid auth token'); + ) + throw new Error('Invalid auth token') } } @@ -164,20 +162,20 @@ export class PlexTvApi extends ExternalApiService { const response = await this.get('/api/users', { transformResponse: [], responseType: 'text', - }); + }) - const parsedXml = (await parseStringPromise(response)) as UsersResponse; - return parsedXml; + const parsedXml = (await parseStringPromise(response)) as UsersResponse + return parsedXml } public async getWatchlist({ offset = 0, size = 20, }: { offset?: number; size?: number } = {}): Promise<{ - offset: number; - size: number; - totalSize: number; - items: PlexWatchlistItem[]; + offset: number + size: number + totalSize: number + items: PlexWatchlistItem[] }> { try { const response = await this.get( @@ -189,7 +187,7 @@ export class PlexTvApi extends ExternalApiService { }, baseURL: 'https://metadata.provider.plex.tv', }, - ); + ) const watchlistDetails = await Promise.all( (response.MediaContainer.Metadata ?? []).map(async (watchlistItem) => { @@ -198,16 +196,16 @@ export class PlexTvApi extends ExternalApiService { { baseURL: 'https://metadata.provider.plex.tv', }, - ); + ) - const metadata = detailedResponse.MediaContainer.Metadata[0]; + const metadata = detailedResponse.MediaContainer.Metadata[0] const tmdbString = metadata.Guid.find((guid) => guid.id.startsWith('tmdb'), - ); + ) const tvdbString = metadata.Guid.find((guid) => guid.id.startsWith('tvdb'), - ); + ) return { ratingKey: metadata.ratingKey, @@ -219,26 +217,26 @@ export class PlexTvApi extends ExternalApiService { : undefined, title: metadata.title, type: metadata.type, - }; + } }), - ); + ) - const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); + const filteredList = watchlistDetails.filter((detail) => detail.tmdbId) return { offset, size, totalSize: response.MediaContainer.totalSize, items: filteredList, - }; + } } catch (e) { - this.logger.error(`Failed to retrieve watchlist items: ${e.message}`); + this.logger.error(`Failed to retrieve watchlist items: ${e.message}`) return { offset, size, totalSize: 0, items: [], - }; + } } } @@ -247,10 +245,10 @@ export class PlexTvApi extends ExternalApiService { const devicesResp = await this.get('/api/resources?includeHttps=1', { transformResponse: [], responseType: 'text', - }); + }) const parsedXml = await xml2js.parseStringPromise( devicesResp as DeviceResponse, - ); + ) return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({ name: pxml.$.name, product: pxml.$.product, @@ -285,15 +283,13 @@ export class PlexTvApi extends ExternalApiService { uri: conn.$.uri, local: conn.$.local == '1' ? true : false, })), - })); + })) } catch (e) { - this.logger.error( - 'Something went wrong getting the devices from plex.tv', - ); - this.logger.debug(e); - return []; + this.logger.error('Something went wrong getting the devices from plex.tv') + this.logger.debug(e) + return [] } } } -export default PlexTvApi; +export default PlexTvApi diff --git a/apps/server/src/modules/api/media-server/guards/index.ts b/apps/server/src/modules/api/media-server/guards/index.ts index e41b3698..806114f5 100644 --- a/apps/server/src/modules/api/media-server/guards/index.ts +++ b/apps/server/src/modules/api/media-server/guards/index.ts @@ -1 +1 @@ -export { MediaServerSetupGuard } from './media-server-setup.guard'; +export { MediaServerSetupGuard } from './media-server-setup.guard' diff --git a/apps/server/src/modules/api/media-server/guards/media-server-setup.guard.spec.ts b/apps/server/src/modules/api/media-server/guards/media-server-setup.guard.spec.ts index e0cb929d..548a4221 100644 --- a/apps/server/src/modules/api/media-server/guards/media-server-setup.guard.spec.ts +++ b/apps/server/src/modules/api/media-server/guards/media-server-setup.guard.spec.ts @@ -1,31 +1,30 @@ -import { SettingsService } from '../../../settings/settings.service'; -import { MediaServerSetupGuard } from './media-server-setup.guard'; +import { SettingsService } from '../../../settings/settings.service' +import { MediaServerSetupGuard } from './media-server-setup.guard' describe('MediaServerSetupGuard', () => { const settingsService = { testSetup: jest.fn(), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked - let guard: MediaServerSetupGuard; + let guard: MediaServerSetupGuard beforeEach(() => { - jest.clearAllMocks(); - guard = new MediaServerSetupGuard(settingsService); - }); + jest.clearAllMocks() + guard = new MediaServerSetupGuard(settingsService) + }) it('returns false and logs when media server setup test throws', async () => { - settingsService.testSetup.mockRejectedValue(new Error('connection failed')); + settingsService.testSetup.mockRejectedValue(new Error('connection failed')) - const logger = (guard as unknown as { logger: { error: jest.Mock } }) - .logger; + const logger = (guard as unknown as { logger: { error: jest.Mock } }).logger const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => { - return undefined; - }); + return undefined + }) - await expect(guard.canActivate()).resolves.toBe(false); + await expect(guard.canActivate()).resolves.toBe(false) expect(errorSpy).toHaveBeenCalledWith( 'Media server setup check failed', expect.any(Error), - ); - }); -}); + ) + }) +}) diff --git a/apps/server/src/modules/api/media-server/guards/media-server-setup.guard.ts b/apps/server/src/modules/api/media-server/guards/media-server-setup.guard.ts index c94db960..4d483d6a 100644 --- a/apps/server/src/modules/api/media-server/guards/media-server-setup.guard.ts +++ b/apps/server/src/modules/api/media-server/guards/media-server-setup.guard.ts @@ -1,5 +1,5 @@ -import { CanActivate, Injectable, Logger } from '@nestjs/common'; -import { SettingsService } from '../../../settings/settings.service'; +import { CanActivate, Injectable, Logger } from '@nestjs/common' +import { SettingsService } from '../../../settings/settings.service' /** * Guard that checks if a media server (Plex or Jellyfin) is configured. @@ -11,16 +11,16 @@ import { SettingsService } from '../../../settings/settings.service'; */ @Injectable() export class MediaServerSetupGuard implements CanActivate { - private readonly logger = new Logger(MediaServerSetupGuard.name); + private readonly logger = new Logger(MediaServerSetupGuard.name) constructor(private readonly settingsService: SettingsService) {} async canActivate(): Promise { try { - return await this.settingsService.testSetup(); + return await this.settingsService.testSetup() } catch (error) { - this.logger.error('Media server setup check failed', error); - return false; + this.logger.error('Media server setup check failed', error) + return false } } } diff --git a/apps/server/src/modules/api/media-server/index.ts b/apps/server/src/modules/api/media-server/index.ts index b27c4a63..d6fb7646 100644 --- a/apps/server/src/modules/api/media-server/index.ts +++ b/apps/server/src/modules/api/media-server/index.ts @@ -1,6 +1,6 @@ -export * from './media-server.module'; -export * from './media-server.interface'; -export * from './media-server.factory'; -export * from './media-server.constants'; -export * from './plex/plex-adapter.service'; -export * from './plex/plex.mapper'; +export * from './media-server.module' +export * from './media-server.interface' +export * from './media-server.factory' +export * from './media-server.constants' +export * from './plex/plex-adapter.service' +export * from './plex/plex.mapper' diff --git a/apps/server/src/modules/api/media-server/jellyfin/index.ts b/apps/server/src/modules/api/media-server/jellyfin/index.ts index e7d020ba..0f9043ec 100644 --- a/apps/server/src/modules/api/media-server/jellyfin/index.ts +++ b/apps/server/src/modules/api/media-server/jellyfin/index.ts @@ -1,5 +1,5 @@ -export * from './jellyfin.module'; -export * from './jellyfin-adapter.service'; -export * from './jellyfin.mapper'; -export * from './jellyfin.types'; -export * from './jellyfin.constants'; +export * from './jellyfin.module' +export * from './jellyfin-adapter.service' +export * from './jellyfin.mapper' +export * from './jellyfin.types' +export * from './jellyfin.constants' diff --git a/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.spec.ts b/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.spec.ts index d9e11005..404abe38 100644 --- a/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.spec.ts +++ b/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.spec.ts @@ -1,7 +1,7 @@ -import { MediaServerFeature, MediaServerType } from '@maintainerr/contracts'; -import { Mocked, TestBed } from '@suites/unit'; -import { SettingsService } from '../../../settings/settings.service'; -import { JellyfinAdapterService } from './jellyfin-adapter.service'; +import { MediaServerFeature, MediaServerType } from '@maintainerr/contracts' +import { Mocked, TestBed } from '@suites/unit' +import { SettingsService } from '../../../settings/settings.service' +import { JellyfinAdapterService } from './jellyfin-adapter.service' const jellyfinApiMocks = { getPublicSystemInfo: jest.fn(), @@ -9,7 +9,7 @@ const jellyfinApiMocks = { getUserById: jest.fn(), getConfiguration: jest.fn(), getItems: jest.fn(), -}; +} const jellyfinCacheMocks = { flush: jest.fn(), @@ -21,7 +21,7 @@ const jellyfinCacheMocks = { flushAll: jest.fn(), keys: jest.fn(), }, -}; +} // Mock the @jellyfin/sdk module and its generated client jest.mock('@jellyfin/sdk', () => ({ @@ -32,7 +32,7 @@ jest.mock('@jellyfin/sdk', () => ({ configuration: {}, }), })), -})); +})) jest.mock('@jellyfin/sdk/lib/generated-client/models', () => ({ __esModule: true, @@ -65,7 +65,7 @@ jest.mock('@jellyfin/sdk/lib/generated-client/models', () => ({ Ascending: 'Ascending', Descending: 'Descending', }, -})); +})) jest.mock('@jellyfin/sdk/lib/utils/api/index.js', () => ({ __esModule: true, @@ -89,7 +89,7 @@ jest.mock('@jellyfin/sdk/lib/utils/api/index.js', () => ({ getSearchApi: jest.fn(), getPlaylistsApi: jest.fn(), getUserViewsApi: jest.fn(), -})); +})) // Mock the cacheManager module jest.mock('../../lib/cache', () => ({ @@ -108,20 +108,20 @@ jest.mock('../../lib/cache', () => ({ }, })), }, -})); +})) describe('JellyfinAdapterService', () => { - let service: JellyfinAdapterService; - let settingsService: Mocked; + let service: JellyfinAdapterService + let settingsService: Mocked const mockSettings = { jellyfin_url: 'http://jellyfin.test:8096', jellyfin_api_key: 'test-api-key', clientId: 'test-client-id', - }; + } beforeEach(async () => { - jest.clearAllMocks(); + jest.clearAllMocks() jellyfinApiMocks.getPublicSystemInfo.mockResolvedValue({ data: { @@ -130,86 +130,86 @@ describe('JellyfinAdapterService', () => { Version: '10.11.0', OperatingSystem: 'Linux', }, - }); - jellyfinApiMocks.getUsers.mockResolvedValue({ data: [] }); - jellyfinApiMocks.getUserById.mockResolvedValue({ data: undefined }); + }) + jellyfinApiMocks.getUsers.mockResolvedValue({ data: [] }) + jellyfinApiMocks.getUserById.mockResolvedValue({ data: undefined }) jellyfinApiMocks.getConfiguration.mockResolvedValue({ data: { MaxResumePct: 90 }, - }); - jellyfinApiMocks.getItems.mockResolvedValue({ data: { Items: [] } }); - jellyfinCacheMocks.data.has.mockReturnValue(false); - jellyfinCacheMocks.data.get.mockReturnValue(undefined); - jellyfinCacheMocks.data.keys.mockReturnValue([]); + }) + jellyfinApiMocks.getItems.mockResolvedValue({ data: { Items: [] } }) + jellyfinCacheMocks.data.has.mockReturnValue(false) + jellyfinCacheMocks.data.get.mockReturnValue(undefined) + jellyfinCacheMocks.data.keys.mockReturnValue([]) const { unit, unitRef } = await TestBed.solitary( JellyfinAdapterService, - ).compile(); + ).compile() - service = unit; - settingsService = unitRef.get(SettingsService); - }); + service = unit + settingsService = unitRef.get(SettingsService) + }) describe('lifecycle', () => { it('should not be setup initially', () => { - expect(service.isSetup()).toBe(false); - }); + expect(service.isSetup()).toBe(false) + }) it('should return JELLYFIN as server type', () => { - expect(service.getServerType()).toBe(MediaServerType.JELLYFIN); - }); + expect(service.getServerType()).toBe(MediaServerType.JELLYFIN) + }) it('should initialize successfully with valid settings', async () => { settingsService.getSettings.mockResolvedValue( mockSettings as unknown as Awaited< ReturnType >, - ); - await service.initialize(); - expect(service.isSetup()).toBe(true); - }); + ) + await service.initialize() + expect(service.isSetup()).toBe(true) + }) it('should throw error when settings are missing', async () => { settingsService.getSettings.mockResolvedValue( null as unknown as Awaited>, - ); + ) await expect(service.initialize()).rejects.toThrow( 'Settings not available', - ); - }); + ) + }) it('should throw error when Jellyfin URL is missing', async () => { settingsService.getSettings.mockResolvedValue({ ...mockSettings, jellyfin_url: undefined, - } as unknown as Awaited>); + } as unknown as Awaited>) await expect(service.initialize()).rejects.toThrow( 'Jellyfin settings not configured', - ); - }); + ) + }) it('should throw error when API key is missing', async () => { settingsService.getSettings.mockResolvedValue({ ...mockSettings, jellyfin_api_key: undefined, - } as unknown as Awaited>); + } as unknown as Awaited>) await expect(service.initialize()).rejects.toThrow( 'Jellyfin settings not configured', - ); - }); + ) + }) it('should uninitialize correctly', async () => { settingsService.getSettings.mockResolvedValue( mockSettings as unknown as Awaited< ReturnType >, - ); - await service.initialize(); - expect(service.isSetup()).toBe(true); + ) + await service.initialize() + expect(service.isSetup()).toBe(true) - service.uninitialize(); - expect(service.isSetup()).toBe(false); - }); - }); + service.uninitialize() + expect(service.isSetup()).toBe(false) + }) + }) describe('feature detection', () => { it.each([ @@ -219,19 +219,19 @@ describe('JellyfinAdapterService', () => { [MediaServerFeature.WATCHLIST, false], [MediaServerFeature.CENTRAL_WATCH_HISTORY, false], ])('supportsFeature(%s) is %s', (feature, expected) => { - expect(service.supportsFeature(feature)).toBe(expected); - }); - }); + expect(service.supportsFeature(feature)).toBe(expected) + }) + }) describe('cache management', () => { it('should not throw when resetting cache with itemId', () => { - expect(() => service.resetMetadataCache('item123')).not.toThrow(); - }); + expect(() => service.resetMetadataCache('item123')).not.toThrow() + }) it('should not throw when resetting all cache', () => { - expect(() => service.resetMetadataCache()).not.toThrow(); - }); - }); + expect(() => service.resetMetadataCache()).not.toThrow() + }) + }) describe('uninitialized state', () => { it.each([ @@ -245,15 +245,15 @@ describe('JellyfinAdapterService', () => { ] as [string, unknown, () => Promise][])( '%s returns %j when not initialized', async (_method, expected, call) => { - const result = await call(); + const result = await call() if (expected === undefined) { - expect(result).toBeUndefined(); + expect(result).toBeUndefined() } else { - expect(result).toEqual(expected); + expect(result).toEqual(expected) } }, - ); - }); + ) + }) describe('getWatchHistory', () => { beforeEach(async () => { @@ -261,9 +261,9 @@ describe('JellyfinAdapterService', () => { mockSettings as unknown as Awaited< ReturnType >, - ); - await service.initialize(); - }); + ) + await service.initialize() + }) it('should apply Jellyfin MaxResumePct when filtering completed views', async () => { jellyfinApiMocks.getUsers.mockResolvedValue({ @@ -271,10 +271,10 @@ describe('JellyfinAdapterService', () => { { Id: 'user-1', Name: 'Alice' }, { Id: 'user-2', Name: 'Bob' }, ], - }); + }) jellyfinApiMocks.getConfiguration.mockResolvedValue({ data: { MaxResumePct: 95 }, - }); + }) jellyfinApiMocks.getItems.mockImplementation( ({ userId }: { userId: string }) => Promise.resolve({ @@ -297,9 +297,9 @@ describe('JellyfinAdapterService', () => { ], }, }), - ); + ) - const history = await service.getWatchHistory('item123'); + const history = await service.getWatchHistory('item123') expect(history).toEqual([ { @@ -308,21 +308,21 @@ describe('JellyfinAdapterService', () => { watchedAt: new Date('2024-06-02T00:00:00.000Z'), progress: 95, }, - ]); + ]) expect(jellyfinCacheMocks.data.set).toHaveBeenCalledWith( 'jellyfin:watch:95:item123', history, 300000, - ); - }); + ) + }) it('should fall back to Jellyfin played state when threshold cannot be loaded', async () => { jellyfinApiMocks.getUsers.mockResolvedValue({ data: [{ Id: 'user-1', Name: 'Alice' }], - }); + }) jellyfinApiMocks.getConfiguration.mockRejectedValue( new Error('Configuration unavailable'), - ); + ) jellyfinApiMocks.getItems.mockResolvedValue({ data: { Items: [ @@ -335,20 +335,20 @@ describe('JellyfinAdapterService', () => { }, ], }, - }); + }) - const history = await service.getWatchHistory('item123'); + const history = await service.getWatchHistory('item123') - expect(history).toEqual([]); - }); + expect(history).toEqual([]) + }) it('should keep Jellyfin played items when no percentage is available', async () => { jellyfinApiMocks.getUsers.mockResolvedValue({ data: [{ Id: 'user-1', Name: 'Alice' }], - }); + }) jellyfinApiMocks.getConfiguration.mockResolvedValue({ data: { MaxResumePct: 95 }, - }); + }) jellyfinApiMocks.getItems.mockResolvedValue({ data: { Items: [ @@ -360,9 +360,9 @@ describe('JellyfinAdapterService', () => { }, ], }, - }); + }) - const history = await service.getWatchHistory('item123'); + const history = await service.getWatchHistory('item123') expect(history).toEqual([ { @@ -371,9 +371,9 @@ describe('JellyfinAdapterService', () => { watchedAt: new Date('2024-06-03T00:00:00.000Z'), progress: 100, }, - ]); - }); - }); + ]) + }) + }) describe('resetMetadataCache', () => { it('should remove threshold-specific watch history entries for one item', () => { @@ -381,19 +381,19 @@ describe('JellyfinAdapterService', () => { 'jellyfin:watch:90:item123', 'jellyfin:watch:95:item123', 'jellyfin:watch:90:item999', - ]); + ]) - service.resetMetadataCache('item123'); + service.resetMetadataCache('item123') expect(jellyfinCacheMocks.data.del).toHaveBeenCalledWith( 'jellyfin:watch:90:item123', - ); + ) expect(jellyfinCacheMocks.data.del).toHaveBeenCalledWith( 'jellyfin:watch:95:item123', - ); + ) expect(jellyfinCacheMocks.data.del).not.toHaveBeenCalledWith( 'jellyfin:watch:90:item999', - ); - }); - }); -}); + ) + }) + }) +}) diff --git a/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts b/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts index d0ba80f9..c42489d8 100644 --- a/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts +++ b/apps/server/src/modules/api/media-server/jellyfin/jellyfin-adapter.service.ts @@ -1,10 +1,10 @@ -import { Jellyfin, type Api } from '@jellyfin/sdk'; +import { Jellyfin, type Api } from '@jellyfin/sdk' import { BaseItemKind, ItemFields, ItemSortBy, SortOrder, -} from '@jellyfin/sdk/lib/generated-client/models'; +} from '@jellyfin/sdk/lib/generated-client/models' import { getCollectionApi, getConfigurationApi, @@ -16,7 +16,7 @@ import { getSystemApi, getTvShowsApi, getUserApi, -} from '@jellyfin/sdk/lib/utils/api/index.js'; +} from '@jellyfin/sdk/lib/utils/api/index.js' import { MediaServerFeature, MediaServerType, @@ -35,34 +35,34 @@ import { type RecentlyAddedOptions, type UpdateCollectionParams, type WatchRecord, -} from '@maintainerr/contracts'; -import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; -import { SettingsService } from '../../../settings/settings.service'; -import cacheManager, { type Cache } from '../../lib/cache'; -import { supportsFeature } from '../media-server.constants'; -import type { IMediaServerService } from '../media-server.interface'; +} from '@maintainerr/contracts' +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common' +import { SettingsService } from '../../../settings/settings.service' +import cacheManager, { type Cache } from '../../lib/cache' +import { supportsFeature } from '../media-server.constants' +import type { IMediaServerService } from '../media-server.interface' import { JELLYFIN_BATCH_SIZE, JELLYFIN_CACHE_KEYS, JELLYFIN_CACHE_TTL, JELLYFIN_CLIENT_INFO, JELLYFIN_DEVICE_INFO, -} from './jellyfin.constants'; -import { JellyfinMapper } from './jellyfin.mapper'; +} from './jellyfin.constants' +import { JellyfinMapper } from './jellyfin.mapper' const toJellyfinSortBy = (sort?: MediaLibrarySortField): ItemSortBy => { switch (sort) { case 'airDate': - return 'PremiereDate' as ItemSortBy; + return 'PremiereDate' as ItemSortBy case 'rating': - return 'CommunityRating' as ItemSortBy; + return 'CommunityRating' as ItemSortBy case 'watchCount': - return 'PlayCount' as ItemSortBy; + return 'PlayCount' as ItemSortBy case 'title': default: - return ItemSortBy.SortName; + return ItemSortBy.SortName } -}; +} /** * Jellyfin media server service implementation. @@ -78,16 +78,16 @@ const toJellyfinSortBy = (sort?: MediaLibrarySortField): ItemSortBy => { */ @Injectable() export class JellyfinAdapterService implements IMediaServerService { - private api: Api | undefined; - private initialized = false; - private readonly logger = new Logger(JellyfinAdapterService.name); - private readonly cache: Cache; + private api: Api | undefined + private initialized = false + private readonly logger = new Logger(JellyfinAdapterService.name) + private readonly cache: Cache constructor( @Inject(forwardRef(() => SettingsService)) private readonly settingsService: SettingsService, ) { - this.cache = cacheManager.getCache('jellyfin'); + this.cache = cacheManager.getCache('jellyfin') } /** @@ -107,40 +107,40 @@ export class JellyfinAdapterService implements IMediaServerService { name: JELLYFIN_DEVICE_INFO.name, id: `${JELLYFIN_DEVICE_INFO.idPrefix}-${deviceSuffix}`, }, - }); + }) - return jellyfin.createApi(url, apiKey); + return jellyfin.createApi(url, apiKey) } /** * Verify connection to a Jellyfin server and return server info. */ private async verifyConnection(api: Api): Promise<{ - success: boolean; - serverName?: string; - version?: string; - error?: string; - users?: Array<{ id: string; name: string }>; + success: boolean + serverName?: string + version?: string + error?: string + users?: Array<{ id: string; name: string }> }> { try { // First get public system info to check if server is reachable - const systemInfo = await getSystemApi(api).getPublicSystemInfo(); + const systemInfo = await getSystemApi(api).getPublicSystemInfo() // Then verify API key by calling an authenticated endpoint - let users: Array<{ id: string; name: string }> = []; + let users: Array<{ id: string; name: string }> = [] try { - const usersResponse = await getUserApi(api).getUsers(); + const usersResponse = await getUserApi(api).getUsers() users = (usersResponse.data || []) .filter((u) => u.Policy?.IsAdministrator) .map((u) => ({ id: u.Id || '', name: u.Name || '', - })); + })) } catch (authError) { return { success: false, error: 'Invalid API key - authentication failed', - }; + } } return { @@ -148,53 +148,53 @@ export class JellyfinAdapterService implements IMediaServerService { serverName: systemInfo.data.ServerName || undefined, version: systemInfo.data.Version || undefined, users, - }; + } } catch (e) { - const error = e instanceof Error ? e.message : 'Connection failed'; - return { success: false, error }; + const error = e instanceof Error ? e.message : 'Connection failed' + return { success: false, error } } } async initialize(): Promise { - const settings = await this.settingsService.getSettings(); + const settings = await this.settingsService.getSettings() if (!settings || !('jellyfin_url' in settings)) { - throw new Error('Settings not available'); + throw new Error('Settings not available') } if (!settings.jellyfin_url || !settings.jellyfin_api_key) { - throw new Error('Jellyfin settings not configured'); + throw new Error('Jellyfin settings not configured') } const api = this.createApiClient( settings.jellyfin_url, settings.jellyfin_api_key, settings.clientId || 'default', - ); + ) - const result = await this.verifyConnection(api); + const result = await this.verifyConnection(api) if (!result.success) { - this.initialized = false; - throw new Error(`Failed to connect to Jellyfin: ${result.error}`); + this.initialized = false + throw new Error(`Failed to connect to Jellyfin: ${result.error}`) } - this.api = api; - this.initialized = true; + this.api = api + this.initialized = true this.logger.log( `Jellyfin connection established: ${result.serverName} (${result.version})`, - ); + ) } uninitialize(): void { - this.initialized = false; - this.api = undefined; + this.initialized = false + this.api = undefined // Clear the cache when uninitializing - this.cache.flush(); + this.cache.flush() } isSetup(): boolean { - return this.initialized && this.api !== undefined; + return this.initialized && this.api !== undefined } /** @@ -206,138 +206,136 @@ export class JellyfinAdapterService implements IMediaServerService { url: string, apiKey: string, ): Promise<{ - success: boolean; - serverName?: string; - version?: string; - error?: string; - users?: Array<{ id: string; name: string }>; + success: boolean + serverName?: string + version?: string + error?: string + users?: Array<{ id: string; name: string }> }> { - const api = this.createApiClient(url, apiKey, 'test'); - const result = await this.verifyConnection(api); + const api = this.createApiClient(url, apiKey, 'test') + const result = await this.verifyConnection(api) if (result.success) { this.logger.log( `Jellyfin connection test successful: ${result.serverName} (${result.version})`, - ); + ) } else { - this.logger.error(`Jellyfin connection test failed: ${result.error}`); + this.logger.error(`Jellyfin connection test failed: ${result.error}`) } - return result; + return result } getServerType(): MediaServerType { - return MediaServerType.JELLYFIN; + return MediaServerType.JELLYFIN } supportsFeature(feature: MediaServerFeature): boolean { - return supportsFeature(MediaServerType.JELLYFIN, feature); + return supportsFeature(MediaServerType.JELLYFIN, feature) } async getStatus(): Promise { - if (!this.api) return undefined; + if (!this.api) return undefined try { if (this.cache.data.has(JELLYFIN_CACHE_KEYS.STATUS)) { return this.cache.data.get( JELLYFIN_CACHE_KEYS.STATUS, - ); + ) } - const response = await getSystemApi(this.api).getPublicSystemInfo(); - const settings = await this.settingsService.getSettings(); + const response = await getSystemApi(this.api).getPublicSystemInfo() + const settings = await this.settingsService.getSettings() // Extract jellyfin_url if settings is a valid Settings object (not an error response) const jellyfinUrl = settings && 'jellyfin_url' in settings ? settings.jellyfin_url - : undefined; + : undefined const status = JellyfinMapper.toMediaServerStatus( response.data.Id || '', response.data.Version || '', response.data.ServerName, response.data.OperatingSystem, jellyfinUrl, - ); + ) this.cache.data.set( JELLYFIN_CACHE_KEYS.STATUS, status, JELLYFIN_CACHE_TTL.STATUS, - ); + ) - return status; + return status } catch (error) { - this.logger.error('Failed to get Jellyfin status', error); - return undefined; + this.logger.error('Failed to get Jellyfin status', error) + return undefined } } async getUsers(): Promise { - if (!this.api) return []; + if (!this.api) return [] try { if (this.cache.data.has(JELLYFIN_CACHE_KEYS.USERS)) { - return ( - this.cache.data.get(JELLYFIN_CACHE_KEYS.USERS) || [] - ); + return this.cache.data.get(JELLYFIN_CACHE_KEYS.USERS) || [] } - const response = await getUserApi(this.api).getUsers(); - const users = (response.data || []).map(JellyfinMapper.toMediaUser); + const response = await getUserApi(this.api).getUsers() + const users = (response.data || []).map(JellyfinMapper.toMediaUser) this.cache.data.set( JELLYFIN_CACHE_KEYS.USERS, users, JELLYFIN_CACHE_TTL.USERS, - ); + ) - return users; + return users } catch (error) { - this.logger.error('Failed to get Jellyfin users', error); - return []; + this.logger.error('Failed to get Jellyfin users', error) + return [] } } private async getPlayedCompletionThreshold(): Promise { - if (!this.api) return undefined; + if (!this.api) return undefined if (this.cache.data.has(JELLYFIN_CACHE_KEYS.PLAYED_THRESHOLD)) { - return this.cache.data.get(JELLYFIN_CACHE_KEYS.PLAYED_THRESHOLD); + return this.cache.data.get(JELLYFIN_CACHE_KEYS.PLAYED_THRESHOLD) } try { - const response = await getConfigurationApi(this.api).getConfiguration(); - const threshold = response.data.MaxResumePct; + const response = await getConfigurationApi(this.api).getConfiguration() + const threshold = response.data.MaxResumePct if (typeof threshold !== 'number' || Number.isNaN(threshold)) { - return undefined; + return undefined } - const normalizedThreshold = Math.min(100, Math.max(0, threshold)); + const normalizedThreshold = Math.min(100, Math.max(0, threshold)) this.cache.data.set( JELLYFIN_CACHE_KEYS.PLAYED_THRESHOLD, normalizedThreshold, JELLYFIN_CACHE_TTL.PLAYED_THRESHOLD, - ); + ) - return normalizedThreshold; + return normalizedThreshold } catch (error) { - this.logger.warn('Failed to get Jellyfin MaxResumePct', error); - return undefined; + this.logger.warn('Failed to get Jellyfin MaxResumePct', error) + return undefined } } private isCompletedWatch( userData: | { - Played?: boolean | null; - PlayedPercentage?: number | null; + Played?: boolean | null + PlayedPercentage?: number | null } | undefined, playedCompletionThreshold?: number, ): boolean { - if (!userData) return false; + if (!userData) return false if ( playedCompletionThreshold !== undefined && @@ -346,30 +344,30 @@ export class JellyfinAdapterService implements IMediaServerService { return ( userData.Played === true || userData.PlayedPercentage >= playedCompletionThreshold - ); + ) } - return userData.Played === true; + return userData.Played === true } async getUser(id: string): Promise { - if (!this.api) return undefined; + if (!this.api) return undefined try { - const response = await getUserApi(this.api).getUserById({ userId: id }); + const response = await getUserApi(this.api).getUserById({ userId: id }) return response.data ? JellyfinMapper.toMediaUser(response.data) - : undefined; + : undefined } catch (error) { - this.logger.warn(`Failed to get Jellyfin user ${id}`, error); - return undefined; + this.logger.warn(`Failed to get Jellyfin user ${id}`, error) + return undefined } } async getLibraries(): Promise { if (!this.api) { - this.logger.warn('getLibraries() - API not initialized'); - return []; + this.logger.warn('getLibraries() - API not initialized') + return [] } try { @@ -377,28 +375,28 @@ export class JellyfinAdapterService implements IMediaServerService { return ( this.cache.data.get(JELLYFIN_CACHE_KEYS.LIBRARIES) || [] - ); + ) } - const response = await getLibraryApi(this.api).getMediaFolders(); + const response = await getLibraryApi(this.api).getMediaFolders() const libraries = (response.data.Items || []) .filter( (item) => item.CollectionType === 'movies' || item.CollectionType === 'tvshows', ) - .map(JellyfinMapper.toMediaLibrary); + .map(JellyfinMapper.toMediaLibrary) this.cache.data.set( JELLYFIN_CACHE_KEYS.LIBRARIES, libraries, JELLYFIN_CACHE_TTL.LIBRARIES, - ); + ) - return libraries; + return libraries } catch (error) { - this.logger.error('Failed to get Jellyfin libraries', error); - return []; + this.logger.error('Failed to get Jellyfin libraries', error) + return [] } } @@ -407,12 +405,12 @@ export class JellyfinAdapterService implements IMediaServerService { options?: LibraryQueryOptions, ): Promise> { if (!this.api) { - this.logger.warn('getLibraryContents() - API not initialized'); - return { items: [], totalSize: 0, offset: 0, limit: 50 }; + this.logger.warn('getLibraryContents() - API not initialized') + return { items: [], totalSize: 0, offset: 0, limit: 50 } } try { - const userId = await this.getUserId(); + const userId = await this.getUserId() const response = await getItemsApi(this.api).getItems({ userId, parentId: libraryId, @@ -439,19 +437,19 @@ export class JellyfinAdapterService implements IMediaServerService { ? SortOrder.Descending : SortOrder.Ascending, ], - }); + }) - const items = (response.data.Items || []).map(JellyfinMapper.toMediaItem); + const items = (response.data.Items || []).map(JellyfinMapper.toMediaItem) return { items, totalSize: response.data.TotalRecordCount || items.length, offset: options?.offset || 0, limit: options?.limit || JELLYFIN_BATCH_SIZE.DEFAULT_PAGE_SIZE, - }; + } } catch (error) { - this.logLibraryError(libraryId, 'get library contents', error); - return { items: [], totalSize: 0, offset: 0, limit: 50 }; + this.logLibraryError(libraryId, 'get library contents', error) + return { items: [], totalSize: 0, offset: 0, limit: 50 } } } @@ -459,10 +457,10 @@ export class JellyfinAdapterService implements IMediaServerService { libraryId: string, type?: MediaItemType, ): Promise { - if (!this.api) return 0; + if (!this.api) return 0 try { - const userId = await this.getUserId(); + const userId = await this.getUserId() const response = await getItemsApi(this.api).getItems({ userId, parentId: libraryId, @@ -471,12 +469,12 @@ export class JellyfinAdapterService implements IMediaServerService { includeItemTypes: type ? JellyfinMapper.toBaseItemKinds([type]) : [BaseItemKind.Movie, BaseItemKind.Series], - }); + }) - return response.data.TotalRecordCount || 0; + return response.data.TotalRecordCount || 0 } catch (error) { - this.logLibraryError(libraryId, 'get library count', error); - return 0; + this.logLibraryError(libraryId, 'get library count', error) + return 0 } } @@ -485,10 +483,10 @@ export class JellyfinAdapterService implements IMediaServerService { query: string, type?: MediaItemType, ): Promise { - if (!this.api) return []; + if (!this.api) return [] try { - const userId = await this.getUserId(); + const userId = await this.getUserId() const response = await getItemsApi(this.api).getItems({ userId, parentId: libraryId, @@ -504,20 +502,20 @@ export class JellyfinAdapterService implements IMediaServerService { ? JellyfinMapper.toBaseItemKinds([type]) : [BaseItemKind.Movie, BaseItemKind.Series], enableUserData: true, - }); + }) - return (response.data.Items || []).map(JellyfinMapper.toMediaItem); + return (response.data.Items || []).map(JellyfinMapper.toMediaItem) } catch (error) { - this.logLibraryError(libraryId, 'search library', error); - return []; + this.logLibraryError(libraryId, 'search library', error) + return [] } } async getMetadata(itemId: string): Promise { - if (!this.api) return undefined; + if (!this.api) return undefined try { - const userId = await this.getUserId(); + const userId = await this.getUserId() const response = await getItemsApi(this.api).getItems({ userId, ids: [itemId], @@ -532,13 +530,13 @@ export class JellyfinAdapterService implements IMediaServerService { ItemFields.People, ], enableUserData: true, - }); + }) - const item = response.data.Items?.[0]; - return item ? JellyfinMapper.toMediaItem(item) : undefined; + const item = response.data.Items?.[0] + return item ? JellyfinMapper.toMediaItem(item) : undefined } catch (error) { - this.logger.warn(`Failed to get metadata for ${itemId}`, error); - return undefined; + this.logger.warn(`Failed to get metadata for ${itemId}`, error) + return undefined } } @@ -546,10 +544,10 @@ export class JellyfinAdapterService implements IMediaServerService { parentId: string, childType?: MediaItemType, ): Promise { - if (!this.api) return []; + if (!this.api) return [] try { - const userId = await this.getUserId(); + const userId = await this.getUserId() // For seasons, use the dedicated TvShows API which properly handles // the Jellyfin data model where seasons have SeriesId pointing to the show, @@ -565,9 +563,9 @@ export class JellyfinAdapterService implements IMediaServerService { ItemFields.MediaSources, ], enableUserData: true, - }); + }) - return (response.data.Items || []).map(JellyfinMapper.toMediaItem); + return (response.data.Items || []).map(JellyfinMapper.toMediaItem) } // For episodes and other types, parentId works correctly @@ -590,12 +588,12 @@ export class JellyfinAdapterService implements IMediaServerService { BaseItemKind.Season, BaseItemKind.Episode, ], - }); + }) - return (response.data.Items || []).map(JellyfinMapper.toMediaItem); + return (response.data.Items || []).map(JellyfinMapper.toMediaItem) } catch (error) { - this.logger.error(`Failed to get children for ${parentId}`, error); - return []; + this.logger.error(`Failed to get children for ${parentId}`, error) + return [] } } @@ -603,10 +601,10 @@ export class JellyfinAdapterService implements IMediaServerService { libraryId: string, options?: RecentlyAddedOptions, ): Promise { - if (!this.api) return []; + if (!this.api) return [] try { - const userId = await this.getUserId(); + const userId = await this.getUserId() const response = await getItemsApi(this.api).getItems({ userId, parentId: libraryId, @@ -623,20 +621,20 @@ export class JellyfinAdapterService implements IMediaServerService { ItemFields.DateCreated, ], enableUserData: true, - }); + }) - return (response.data.Items || []).map(JellyfinMapper.toMediaItem); + return (response.data.Items || []).map(JellyfinMapper.toMediaItem) } catch (error) { - this.logLibraryError(libraryId, 'get recently added', error); - return []; + this.logLibraryError(libraryId, 'get recently added', error) + return [] } } async searchContent(query: string): Promise { - if (!this.api) return []; + if (!this.api) return [] try { - const userId = await this.getUserId(); + const userId = await this.getUserId() const response = await getSearchApi(this.api).getSearchHints({ userId, searchTerm: query, @@ -651,7 +649,7 @@ export class JellyfinAdapterService implements IMediaServerService { includeGenres: false, includeStudios: false, includeArtists: false, - }); + }) return (response.data.SearchHints || []) .filter((hint) => hint.Id) @@ -664,26 +662,26 @@ export class JellyfinAdapterService implements IMediaServerService { providerIds: {}, mediaSources: [], library: { id: '', title: '' }, - })) as MediaItem[]; + })) as MediaItem[] } catch (error) { - this.logger.error('Failed to search Jellyfin content', error); - return []; + this.logger.error('Failed to search Jellyfin content', error) + return [] } } async getWatchHistory(itemId: string): Promise { - if (!this.api) return []; + if (!this.api) return [] try { const playedCompletionThreshold = - await this.getPlayedCompletionThreshold(); - const cacheKey = `${JELLYFIN_CACHE_KEYS.WATCH_HISTORY}:${playedCompletionThreshold ?? 'played'}:${itemId}`; + await this.getPlayedCompletionThreshold() + const cacheKey = `${JELLYFIN_CACHE_KEYS.WATCH_HISTORY}:${playedCompletionThreshold ?? 'played'}:${itemId}` if (this.cache.data.has(cacheKey)) { - return this.cache.data.get(cacheKey) || []; + return this.cache.data.get(cacheKey) || [] } - const users = await this.getUsers(); - const records: WatchRecord[] = []; + const users = await this.getUsers() + const records: WatchRecord[] = [] // Batch users to avoid overwhelming the API for ( @@ -691,14 +689,11 @@ export class JellyfinAdapterService implements IMediaServerService { i < users.length; i += JELLYFIN_BATCH_SIZE.USER_WATCH_HISTORY ) { - const batch = users.slice( - i, - i + JELLYFIN_BATCH_SIZE.USER_WATCH_HISTORY, - ); + const batch = users.slice(i, i + JELLYFIN_BATCH_SIZE.USER_WATCH_HISTORY) const results = await Promise.allSettled( batch.map((user) => this.getItemUserData(itemId, user.id)), - ); + ) results.forEach((result, idx) => { if ( @@ -714,22 +709,22 @@ export class JellyfinAdapterService implements IMediaServerService { : undefined, result.value.PlayedPercentage, ), - ); + ) } - }); + }) } - this.cache.data.set(cacheKey, records, JELLYFIN_CACHE_TTL.WATCH_HISTORY); - return records; + this.cache.data.set(cacheKey, records, JELLYFIN_CACHE_TTL.WATCH_HISTORY) + return records } catch (error) { - this.logger.error(`Failed to get watch history for ${itemId}`, error); - return []; + this.logger.error(`Failed to get watch history for ${itemId}`, error) + return [] } } async getItemSeenBy(itemId: string): Promise { - const history = await this.getWatchHistory(itemId); - return history.map((record) => record.userId); + const history = await this.getWatchHistory(itemId) + return history.map((record) => record.userId) } /** @@ -737,37 +732,34 @@ export class JellyfinAdapterService implements IMediaServerService { * Iterates over all users and checks UserData.IsFavorite. */ async getItemFavoritedBy(itemId: string): Promise { - if (!this.api) return []; + if (!this.api) return [] try { - const users = await this.getUsers(); - const favoritedBy: string[] = []; + const users = await this.getUsers() + const favoritedBy: string[] = [] for ( let i = 0; i < users.length; i += JELLYFIN_BATCH_SIZE.USER_WATCH_HISTORY ) { - const batch = users.slice( - i, - i + JELLYFIN_BATCH_SIZE.USER_WATCH_HISTORY, - ); + const batch = users.slice(i, i + JELLYFIN_BATCH_SIZE.USER_WATCH_HISTORY) const results = await Promise.allSettled( batch.map((user) => this.getItemUserData(itemId, user.id)), - ); + ) results.forEach((result, idx) => { if (result.status === 'fulfilled' && result.value?.IsFavorite) { - favoritedBy.push(batch[idx].id); + favoritedBy.push(batch[idx].id) } - }); + }) } - return favoritedBy; + return favoritedBy } catch (error) { - this.logger.error(`Failed to get favorited-by list for ${itemId}`, error); - return []; + this.logger.error(`Failed to get favorited-by list for ${itemId}`, error) + return [] } } @@ -777,11 +769,11 @@ export class JellyfinAdapterService implements IMediaServerService { * Only meaningful for Movies and Episodes (Series/Seasons always return 0). */ async getTotalPlayCount(itemId: string): Promise { - if (!this.api) return 0; + if (!this.api) return 0 try { - const users = await this.getUsers(); - let totalPlayCount = 0; + const users = await this.getUsers() + let totalPlayCount = 0 // Batch users to avoid overwhelming the API for ( @@ -789,26 +781,23 @@ export class JellyfinAdapterService implements IMediaServerService { i < users.length; i += JELLYFIN_BATCH_SIZE.USER_WATCH_HISTORY ) { - const batch = users.slice( - i, - i + JELLYFIN_BATCH_SIZE.USER_WATCH_HISTORY, - ); + const batch = users.slice(i, i + JELLYFIN_BATCH_SIZE.USER_WATCH_HISTORY) const results = await Promise.allSettled( batch.map((user) => this.getItemUserData(itemId, user.id)), - ); + ) results.forEach((result) => { if (result.status === 'fulfilled' && result.value?.PlayCount) { - totalPlayCount += result.value.PlayCount; + totalPlayCount += result.value.PlayCount } - }); + }) } - return totalPlayCount; + return totalPlayCount } catch (error) { - this.logger.error(`Failed to get play count for ${itemId}`, error); - return 0; + this.logger.error(`Failed to get play count for ${itemId}`, error) + return 0 } } @@ -816,17 +805,17 @@ export class JellyfinAdapterService implements IMediaServerService { * Get user data for a specific item. */ private async getItemUserData(itemId: string, userId: string) { - if (!this.api) return undefined; + if (!this.api) return undefined try { const response = await getItemsApi(this.api).getItems({ userId, ids: [itemId], enableUserData: true, - }); - return response.data.Items?.[0]?.UserData; + }) + return response.data.Items?.[0]?.UserData } catch { - return undefined; + return undefined } } @@ -836,17 +825,17 @@ export class JellyfinAdapterService implements IMediaServerService { * authenticating with an API key (no implicit user session). */ private async getUserId(): Promise { - const settings = await this.settingsService.getSettings(); + const settings = await this.settingsService.getSettings() return settings && 'jellyfin_user_id' in settings ? settings.jellyfin_user_id - : undefined; + : undefined } async getCollections(libraryId: string): Promise { - if (!this.api) return []; + if (!this.api) return [] try { - const userId = await this.getUserId(); + const userId = await this.getUserId() // Get all BoxSets system-wide - Jellyfin collections can contain items // from any library, so we can't filter by parentId const response = await getItemsApi(this.api).getItems({ @@ -858,22 +847,22 @@ export class JellyfinAdapterService implements IMediaServerService { ItemFields.DateCreated, ItemFields.ChildCount, ], - }); + }) - return (response.data.Items || []).map(JellyfinMapper.toMediaCollection); + return (response.data.Items || []).map(JellyfinMapper.toMediaCollection) } catch (error) { - this.logger.error(`Failed to get collections for ${libraryId}`, error); - return []; + this.logger.error(`Failed to get collections for ${libraryId}`, error) + return [] } } async getCollection( collectionId: string, ): Promise { - if (!this.api) return undefined; + if (!this.api) return undefined try { - const userId = await this.getUserId(); + const userId = await this.getUserId() const response = await getItemsApi(this.api).getItems({ userId, ids: [collectionId], @@ -882,13 +871,13 @@ export class JellyfinAdapterService implements IMediaServerService { ItemFields.DateCreated, ItemFields.ChildCount, ], - }); + }) - const item = response.data.Items?.[0]; - return item ? JellyfinMapper.toMediaCollection(item) : undefined; + const item = response.data.Items?.[0] + return item ? JellyfinMapper.toMediaCollection(item) : undefined } catch (error) { - this.logger.warn(`Failed to get collection ${collectionId}`, error); - return undefined; + this.logger.warn(`Failed to get collection ${collectionId}`, error) + return undefined } } @@ -896,7 +885,7 @@ export class JellyfinAdapterService implements IMediaServerService { params: CreateCollectionParams, ): Promise { if (!this.api) { - throw new Error('Jellyfin not initialized'); + throw new Error('Jellyfin not initialized') } try { @@ -905,44 +894,44 @@ export class JellyfinAdapterService implements IMediaServerService { parentId: params.libraryId, // isLocked enables composite image generation from collection items isLocked: true, - }); + }) - const collectionId = response.data.Id; + const collectionId = response.data.Id if (!collectionId) { - throw new Error('Collection created but no ID returned'); + throw new Error('Collection created but no ID returned') } // Note: No refresh needed - Jellyfin auto-generates composite images // when items are added (as long as isLocked: true, which we set above). - const collection = await this.getCollection(collectionId); + const collection = await this.getCollection(collectionId) if (!collection) { - throw new Error('Failed to fetch created collection'); + throw new Error('Failed to fetch created collection') } - return collection; + return collection } catch (error) { - this.logger.error('Failed to create Jellyfin collection', error); - throw error; + this.logger.error('Failed to create Jellyfin collection', error) + throw error } } async deleteCollection(collectionId: string): Promise { - if (!this.api) return; + if (!this.api) return try { - await getLibraryApi(this.api).deleteItem({ itemId: collectionId }); + await getLibraryApi(this.api).deleteItem({ itemId: collectionId }) } catch (error) { - this.logger.error(`Failed to delete collection ${collectionId}`, error); - throw error; + this.logger.error(`Failed to delete collection ${collectionId}`, error) + throw error } } async getCollectionChildren(collectionId: string): Promise { - if (!this.api) return []; + if (!this.api) return [] try { - const userId = await this.getUserId(); + const userId = await this.getUserId() // For BoxSets in Jellyfin, we need to use the Items endpoint // with the collection's ID as parentId AND a userId @@ -956,7 +945,7 @@ export class JellyfinAdapterService implements IMediaServerService { ], enableUserData: true, recursive: false, - }); + }) // If parentId approach returns nothing, try recursive search if (!response.data.Items?.length) { @@ -976,39 +965,39 @@ export class JellyfinAdapterService implements IMediaServerService { ItemFields.DateCreated, ], enableUserData: true, - }); + }) if (itemsResponse.data.Items?.length) { return (itemsResponse.data.Items || []).map( JellyfinMapper.toMediaItem, - ); + ) } } - return (response.data.Items || []).map(JellyfinMapper.toMediaItem); + return (response.data.Items || []).map(JellyfinMapper.toMediaItem) } catch (error) { this.logger.error( `Failed to get collection children for ${collectionId}`, error, - ); - return []; + ) + return [] } } async addToCollection(collectionId: string, itemId: string): Promise { - if (!this.api) return; + if (!this.api) return try { await getCollectionApi(this.api).addToCollection({ collectionId, ids: [itemId], - }); + }) } catch (error) { this.logger.error( `Failed to add ${itemId} to collection ${collectionId}`, error, - ); - throw error; + ) + throw error } } @@ -1016,19 +1005,19 @@ export class JellyfinAdapterService implements IMediaServerService { collectionId: string, itemId: string, ): Promise { - if (!this.api) return; + if (!this.api) return try { await getCollectionApi(this.api).removeFromCollection({ collectionId, ids: [itemId], - }); + }) } catch (error) { this.logger.error( `Failed to remove ${itemId} from collection ${collectionId}`, error, - ); - throw error; + ) + throw error } } @@ -1038,11 +1027,11 @@ export class JellyfinAdapterService implements IMediaServerService { params: UpdateCollectionParams, ): Promise { if (!this.api) { - throw new Error('Jellyfin client not initialized'); + throw new Error('Jellyfin client not initialized') } try { - const userId = await this.getUserId(); + const userId = await this.getUserId() // First, get the existing collection to preserve all properties const existingResponse = await getItemsApi(this.api).getItems({ userId, @@ -1057,11 +1046,11 @@ export class JellyfinAdapterService implements IMediaServerService { ItemFields.Studios, ItemFields.People, ], - }); + }) - const existingCollection = existingResponse.data.Items?.[0]; + const existingCollection = existingResponse.data.Items?.[0] if (!existingCollection) { - throw new Error(`Collection ${params.collectionId} not found`); + throw new Error(`Collection ${params.collectionId} not found`) } // Update collection metadata using ItemUpdateApi @@ -1085,7 +1074,7 @@ export class JellyfinAdapterService implements IMediaServerService { ProviderIds: existingCollection.ProviderIds ?? {}, LockedFields: existingCollection.LockedFields ?? [], }, - }); + }) // Return updated collection info const response = await getItemsApi(this.api).getItems({ @@ -1097,20 +1086,20 @@ export class JellyfinAdapterService implements IMediaServerService { ItemFields.DateCreated, ItemFields.ChildCount, ], - }); + }) - const collection = response.data.Items?.[0]; + const collection = response.data.Items?.[0] if (!collection) { - throw new Error(`Collection ${params.collectionId} not found`); + throw new Error(`Collection ${params.collectionId} not found`) } - return JellyfinMapper.toMediaCollection(collection); + return JellyfinMapper.toMediaCollection(collection) } catch (error) { this.logger.error( `Failed to update Jellyfin collection ${params.collectionId}`, error, - ); - throw error; + ) + throw error } } @@ -1120,11 +1109,11 @@ export class JellyfinAdapterService implements IMediaServerService { this.logger.warn( `Attempted to update collection visibility for collection ${settings.collectionId} in library ${settings.libraryId}, ` + 'but Jellyfin does not support hub/recommendation visibility features.', - ); + ) throw new Error( 'Collection visibility settings are not supported on Jellyfin. ' + 'Jellyfin does not have hub/recommendation visibility features.', - ); + ) } // OPTIONAL: SERVER-SPECIFIC FEATURES (Not supported) @@ -1133,10 +1122,10 @@ export class JellyfinAdapterService implements IMediaServerService { // as it doesn't have a watchlist API async getPlaylists(libraryId: string): Promise { - if (!this.api) return []; + if (!this.api) return [] try { - const userId = await this.getUserId(); + const userId = await this.getUserId() // Jellyfin playlists are not library-specific, but we filter by parentId // to maintain consistency with the interface contract @@ -1146,35 +1135,35 @@ export class JellyfinAdapterService implements IMediaServerService { includeItemTypes: [BaseItemKind.Playlist], recursive: true, fields: [ItemFields.Overview, ItemFields.DateCreated], - }); + }) - return (response.data.Items || []).map(JellyfinMapper.toMediaPlaylist); + return (response.data.Items || []).map(JellyfinMapper.toMediaPlaylist) } catch (error) { this.logger.error( `Failed to get Jellyfin playlists for library ${libraryId}`, error, - ); - return []; + ) + return [] } } async getPlaylistItems(playlistId: string): Promise { - if (!this.api) return []; + if (!this.api) return [] try { - const userId = await this.getUserId(); + const userId = await this.getUserId() const response = await getPlaylistsApi(this.api).getPlaylistItems({ userId, playlistId, - }); + }) - return (response.data.Items || []).map(JellyfinMapper.toMediaItem); + return (response.data.Items || []).map(JellyfinMapper.toMediaItem) } catch (error) { this.logger.error( `Failed to get Jellyfin playlist items for ${playlistId}`, error, - ); - return []; + ) + return [] } } @@ -1185,10 +1174,10 @@ export class JellyfinAdapterService implements IMediaServerService { ): Promise { // Handle -1 sentinel value (meaning "all" from UI) - just return the mediaId if (context.id === '-1') { - return [mediaId]; + return [mediaId] } - const handleMedia: string[] = []; + const handleMedia: string[] = [] // If we have a collection type, use it to determine what IDs to return if (collectionType) { @@ -1198,55 +1187,55 @@ export class JellyfinAdapterService implements IMediaServerService { switch (context.type) { // and context type is seasons - return just the season case 'season': - handleMedia.push(context.id); - break; + handleMedia.push(context.id) + break // and context type is episodes - not allowed case 'episode': this.logger.warn( 'Tried to add episodes to a collection of type season. This is not allowed.', - ); - break; + ) + break // and context type is show - return all seasons default: - const seasons = await this.getChildrenMetadata(mediaId, 'season'); - handleMedia.push(...seasons.map((s) => s.id)); - break; + const seasons = await this.getChildrenMetadata(mediaId, 'season') + handleMedia.push(...seasons.map((s) => s.id)) + break } - break; + break // When collection type is episodes case 'episode': switch (context.type) { // and context type is seasons - return all episodes in season case 'season': - const eps = await this.getChildrenMetadata(context.id, 'episode'); - handleMedia.push(...eps.map((ep) => ep.id)); - break; + const eps = await this.getChildrenMetadata(context.id, 'episode') + handleMedia.push(...eps.map((ep) => ep.id)) + break // and context type is episodes - return just the episode case 'episode': - handleMedia.push(context.id); - break; + handleMedia.push(context.id) + break // and context type is show - return all episodes in show default: const allSeasons = await this.getChildrenMetadata( mediaId, 'season', - ); + ) for (const season of allSeasons) { const episodes = await this.getChildrenMetadata( season.id, 'episode', - ); - handleMedia.push(...episodes.map((ep) => ep.id)); + ) + handleMedia.push(...episodes.map((ep) => ep.id)) } - break; + break } - break; + break // When collection type is show or movie - just return the media item default: - handleMedia.push(mediaId); - break; + handleMedia.push(mediaId) + break } } // For global exclusions (no collection type), return hierarchically @@ -1254,59 +1243,59 @@ export class JellyfinAdapterService implements IMediaServerService { switch (context.type) { case 'show': // For shows, add the show + all seasons + all episodes - handleMedia.push(mediaId); - const showSeasons = await this.getChildrenMetadata(mediaId, 'season'); + handleMedia.push(mediaId) + const showSeasons = await this.getChildrenMetadata(mediaId, 'season') for (const season of showSeasons) { - handleMedia.push(season.id); + handleMedia.push(season.id) const episodes = await this.getChildrenMetadata( season.id, 'episode', - ); - handleMedia.push(...episodes.map((ep) => ep.id)); + ) + handleMedia.push(...episodes.map((ep) => ep.id)) } - break; + break case 'season': // For seasons, add the season + all its episodes - handleMedia.push(context.id); + handleMedia.push(context.id) const seasonEps = await this.getChildrenMetadata( context.id, 'episode', - ); - handleMedia.push(...seasonEps.map((ep) => ep.id)); - break; + ) + handleMedia.push(...seasonEps.map((ep) => ep.id)) + break case 'episode': // Just the episode - handleMedia.push(context.id); - break; + handleMedia.push(context.id) + break default: // Movies or unknown - just the item - handleMedia.push(mediaId); - break; + handleMedia.push(mediaId) + break } } - return handleMedia; + return handleMedia } async deleteFromDisk(itemId: string): Promise { if (!this.api) { throw new Error( 'Jellyfin API not initialized — cannot delete item from disk', - ); + ) } if (!itemId || itemId.trim() === '') { throw new Error( 'deleteFromDisk called with empty itemId — aborting to prevent unintended deletion', - ); + ) } try { - await getLibraryApi(this.api).deleteItem({ itemId }); - this.logger.log(`Successfully deleted Jellyfin item ${itemId} from disk`); + await getLibraryApi(this.api).deleteItem({ itemId }) + this.logger.log(`Successfully deleted Jellyfin item ${itemId} from disk`) } catch (error) { - this.logger.error(`Failed to delete item ${itemId} from disk`, error); - throw error; + this.logger.error(`Failed to delete item ${itemId} from disk`, error) + throw error } } @@ -1319,10 +1308,10 @@ export class JellyfinAdapterService implements IMediaServerService { key.startsWith(`${JELLYFIN_CACHE_KEYS.WATCH_HISTORY}:`) && key.endsWith(`:${itemId}`), ) - .forEach((key) => this.cache.data.del(key)); + .forEach((key) => this.cache.data.del(key)) } else { // Clear all Jellyfin cache - this.cache.data.flushAll(); + this.cache.data.flushAll() } } @@ -1331,7 +1320,7 @@ export class JellyfinAdapterService implements IMediaServerService { * (e.g. Plex numeric IDs) or is empty/invalid. */ private isLikelyMigrationId(libraryId: string): boolean { - return !libraryId || libraryId.trim() === '' || /^\d+$/.test(libraryId); + return !libraryId || libraryId.trim() === '' || /^\d+$/.test(libraryId) } /** @@ -1345,9 +1334,9 @@ export class JellyfinAdapterService implements IMediaServerService { if (this.isLikelyMigrationId(libraryId)) { this.logger.warn( `Library '${libraryId || '(empty)'}' appears to be from a different media server. Please update the library setting in your rules.`, - ); + ) } else { - this.logger.error(`Failed to ${operation} for ${libraryId}`, error); + this.logger.error(`Failed to ${operation} for ${libraryId}`, error) } } } diff --git a/apps/server/src/modules/api/media-server/jellyfin/jellyfin.constants.ts b/apps/server/src/modules/api/media-server/jellyfin/jellyfin.constants.ts index af8c5321..eaf772ae 100644 --- a/apps/server/src/modules/api/media-server/jellyfin/jellyfin.constants.ts +++ b/apps/server/src/modules/api/media-server/jellyfin/jellyfin.constants.ts @@ -4,13 +4,13 @@ export const JELLYFIN_CACHE_TTL = { USERS: 1800000, LIBRARIES: 1800000, STATUS: 60000, -} as const; +} as const export const JELLYFIN_BATCH_SIZE = { USER_WATCH_HISTORY: 5, DEFAULT_PAGE_SIZE: 100, MAX_PAGE_SIZE: 500, -} as const; +} as const export const JELLYFIN_CACHE_KEYS = { WATCH_HISTORY: 'jellyfin:watch', @@ -18,14 +18,14 @@ export const JELLYFIN_CACHE_KEYS = { USERS: 'jellyfin:users', LIBRARIES: 'jellyfin:libraries', STATUS: 'jellyfin:status', -} as const; +} as const /** * Jellyfin ticks to milliseconds conversion factor. * 1 Jellyfin tick = 100 nanoseconds * 1 millisecond = 10,000 ticks */ -export const JELLYFIN_TICKS_PER_MS = 10000; +export const JELLYFIN_TICKS_PER_MS = 10000 /** * Client information for Jellyfin API authentication @@ -33,7 +33,7 @@ export const JELLYFIN_TICKS_PER_MS = 10000; export const JELLYFIN_CLIENT_INFO = { name: 'Maintainerr', version: process.env.npm_package_version || '2.0.0', -} as const; +} as const /** * Device information for Jellyfin API authentication @@ -41,4 +41,4 @@ export const JELLYFIN_CLIENT_INFO = { export const JELLYFIN_DEVICE_INFO = { name: 'Maintainerr-Server', idPrefix: 'maintainerr', -} as const; +} as const diff --git a/apps/server/src/modules/api/media-server/jellyfin/jellyfin.mapper.spec.ts b/apps/server/src/modules/api/media-server/jellyfin/jellyfin.mapper.spec.ts index 549ee042..95c1fec2 100644 --- a/apps/server/src/modules/api/media-server/jellyfin/jellyfin.mapper.spec.ts +++ b/apps/server/src/modules/api/media-server/jellyfin/jellyfin.mapper.spec.ts @@ -2,8 +2,8 @@ import { BaseItemKind, type BaseItemDto, type UserDto, -} from '@jellyfin/sdk/lib/generated-client/models'; -import { JellyfinMapper } from './jellyfin.mapper'; +} from '@jellyfin/sdk/lib/generated-client/models' +import { JellyfinMapper } from './jellyfin.mapper' describe('JellyfinMapper', () => { describe('toMediaItemType', () => { @@ -17,9 +17,9 @@ describe('JellyfinMapper', () => { [undefined, 'movie'], ['Unknown', 'movie'], ])('maps %s to %s', (input, expected) => { - expect(JellyfinMapper.toMediaItemType(input as any)).toBe(expected); - }); - }); + expect(JellyfinMapper.toMediaItemType(input as any)).toBe(expected) + }) + }) describe('toBaseItemKind', () => { it.each([ @@ -28,75 +28,75 @@ describe('JellyfinMapper', () => { ['season', BaseItemKind.Season], ['episode', BaseItemKind.Episode], ])('maps %s to %s', (input, expected) => { - expect(JellyfinMapper.toBaseItemKind(input as any)).toBe(expected); - }); - }); + expect(JellyfinMapper.toBaseItemKind(input as any)).toBe(expected) + }) + }) describe('toBaseItemKinds', () => { it('should return Movie and Series for empty array', () => { - const result = JellyfinMapper.toBaseItemKinds([]); - expect(result).toContain(BaseItemKind.Movie); - expect(result).toContain(BaseItemKind.Series); - }); + const result = JellyfinMapper.toBaseItemKinds([]) + expect(result).toContain(BaseItemKind.Movie) + expect(result).toContain(BaseItemKind.Series) + }) it('should return Movie and Series for undefined', () => { - const result = JellyfinMapper.toBaseItemKinds(undefined); - expect(result).toContain(BaseItemKind.Movie); - expect(result).toContain(BaseItemKind.Series); - }); + const result = JellyfinMapper.toBaseItemKinds(undefined) + expect(result).toContain(BaseItemKind.Movie) + expect(result).toContain(BaseItemKind.Series) + }) it('should map multiple types correctly', () => { - const result = JellyfinMapper.toBaseItemKinds(['movie', 'show']); - expect(result).toEqual([BaseItemKind.Movie, BaseItemKind.Series]); - }); - }); + const result = JellyfinMapper.toBaseItemKinds(['movie', 'show']) + expect(result).toEqual([BaseItemKind.Movie, BaseItemKind.Series]) + }) + }) describe('extractProviderIds', () => { it('should extract IMDB id correctly', () => { - const providerIds = { Imdb: 'tt1234567' }; - const result = JellyfinMapper.extractProviderIds(providerIds); - expect(result.imdb).toEqual(['tt1234567']); - }); + const providerIds = { Imdb: 'tt1234567' } + const result = JellyfinMapper.extractProviderIds(providerIds) + expect(result.imdb).toEqual(['tt1234567']) + }) it('should extract TMDB id correctly', () => { - const providerIds = { Tmdb: '12345' }; - const result = JellyfinMapper.extractProviderIds(providerIds); - expect(result.tmdb).toEqual(['12345']); - }); + const providerIds = { Tmdb: '12345' } + const result = JellyfinMapper.extractProviderIds(providerIds) + expect(result.tmdb).toEqual(['12345']) + }) it('should extract TVDB id correctly', () => { - const providerIds = { Tvdb: '67890' }; - const result = JellyfinMapper.extractProviderIds(providerIds); - expect(result.tvdb).toEqual(['67890']); - }); + const providerIds = { Tvdb: '67890' } + const result = JellyfinMapper.extractProviderIds(providerIds) + expect(result.tvdb).toEqual(['67890']) + }) it('should extract multiple provider ids', () => { const providerIds = { Imdb: 'tt1234567', Tmdb: '12345', Tvdb: '67890', - }; - const result = JellyfinMapper.extractProviderIds(providerIds); - expect(result.imdb).toEqual(['tt1234567']); - expect(result.tmdb).toEqual(['12345']); - expect(result.tvdb).toEqual(['67890']); - }); + } + const result = JellyfinMapper.extractProviderIds(providerIds) + expect(result.imdb).toEqual(['tt1234567']) + expect(result.tmdb).toEqual(['12345']) + expect(result.tvdb).toEqual(['67890']) + }) it('should handle undefined provider ids', () => { - const result = JellyfinMapper.extractProviderIds(undefined); - expect(result).toEqual({ imdb: [], tmdb: [], tvdb: [] }); - }); + const result = JellyfinMapper.extractProviderIds(undefined) + expect(result).toEqual({ imdb: [], tmdb: [], tvdb: [] }) + }) it('should handle null provider ids', () => { - const result = JellyfinMapper.extractProviderIds(null); - expect(result).toEqual({ imdb: [], tmdb: [], tvdb: [] }); - }); + const result = JellyfinMapper.extractProviderIds(null) + expect(result).toEqual({ imdb: [], tmdb: [], tvdb: [] }) + }) it('should handle empty provider ids', () => { - const result = JellyfinMapper.extractProviderIds({}); - expect(result).toEqual({ imdb: [], tmdb: [], tvdb: [] }); - }); - }); + const result = JellyfinMapper.extractProviderIds({}) + expect(result).toEqual({ imdb: [], tmdb: [], tvdb: [] }) + }) + }) describe('toMediaItem', () => { describe('Episode', () => { @@ -161,97 +161,97 @@ describe('JellyfinMapper', () => { IndexNumber: 1, ParentIndexNumber: 1, Tags: ['HD', '4K'], - } as BaseItemDto; + } as BaseItemDto it('should convert episode with correct parent/grandparent hierarchy', () => { - const result = JellyfinMapper.toMediaItem(episodeItem); + const result = JellyfinMapper.toMediaItem(episodeItem) - expect(result.id).toBe('episode123'); + expect(result.id).toBe('episode123') // Episode: parentId = season (from ParentId or SeasonId) - expect(result.parentId).toBe('season123'); + expect(result.parentId).toBe('season123') // Episode: grandparentId = show (from SeriesId) - expect(result.grandparentId).toBe('series123'); - expect(result.title).toBe('Test Episode'); - expect(result.parentTitle).toBe('Season 1'); - expect(result.grandparentTitle).toBe('Test Series'); - expect(result.guid).toBe('episode123'); - expect(result.type).toBe('episode'); - }); + expect(result.grandparentId).toBe('series123') + expect(result.title).toBe('Test Episode') + expect(result.parentTitle).toBe('Season 1') + expect(result.grandparentTitle).toBe('Test Series') + expect(result.guid).toBe('episode123') + expect(result.type).toBe('episode') + }) it('should convert timestamps to Date objects', () => { - const result = JellyfinMapper.toMediaItem(episodeItem); + const result = JellyfinMapper.toMediaItem(episodeItem) - expect(result.addedAt).toEqual(new Date('2021-01-01T00:00:00.000Z')); - expect(result.updatedAt).toEqual(new Date('2021-01-02T00:00:00.000Z')); + expect(result.addedAt).toEqual(new Date('2021-01-01T00:00:00.000Z')) + expect(result.updatedAt).toEqual(new Date('2021-01-02T00:00:00.000Z')) expect(result.lastViewedAt).toEqual( new Date('2021-01-03T00:00:00.000Z'), - ); - }); + ) + }) it('should extract provider IDs correctly', () => { - const result = JellyfinMapper.toMediaItem(episodeItem); + const result = JellyfinMapper.toMediaItem(episodeItem) - expect(result.providerIds.imdb).toEqual(['tt1234567']); - expect(result.providerIds.tmdb).toEqual(['12345']); - }); + expect(result.providerIds.imdb).toEqual(['tt1234567']) + expect(result.providerIds.tmdb).toEqual(['12345']) + }) it('should convert duration from ticks to milliseconds', () => { - const result = JellyfinMapper.toMediaItem(episodeItem); + const result = JellyfinMapper.toMediaItem(episodeItem) // 72000000000 ticks / 10000 = 7200000 ms = 2 hours - expect(result.durationMs).toBe(7200000); - }); + expect(result.durationMs).toBe(7200000) + }) it('should convert media sources correctly', () => { - const result = JellyfinMapper.toMediaItem(episodeItem); + const result = JellyfinMapper.toMediaItem(episodeItem) - expect(result.mediaSources).toHaveLength(1); - expect(result.mediaSources[0].id).toBe('source1'); - expect(result.mediaSources[0].duration).toBe(7200000); - expect(result.mediaSources[0].videoCodec).toBe('h264'); - expect(result.mediaSources[0].audioCodec).toBe('aac'); - expect(result.mediaSources[0].audioChannels).toBe(6); - }); + expect(result.mediaSources).toHaveLength(1) + expect(result.mediaSources[0].id).toBe('source1') + expect(result.mediaSources[0].duration).toBe(7200000) + expect(result.mediaSources[0].videoCodec).toBe('h264') + expect(result.mediaSources[0].audioCodec).toBe('aac') + expect(result.mediaSources[0].audioChannels).toBe(6) + }) it('should convert genres correctly', () => { - const result = JellyfinMapper.toMediaItem(episodeItem); + const result = JellyfinMapper.toMediaItem(episodeItem) - expect(result.genres).toHaveLength(2); - expect(result.genres![0].name).toBe('Action'); - expect(result.genres![1].name).toBe('Thriller'); - }); + expect(result.genres).toHaveLength(2) + expect(result.genres![0].name).toBe('Action') + expect(result.genres![1].name).toBe('Thriller') + }) it('should convert actors correctly', () => { - const result = JellyfinMapper.toMediaItem(episodeItem); + const result = JellyfinMapper.toMediaItem(episodeItem) - expect(result.actors).toHaveLength(1); - expect(result.actors![0].name).toBe('Actor Name'); - expect(result.actors![0].role).toBe('Hero'); - }); + expect(result.actors).toHaveLength(1) + expect(result.actors![0].name).toBe('Actor Name') + expect(result.actors![0].role).toBe('Hero') + }) it('should convert labels from tags', () => { - const result = JellyfinMapper.toMediaItem(episodeItem); + const result = JellyfinMapper.toMediaItem(episodeItem) - expect(result.labels).toEqual(['HD', '4K']); - }); + expect(result.labels).toEqual(['HD', '4K']) + }) it('should convert ratings correctly', () => { - const result = JellyfinMapper.toMediaItem(episodeItem); + const result = JellyfinMapper.toMediaItem(episodeItem) - expect(result.ratings).toHaveLength(2); + expect(result.ratings).toHaveLength(2) expect(result.ratings).toContainEqual({ source: 'community', value: 8.5, type: 'audience', - }); + }) // Critic rating is normalized from 0-100 to 0-10 expect(result.ratings).toContainEqual({ source: 'critic', value: 8.5, type: 'critic', - }); - }); - }); + }) + }) + }) describe('Season', () => { it('should convert season with correct parent hierarchy', () => { @@ -264,21 +264,21 @@ describe('JellyfinMapper', () => { Type: BaseItemKind.Season, IndexNumber: 1, DateCreated: '2021-01-01T00:00:00.000Z', - }; + } - const result = JellyfinMapper.toMediaItem(seasonItem); + const result = JellyfinMapper.toMediaItem(seasonItem) - expect(result.id).toBe('season123'); + expect(result.id).toBe('season123') // Season: parentId = show (from SeriesId, not library ParentId) - expect(result.parentId).toBe('series123'); + expect(result.parentId).toBe('series123') // Season: no grandparent - expect(result.grandparentId).toBeUndefined(); - expect(result.title).toBe('Season 1'); - expect(result.parentTitle).toBe('Test Series'); - expect(result.type).toBe('season'); - expect(result.index).toBe(1); - }); - }); + expect(result.grandparentId).toBeUndefined() + expect(result.title).toBe('Season 1') + expect(result.parentTitle).toBe('Test Series') + expect(result.type).toBe('season') + expect(result.index).toBe(1) + }) + }) describe('Show/Series', () => { it('should convert show with library as parent', () => { @@ -292,20 +292,20 @@ describe('JellyfinMapper', () => { ProviderIds: { Tvdb: '12345', }, - }; + } - const result = JellyfinMapper.toMediaItem(showItem); + const result = JellyfinMapper.toMediaItem(showItem) - expect(result.id).toBe('series123'); + expect(result.id).toBe('series123') // Show: parentId = library - expect(result.parentId).toBe('library123'); + expect(result.parentId).toBe('library123') // Show: no grandparent - expect(result.grandparentId).toBeUndefined(); - expect(result.title).toBe('Test Series'); - expect(result.type).toBe('show'); - expect(result.summary).toBe('A test series'); - }); - }); + expect(result.grandparentId).toBeUndefined() + expect(result.title).toBe('Test Series') + expect(result.type).toBe('show') + expect(result.summary).toBe('A test series') + }) + }) describe('Movie', () => { it('should convert movie with library as parent', () => { @@ -321,39 +321,39 @@ describe('JellyfinMapper', () => { Imdb: 'tt1234567', Tmdb: '12345', }, - }; + } - const result = JellyfinMapper.toMediaItem(movieItem); + const result = JellyfinMapper.toMediaItem(movieItem) - expect(result.id).toBe('movie123'); + expect(result.id).toBe('movie123') // Movie: parentId = library - expect(result.parentId).toBe('library123'); + expect(result.parentId).toBe('library123') // Movie: no grandparent - expect(result.grandparentId).toBeUndefined(); - expect(result.title).toBe('Test Movie'); - expect(result.type).toBe('movie'); - expect(result.summary).toBe('A test movie'); - expect(result.year).toBe(2021); - }); + expect(result.grandparentId).toBeUndefined() + expect(result.title).toBe('Test Movie') + expect(result.type).toBe('movie') + expect(result.summary).toBe('A test movie') + expect(result.year).toBe(2021) + }) it('should handle missing optional fields', () => { const minimalItem: BaseItemDto = { Id: 'minimal123', Name: 'Minimal Item', Type: BaseItemKind.Movie, - }; + } - const result = JellyfinMapper.toMediaItem(minimalItem); + const result = JellyfinMapper.toMediaItem(minimalItem) - expect(result.id).toBe('minimal123'); - expect(result.title).toBe('Minimal Item'); - expect(result.parentId).toBeUndefined(); - expect(result.providerIds).toEqual({ imdb: [], tmdb: [], tvdb: [] }); - expect(result.mediaSources).toEqual([]); - expect(result.genres).toEqual([]); - }); - }); - }); + expect(result.id).toBe('minimal123') + expect(result.title).toBe('Minimal Item') + expect(result.parentId).toBeUndefined() + expect(result.providerIds).toEqual({ imdb: [], tmdb: [], tvdb: [] }) + expect(result.mediaSources).toEqual([]) + expect(result.genres).toEqual([]) + }) + }) + }) describe('toMediaLibrary', () => { it('should convert movie library correctly', () => { @@ -361,39 +361,39 @@ describe('JellyfinMapper', () => { Id: 'lib1', Name: 'Movies', CollectionType: 'movies', - }; + } - const result = JellyfinMapper.toMediaLibrary(jellyfinLibrary); + const result = JellyfinMapper.toMediaLibrary(jellyfinLibrary) - expect(result.id).toBe('lib1'); - expect(result.title).toBe('Movies'); - expect(result.type).toBe('movie'); - }); + expect(result.id).toBe('lib1') + expect(result.title).toBe('Movies') + expect(result.type).toBe('movie') + }) it('should convert TV shows library correctly', () => { const jellyfinLibrary: BaseItemDto = { Id: 'lib2', Name: 'TV Shows', CollectionType: 'tvshows', - }; + } - const result = JellyfinMapper.toMediaLibrary(jellyfinLibrary); + const result = JellyfinMapper.toMediaLibrary(jellyfinLibrary) - expect(result.type).toBe('show'); - }); + expect(result.type).toBe('show') + }) it('should default to movie for unknown collection types', () => { const jellyfinLibrary: BaseItemDto = { Id: 'lib3', Name: 'Unknown', CollectionType: 'music', - }; + } - const result = JellyfinMapper.toMediaLibrary(jellyfinLibrary); + const result = JellyfinMapper.toMediaLibrary(jellyfinLibrary) - expect(result.type).toBe('movie'); - }); - }); + expect(result.type).toBe('movie') + }) + }) describe('toMediaUser', () => { it('should convert user correctly', () => { @@ -408,26 +408,26 @@ describe('JellyfinMapper', () => { PasswordResetProviderId: 'Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider', }, - }; + } - const result = JellyfinMapper.toMediaUser(jellyfinUser); + const result = JellyfinMapper.toMediaUser(jellyfinUser) - expect(result.id).toBe('user123'); - expect(result.name).toBe('Test User'); - expect(result.thumb).toBe('/Users/user123/Images/Primary'); - }); + expect(result.id).toBe('user123') + expect(result.name).toBe('Test User') + expect(result.thumb).toBe('/Users/user123/Images/Primary') + }) it('should handle user without image', () => { const jellyfinUser: UserDto = { Id: 'user456', Name: 'No Image User', - }; + } - const result = JellyfinMapper.toMediaUser(jellyfinUser); + const result = JellyfinMapper.toMediaUser(jellyfinUser) - expect(result.thumb).toBeUndefined(); - }); - }); + expect(result.thumb).toBeUndefined() + }) + }) describe('toWatchRecord', () => { it('should create watch record correctly', () => { @@ -435,24 +435,24 @@ describe('JellyfinMapper', () => { 'user123', 'item456', new Date('2021-01-01T00:00:00.000Z'), - ); + ) - expect(result.userId).toBe('user123'); - expect(result.itemId).toBe('item456'); - expect(result.watchedAt).toEqual(new Date('2021-01-01T00:00:00.000Z')); - expect(result.progress).toBe(100); - }); + expect(result.userId).toBe('user123') + expect(result.itemId).toBe('item456') + expect(result.watchedAt).toEqual(new Date('2021-01-01T00:00:00.000Z')) + expect(result.progress).toBe(100) + }) it('should leave watchedAt undefined if no lastPlayedDate', () => { const result = JellyfinMapper.toWatchRecord( 'user123', 'item456', undefined, - ); + ) - expect(result.watchedAt).toBeUndefined(); - }); - }); + expect(result.watchedAt).toBeUndefined() + }) + }) describe('toMediaCollection', () => { it('should convert collection correctly', () => { @@ -464,20 +464,20 @@ describe('JellyfinMapper', () => { ChildCount: 10, DateCreated: '2021-01-01T00:00:00.000Z', ParentId: 'lib1', - }; + } - const result = JellyfinMapper.toMediaCollection(jellyfinCollection); + const result = JellyfinMapper.toMediaCollection(jellyfinCollection) - expect(result.id).toBe('col123'); - expect(result.title).toBe('My Collection'); - expect(result.summary).toBe('Collection description'); - expect(result.thumb).toBe('/Items/col123/Images/Primary'); - expect(result.childCount).toBe(10); - expect(result.addedAt).toEqual(new Date('2021-01-01T00:00:00.000Z')); - expect(result.smart).toBe(false); - expect(result.libraryId).toBe('lib1'); - }); - }); + expect(result.id).toBe('col123') + expect(result.title).toBe('My Collection') + expect(result.summary).toBe('Collection description') + expect(result.thumb).toBe('/Items/col123/Images/Primary') + expect(result.childCount).toBe(10) + expect(result.addedAt).toEqual(new Date('2021-01-01T00:00:00.000Z')) + expect(result.smart).toBe(false) + expect(result.libraryId).toBe('lib1') + }) + }) describe('toMediaPlaylist', () => { it('should convert playlist correctly', () => { @@ -488,18 +488,18 @@ describe('JellyfinMapper', () => { ChildCount: 25, RunTimeTicks: 36000000000, // 1 hour DateCreated: '2021-01-01T00:00:00.000Z', - }; + } - const result = JellyfinMapper.toMediaPlaylist(jellyfinPlaylist); + const result = JellyfinMapper.toMediaPlaylist(jellyfinPlaylist) - expect(result.id).toBe('pl123'); - expect(result.title).toBe('My Playlist'); - expect(result.summary).toBe('Playlist description'); - expect(result.itemCount).toBe(25); - expect(result.durationMs).toBe(3600000); // 1 hour in ms - expect(result.smart).toBe(false); - }); - }); + expect(result.id).toBe('pl123') + expect(result.title).toBe('My Playlist') + expect(result.summary).toBe('Playlist description') + expect(result.itemCount).toBe(25) + expect(result.durationMs).toBe(3600000) // 1 hour in ms + expect(result.smart).toBe(false) + }) + }) describe('toMediaServerStatus', () => { it('should convert server status correctly', () => { @@ -508,13 +508,13 @@ describe('JellyfinMapper', () => { '10.11.0', 'My Jellyfin Server', 'Linux', - ); + ) - expect(result.machineId).toBe('server123'); - expect(result.version).toBe('10.11.0'); - expect(result.name).toBe('My Jellyfin Server'); - expect(result.platform).toBe('Linux'); - }); + expect(result.machineId).toBe('server123') + expect(result.version).toBe('10.11.0') + expect(result.name).toBe('My Jellyfin Server') + expect(result.platform).toBe('Linux') + }) it('should handle null optional fields', () => { const result = JellyfinMapper.toMediaServerStatus( @@ -522,10 +522,10 @@ describe('JellyfinMapper', () => { '10.11.0', null, null, - ); + ) - expect(result.name).toBeUndefined(); - expect(result.platform).toBeUndefined(); - }); - }); -}); + expect(result.name).toBeUndefined() + expect(result.platform).toBeUndefined() + }) + }) +}) diff --git a/apps/server/src/modules/api/media-server/jellyfin/jellyfin.mapper.ts b/apps/server/src/modules/api/media-server/jellyfin/jellyfin.mapper.ts index 4d63d095..65bf35ac 100644 --- a/apps/server/src/modules/api/media-server/jellyfin/jellyfin.mapper.ts +++ b/apps/server/src/modules/api/media-server/jellyfin/jellyfin.mapper.ts @@ -3,7 +3,7 @@ import { BaseItemKind, type MediaSourceInfo, type UserDto, -} from '@jellyfin/sdk/lib/generated-client/models'; +} from '@jellyfin/sdk/lib/generated-client/models' import { type MediaActor, type MediaCollection, @@ -18,8 +18,8 @@ import { type MediaSource, type MediaUser, type WatchRecord, -} from '@maintainerr/contracts'; -import { JELLYFIN_TICKS_PER_MS } from './jellyfin.constants'; +} from '@maintainerr/contracts' +import { JELLYFIN_TICKS_PER_MS } from './jellyfin.constants' /** * Extended BaseItemDto that includes fields returned by the Jellyfin API @@ -29,7 +29,7 @@ import { JELLYFIN_TICKS_PER_MS } from './jellyfin.constants'; * in API responses, but is missing from the SDK's BaseItemDto definition. */ interface JellyfinItemDto extends BaseItemDto { - DateLastSaved?: string; + DateLastSaved?: string } export class JellyfinMapper { @@ -37,18 +37,18 @@ export class JellyfinMapper { switch (kind) { case BaseItemKind.Movie: case 'Movie': - return 'movie'; + return 'movie' case BaseItemKind.Series: case 'Series': - return 'show'; + return 'show' case BaseItemKind.Season: case 'Season': - return 'season'; + return 'season' case BaseItemKind.Episode: case 'Episode': - return 'episode'; + return 'episode' default: - return 'movie'; + return 'movie' } } @@ -58,15 +58,15 @@ export class JellyfinMapper { static toBaseItemKind(type: MediaItemType): BaseItemKind { switch (type) { case 'movie': - return BaseItemKind.Movie; + return BaseItemKind.Movie case 'show': - return BaseItemKind.Series; + return BaseItemKind.Series case 'season': - return BaseItemKind.Season; + return BaseItemKind.Season case 'episode': - return BaseItemKind.Episode; + return BaseItemKind.Episode default: - return BaseItemKind.Movie; + return BaseItemKind.Movie } } @@ -75,9 +75,9 @@ export class JellyfinMapper { */ static toBaseItemKinds(types?: MediaItemType[]): BaseItemKind[] { if (!types?.length) { - return [BaseItemKind.Movie, BaseItemKind.Series]; + return [BaseItemKind.Movie, BaseItemKind.Series] } - return types.map((type) => JellyfinMapper.toBaseItemKind(type)); + return types.map((type) => JellyfinMapper.toBaseItemKind(type)) } /** @@ -95,24 +95,24 @@ export class JellyfinMapper { imdb: [], tmdb: [], tvdb: [], - }; + } if (!providerIds) { - return result; + return result } // Jellyfin uses capitalized keys if (providerIds.Imdb) { - result.imdb.push(providerIds.Imdb); + result.imdb.push(providerIds.Imdb) } if (providerIds.Tmdb) { - result.tmdb.push(providerIds.Tmdb); + result.tmdb.push(providerIds.Tmdb) } if (providerIds.Tvdb) { - result.tvdb.push(providerIds.Tvdb); + result.tvdb.push(providerIds.Tvdb) } - return result; + return result } /** @@ -127,21 +127,21 @@ export class JellyfinMapper { * is the show), we use SeriesId as parentId for seasons. */ private static getParentId(item: BaseItemDto): string | undefined { - const itemType = JellyfinMapper.toMediaItemType(item.Type); + const itemType = JellyfinMapper.toMediaItemType(item.Type) // For seasons, the "parent" should be the show (SeriesId), not the library (ParentId) if (itemType === 'season') { - return item.SeriesId || item.ParentId || undefined; + return item.SeriesId || item.ParentId || undefined } // For episodes, Jellyfin provides SeasonId for the parent season. // ParentId may refer to the series or library depending on context. if (itemType === 'episode') { - return item.SeasonId || item.ParentId || undefined; + return item.SeasonId || item.ParentId || undefined } // For all other types, use the standard ParentId - return item.ParentId || undefined; + return item.ParentId || undefined } /** @@ -153,23 +153,23 @@ export class JellyfinMapper { * - Movies/Shows: no grandparent */ private static getGrandparentId(item: BaseItemDto): string | undefined { - const itemType = JellyfinMapper.toMediaItemType(item.Type); + const itemType = JellyfinMapper.toMediaItemType(item.Type) // Only episodes have a meaningful grandparent (the show) if (itemType === 'episode') { - return item.SeriesId || undefined; + return item.SeriesId || undefined } // Seasons, movies, and shows don't have a useful grandparent - return undefined; + return undefined } /** * Convert a Jellyfin BaseItemDto to a MediaItem. */ static toMediaItem(item: BaseItemDto): MediaItem { - const parentId = JellyfinMapper.getParentId(item); - const grandparentId = JellyfinMapper.getGrandparentId(item); + const parentId = JellyfinMapper.getParentId(item) + const grandparentId = JellyfinMapper.getGrandparentId(item) return { id: item.Id || '', @@ -220,7 +220,7 @@ export class JellyfinMapper { parentIndex: item.ParentIndexNumber || undefined, collections: undefined, // Need to query separately labels: item.Tags || undefined, - }; + } } /** @@ -232,7 +232,7 @@ export class JellyfinMapper { title: item.Name || '', type: JellyfinMapper.toLibraryType(item.CollectionType), agent: undefined, // Jellyfin doesn't expose agent info - }; + } } /** @@ -241,11 +241,11 @@ export class JellyfinMapper { static toLibraryType(collectionType?: string | null): 'movie' | 'show' { switch (collectionType?.toLowerCase()) { case 'movies': - return 'movie'; + return 'movie' case 'tvshows': - return 'show'; + return 'show' default: - return 'movie'; + return 'movie' } } @@ -259,7 +259,7 @@ export class JellyfinMapper { thumb: user.PrimaryImageTag ? `/Users/${user.Id}/Images/Primary` : undefined, - }; + } } /** @@ -277,7 +277,7 @@ export class JellyfinMapper { itemId, watchedAt: lastPlayedDate, progress: progress ?? 100, - }; + } } /** @@ -298,7 +298,7 @@ export class JellyfinMapper { : undefined, smart: false, // Jellyfin doesn't have smart collections libraryId: item.ParentId || undefined, - }; + } } /** @@ -318,7 +318,7 @@ export class JellyfinMapper { updatedAt: (item as JellyfinItemDto).DateLastSaved ? new Date((item as JellyfinItemDto).DateLastSaved!) : undefined, - }; + } } /** @@ -337,19 +337,19 @@ export class JellyfinMapper { name: serverName || undefined, platform: platform || undefined, url: url || undefined, - }; + } } private static toMediaSources( sources?: MediaSourceInfo[] | null, ): MediaSource[] { if (!sources || !Array.isArray(sources)) { - return []; + return [] } return sources.map((source) => { - const videoStream = source.MediaStreams?.find((s) => s.Type === 'Video'); - const audioStream = source.MediaStreams?.find((s) => s.Type === 'Audio'); + const videoStream = source.MediaStreams?.find((s) => s.Type === 'Video') + const audioStream = source.MediaStreams?.find((s) => s.Type === 'Audio') return { id: source.Id || '', @@ -373,19 +373,19 @@ export class JellyfinMapper { : undefined, container: source.Container || undefined, sizeBytes: source.Size || undefined, - }; - }); + } + }) } private static toMediaGenres(genres?: string[] | null): MediaGenre[] { if (!genres || !Array.isArray(genres)) { - return []; + return [] } return genres.map((genre) => ({ id: JellyfinMapper.hashString(genre), name: genre, - })); + })) } /** @@ -393,16 +393,16 @@ export class JellyfinMapper { * Uses djb2 algorithm for fast, reasonable distribution. */ private static hashString(str: string): number { - let hash = 5381; + let hash = 5381 for (let i = 0; i < str.length; i++) { - hash = (hash * 33) ^ str.charCodeAt(i); + hash = (hash * 33) ^ str.charCodeAt(i) } - return hash >>> 0; // Convert to unsigned 32-bit integer + return hash >>> 0 // Convert to unsigned 32-bit integer } private static toMediaActors(people?: BaseItemDto['People']): MediaActor[] { if (!people || !Array.isArray(people)) { - return []; + return [] } return people @@ -414,18 +414,18 @@ export class JellyfinMapper { thumb: actor.PrimaryImageTag ? `/Items/${actor.Id}/Images/Primary` : undefined, - })); + })) } private static toMediaRatings(item: BaseItemDto): MediaRating[] { - const ratings: MediaRating[] = []; + const ratings: MediaRating[] = [] if (item.CommunityRating !== undefined && item.CommunityRating !== null) { ratings.push({ source: 'community', value: item.CommunityRating, type: 'audience', - }); + }) } if (item.CriticRating !== undefined && item.CriticRating !== null) { @@ -433,9 +433,9 @@ export class JellyfinMapper { source: 'critic', value: item.CriticRating / 10, // Jellyfin uses 0-100, normalize to 0-10 type: 'critic', - }); + }) } - return ratings; + return ratings } } diff --git a/apps/server/src/modules/api/media-server/jellyfin/jellyfin.module.ts b/apps/server/src/modules/api/media-server/jellyfin/jellyfin.module.ts index 18b4e474..1ed16b45 100644 --- a/apps/server/src/modules/api/media-server/jellyfin/jellyfin.module.ts +++ b/apps/server/src/modules/api/media-server/jellyfin/jellyfin.module.ts @@ -1,6 +1,6 @@ -import { forwardRef, Module } from '@nestjs/common'; -import { SettingsModule } from '../../../settings/settings.module'; -import { JellyfinAdapterService } from './jellyfin-adapter.service'; +import { forwardRef, Module } from '@nestjs/common' +import { SettingsModule } from '../../../settings/settings.module' +import { JellyfinAdapterService } from './jellyfin-adapter.service' /** * Jellyfin Module diff --git a/apps/server/src/modules/api/media-server/jellyfin/jellyfin.types.ts b/apps/server/src/modules/api/media-server/jellyfin/jellyfin.types.ts index 39a0a4b2..aded5900 100644 --- a/apps/server/src/modules/api/media-server/jellyfin/jellyfin.types.ts +++ b/apps/server/src/modules/api/media-server/jellyfin/jellyfin.types.ts @@ -7,42 +7,42 @@ import type { BaseItemDto, UserDto, UserItemDataDto, -} from '@jellyfin/sdk/lib/generated-client/models'; +} from '@jellyfin/sdk/lib/generated-client/models' -export type JellyfinMediaItem = BaseItemDto; +export type JellyfinMediaItem = BaseItemDto export interface JellyfinUserItemData extends UserItemDataDto { - userId: string; - userName?: string; + userId: string + userName?: string } -export type JellyfinUser = UserDto; +export type JellyfinUser = UserDto export interface JellyfinLibraryFolder { - Id: string; - Name: string; - CollectionType?: string; - Path?: string; + Id: string + Name: string + CollectionType?: string + Path?: string } export interface JellyfinCollectionCreatedResult { - Id: string; + Id: string } export function hasProviderIds(item: BaseItemDto): item is BaseItemDto & { - ProviderIds: NonNullable; + ProviderIds: NonNullable } { - return item.ProviderIds !== undefined && item.ProviderIds !== null; + return item.ProviderIds !== undefined && item.ProviderIds !== null } export function hasUserData( item: BaseItemDto, ): item is BaseItemDto & { UserData: NonNullable } { - return item.UserData !== undefined && item.UserData !== null; + return item.UserData !== undefined && item.UserData !== null } export function hasMediaSources(item: BaseItemDto): item is BaseItemDto & { - MediaSources: NonNullable; + MediaSources: NonNullable } { - return item.MediaSources !== undefined && item.MediaSources !== null; + return item.MediaSources !== undefined && item.MediaSources !== null } diff --git a/apps/server/src/modules/api/media-server/media-server.constants.ts b/apps/server/src/modules/api/media-server/media-server.constants.ts index 2a38da65..598b16af 100644 --- a/apps/server/src/modules/api/media-server/media-server.constants.ts +++ b/apps/server/src/modules/api/media-server/media-server.constants.ts @@ -1,4 +1,4 @@ -import { MediaServerFeature, MediaServerType } from '@maintainerr/contracts'; +import { MediaServerFeature, MediaServerType } from '@maintainerr/contracts' /** * Feature support matrix for media servers. @@ -22,7 +22,7 @@ export const MEDIA_SERVER_FEATURES: Record< // Note: WATCHLIST not supported (no API) // Note: CENTRAL_WATCH_HISTORY not supported (requires user iteration) ]), -}; +} /** * Check if a media server type supports a specific feature. @@ -31,5 +31,5 @@ export function supportsFeature( serverType: MediaServerType, feature: MediaServerFeature, ): boolean { - return MEDIA_SERVER_FEATURES[serverType]?.has(feature) ?? false; + return MEDIA_SERVER_FEATURES[serverType]?.has(feature) ?? false } diff --git a/apps/server/src/modules/api/media-server/media-server.controller.spec.ts b/apps/server/src/modules/api/media-server/media-server.controller.spec.ts index 14d40110..c35fe3d8 100644 --- a/apps/server/src/modules/api/media-server/media-server.controller.spec.ts +++ b/apps/server/src/modules/api/media-server/media-server.controller.spec.ts @@ -1,7 +1,7 @@ -import { BadRequestException } from '@nestjs/common'; -import { MediaServerController } from './media-server.controller'; -import { MediaServerFactory } from './media-server.factory'; -import { IMediaServerService } from './media-server.interface'; +import { BadRequestException } from '@nestjs/common' +import { MediaServerController } from './media-server.controller' +import { MediaServerFactory } from './media-server.factory' +import { IMediaServerService } from './media-server.interface' /** * MediaServerController Tests @@ -14,9 +14,9 @@ import { IMediaServerService } from './media-server.interface'; * as they contain no logic - they just delegate to the service. */ describe('MediaServerController', () => { - let controller: MediaServerController; - let mockMediaServerFactory: jest.Mocked; - let mockMediaServerService: jest.Mocked; + let controller: MediaServerController + let mockMediaServerFactory: jest.Mocked + let mockMediaServerService: jest.Mocked beforeEach(() => { mockMediaServerService = { @@ -27,139 +27,139 @@ describe('MediaServerController', () => { limit: 50, }), updateCollectionVisibility: jest.fn().mockResolvedValue(undefined), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked mockMediaServerFactory = { getService: jest.fn().mockResolvedValue(mockMediaServerService), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked - controller = new MediaServerController(mockMediaServerFactory); - }); + controller = new MediaServerController(mockMediaServerFactory) + }) describe('getLibraryContent - Pagination Logic', () => { it('should use default pagination (page 1, limit 50, offset 0)', async () => { - await controller.getLibraryContent('lib1'); + await controller.getLibraryContent('lib1') expect(mockMediaServerService.getLibraryContents).toHaveBeenCalledWith( 'lib1', { offset: 0, limit: 50, type: undefined }, - ); - }); + ) + }) it('should calculate offset correctly for page 2 with limit 50', async () => { - await controller.getLibraryContent('lib1', 2, 50); + await controller.getLibraryContent('lib1', 2, 50) // offset = (page - 1) * limit = (2 - 1) * 50 = 50 expect(mockMediaServerService.getLibraryContents).toHaveBeenCalledWith( 'lib1', { offset: 50, limit: 50, type: undefined }, - ); - }); + ) + }) it('should calculate offset correctly for page 3 with limit 25', async () => { - await controller.getLibraryContent('lib1', 3, 25); + await controller.getLibraryContent('lib1', 3, 25) // offset = (page - 1) * limit = (3 - 1) * 25 = 50 expect(mockMediaServerService.getLibraryContents).toHaveBeenCalledWith( 'lib1', { offset: 50, limit: 25, type: undefined }, - ); - }); + ) + }) it('should calculate offset correctly for page 5 with limit 10', async () => { - await controller.getLibraryContent('lib1', 5, 10); + await controller.getLibraryContent('lib1', 5, 10) // offset = (page - 1) * limit = (5 - 1) * 10 = 40 expect(mockMediaServerService.getLibraryContents).toHaveBeenCalledWith( 'lib1', { offset: 40, limit: 10, type: undefined }, - ); - }); + ) + }) it('should pass type filter to service', async () => { - await controller.getLibraryContent('lib1', 1, 50, 'movie'); + await controller.getLibraryContent('lib1', 1, 50, 'movie') expect(mockMediaServerService.getLibraryContents).toHaveBeenCalledWith( 'lib1', { offset: 0, limit: 50, type: 'movie' }, - ); - }); - }); + ) + }) + }) describe('updateCollectionVisibility - Validation Logic', () => { it('should throw BadRequestException when libraryId is missing', async () => { const settings = { collectionId: 'coll1', recommended: true, - } as any; + } as any await expect( controller.updateCollectionVisibility(settings), - ).rejects.toThrow(BadRequestException); - }); + ).rejects.toThrow(BadRequestException) + }) it('should throw BadRequestException when collectionId is missing', async () => { const settings = { libraryId: '1', recommended: true, - } as any; + } as any await expect( controller.updateCollectionVisibility(settings), - ).rejects.toThrow(BadRequestException); - }); + ).rejects.toThrow(BadRequestException) + }) it('should throw BadRequestException when no visibility settings provided', async () => { const settings = { libraryId: '1', collectionId: 'coll1', - } as any; + } as any await expect( controller.updateCollectionVisibility(settings), - ).rejects.toThrow(BadRequestException); - }); + ).rejects.toThrow(BadRequestException) + }) it('should accept valid settings with recommended', async () => { const settings = { libraryId: '1', collectionId: 'coll1', recommended: true, - }; + } - await controller.updateCollectionVisibility(settings); + await controller.updateCollectionVisibility(settings) expect( mockMediaServerService.updateCollectionVisibility, - ).toHaveBeenCalledWith(settings); - }); + ).toHaveBeenCalledWith(settings) + }) it('should accept valid settings with ownHome', async () => { const settings = { libraryId: '1', collectionId: 'coll1', ownHome: true, - }; + } - await controller.updateCollectionVisibility(settings); + await controller.updateCollectionVisibility(settings) expect( mockMediaServerService.updateCollectionVisibility, - ).toHaveBeenCalledWith(settings); - }); + ).toHaveBeenCalledWith(settings) + }) it('should accept valid settings with sharedHome', async () => { const settings = { libraryId: '1', collectionId: 'coll1', sharedHome: false, - }; + } - await controller.updateCollectionVisibility(settings); + await controller.updateCollectionVisibility(settings) expect( mockMediaServerService.updateCollectionVisibility, - ).toHaveBeenCalledWith(settings); - }); - }); -}); + ).toHaveBeenCalledWith(settings) + }) + }) +}) diff --git a/apps/server/src/modules/api/media-server/media-server.controller.ts b/apps/server/src/modules/api/media-server/media-server.controller.ts index acdea66b..3c266ede 100644 --- a/apps/server/src/modules/api/media-server/media-server.controller.ts +++ b/apps/server/src/modules/api/media-server/media-server.controller.ts @@ -14,7 +14,7 @@ import { PagedResult, UpdateCollectionParams, WatchRecord, -} from '@maintainerr/contracts'; +} from '@maintainerr/contracts' import { BadRequestException, Body, @@ -28,14 +28,14 @@ import { Put, Query, UseGuards, -} from '@nestjs/common'; -import { ZodValidationPipe } from 'nestjs-zod'; -import { z } from 'zod'; -import { MediaServerSetupGuard } from './guards'; -import { MediaServerFactory } from './media-server.factory'; +} from '@nestjs/common' +import { ZodValidationPipe } from 'nestjs-zod' +import { z } from 'zod' +import { MediaServerSetupGuard } from './guards' +import { MediaServerFactory } from './media-server.factory' -const mediaLibrarySortQuerySchema = z.enum(mediaLibrarySortFields).optional(); -const mediaSortOrderQuerySchema = z.enum(mediaSortOrders).optional(); +const mediaLibrarySortQuerySchema = z.enum(mediaLibrarySortFields).optional() +const mediaSortOrderQuerySchema = z.enum(mediaSortOrders).optional() /** * Unified Media Server Controller @@ -48,26 +48,26 @@ const mediaSortOrderQuerySchema = z.enum(mediaSortOrders).optional(); @Controller('api/media-server') @UseGuards(MediaServerSetupGuard) export class MediaServerController { - private readonly logger = new Logger(MediaServerController.name); + private readonly logger = new Logger(MediaServerController.name) constructor(private readonly mediaServerFactory: MediaServerFactory) {} @Get() async getStatus(): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.getStatus(); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.getStatus() } @Get('type') async getServerType(): Promise<{ type: string }> { - const mediaServer = await this.mediaServerFactory.getService(); - return { type: mediaServer.getServerType() }; + const mediaServer = await this.mediaServerFactory.getService() + return { type: mediaServer.getServerType() } } @Get('libraries') async getLibraries(): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return await mediaServer.getLibraries(); + const mediaServer = await this.mediaServerFactory.getService() + return await mediaServer.getLibraries() } @Get('library/:id/content') @@ -81,10 +81,10 @@ export class MediaServerController { @Query('sortOrder', new ZodValidationPipe(mediaSortOrderQuerySchema)) sortOrder?: MediaSortOrder, ): Promise> { - const mediaServer = await this.mediaServerFactory.getService(); - const pageNum = Math.max(page ?? 1, 1); - const size = limit ?? 50; - const offset = (pageNum - 1) * size; + const mediaServer = await this.mediaServerFactory.getService() + const pageNum = Math.max(page ?? 1, 1) + const size = limit ?? 50 + const offset = (pageNum - 1) * size return await mediaServer.getLibraryContents(id, { offset, @@ -92,7 +92,7 @@ export class MediaServerController { type, sort, sortOrder, - }); + }) } @Get('library/:id/content/search/:query') @@ -101,8 +101,8 @@ export class MediaServerController { @Param('query') query: string, @Query('type') type?: MediaItemType, ): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.searchLibraryContents(id, query, type); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.searchLibraryContents(id, query, type) } @Get('library/:id/recent') @@ -110,78 +110,78 @@ export class MediaServerController { @Param('id') id: string, @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, ): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.getRecentlyAdded(id, { limit }); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.getRecentlyAdded(id, { limit }) } @Get('users') async getUsers(): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.getUsers(); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.getUsers() } @Get('user/:id') async getUser(@Param('id') id: string): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.getUser(id); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.getUser(id) } @Get('meta/:id') async getMetadata(@Param('id') id: string): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.getMetadata(id); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.getMetadata(id) } @Get('meta/:id/children') async getChildrenMetadata(@Param('id') id: string): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.getChildrenMetadata(id); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.getChildrenMetadata(id) } @Get('meta/:id/seen') async getWatchHistory(@Param('id') id: string): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.getWatchHistory(id); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.getWatchHistory(id) } @Get('search/:query') async searchContent(@Param('query') query: string): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.searchContent(query); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.searchContent(query) } @Get('library/:id/collections') async getCollections(@Param('id') id: string): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.getCollections(id); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.getCollections(id) } @Get('collection/:id') async getCollection( @Param('id') id: string, ): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.getCollection(id); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.getCollection(id) } @Get('collection/:id/children') async getCollectionChildren(@Param('id') id: string): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.getCollectionChildren(id); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.getCollectionChildren(id) } @Post('collection') async createCollection( @Body() params: CreateCollectionParams, ): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.createCollection(params); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.createCollection(params) } @Delete('collection/:id') async deleteCollection(@Param('id') id: string): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.deleteCollection(id); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.deleteCollection(id) } @Put('collection/:collectionId/item/:itemId') @@ -189,8 +189,8 @@ export class MediaServerController { @Param('collectionId') collectionId: string, @Param('itemId') itemId: string, ): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.addToCollection(collectionId, itemId); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.addToCollection(collectionId, itemId) } @Delete('collection/:collectionId/item/:itemId') @@ -198,8 +198,8 @@ export class MediaServerController { @Param('collectionId') collectionId: string, @Param('itemId') itemId: string, ): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.removeFromCollection(collectionId, itemId); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.removeFromCollection(collectionId, itemId) } // COLLECTION METADATA & VISIBILITY @@ -213,8 +213,8 @@ export class MediaServerController { async updateCollection( @Body() params: UpdateCollectionParams, ): Promise { - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.updateCollection(params); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.updateCollection(params) } /** @@ -234,9 +234,9 @@ export class MediaServerController { ) { throw new BadRequestException( 'libraryId, collectionId, and at least one visibility setting are required.', - ); + ) } - const mediaServer = await this.mediaServerFactory.getService(); - return mediaServer.updateCollectionVisibility(settings); + const mediaServer = await this.mediaServerFactory.getService() + return mediaServer.updateCollectionVisibility(settings) } } diff --git a/apps/server/src/modules/api/media-server/media-server.factory.spec.ts b/apps/server/src/modules/api/media-server/media-server.factory.spec.ts index 62de8685..4f1d38d9 100644 --- a/apps/server/src/modules/api/media-server/media-server.factory.spec.ts +++ b/apps/server/src/modules/api/media-server/media-server.factory.spec.ts @@ -1,35 +1,35 @@ -import { MediaServerType } from '@maintainerr/contracts'; -import { ServiceUnavailableException } from '@nestjs/common'; -import { Settings } from '../../settings/entities/settings.entities'; -import { MediaServerSwitchService } from '../../settings/media-server-switch.service'; -import { SettingsService } from '../../settings/settings.service'; -import { JellyfinAdapterService } from './jellyfin/jellyfin-adapter.service'; -import { MediaServerFactory } from './media-server.factory'; -import { PlexAdapterService } from './plex/plex-adapter.service'; +import { MediaServerType } from '@maintainerr/contracts' +import { ServiceUnavailableException } from '@nestjs/common' +import { Settings } from '../../settings/entities/settings.entities' +import { MediaServerSwitchService } from '../../settings/media-server-switch.service' +import { SettingsService } from '../../settings/settings.service' +import { JellyfinAdapterService } from './jellyfin/jellyfin-adapter.service' +import { MediaServerFactory } from './media-server.factory' +import { PlexAdapterService } from './plex/plex-adapter.service' describe('MediaServerFactory', () => { - let factory: MediaServerFactory; + let factory: MediaServerFactory const settingsService = { getSettings: jest.fn(), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked const mediaServerSwitchService = { isSwitching: jest.fn(), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked const plexAdapter = { isSetup: jest.fn(), initialize: jest.fn(), uninitialize: jest.fn(), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked const jellyfinAdapter = { isSetup: jest.fn(), initialize: jest.fn(), uninitialize: jest.fn(), testConnection: jest.fn(), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked const createSettings = (overrides: Partial = {}): Settings => Object.assign(new Settings(), { @@ -41,65 +41,65 @@ describe('MediaServerFactory', () => { jellyfin_url: null, jellyfin_api_key: null, ...overrides, - }); + }) beforeEach(() => { - jest.clearAllMocks(); + jest.clearAllMocks() factory = new MediaServerFactory( settingsService, mediaServerSwitchService, plexAdapter, jellyfinAdapter, - ); + ) - mediaServerSwitchService.isSwitching.mockReturnValue(false); - plexAdapter.isSetup.mockReturnValue(true); - jellyfinAdapter.isSetup.mockReturnValue(true); - }); + mediaServerSwitchService.isSwitching.mockReturnValue(false) + plexAdapter.isSetup.mockReturnValue(true) + jellyfinAdapter.isSetup.mockReturnValue(true) + }) it('throws ServiceUnavailableException while switch is in progress', async () => { - mediaServerSwitchService.isSwitching.mockReturnValue(true); + mediaServerSwitchService.isSwitching.mockReturnValue(true) await expect(factory.getService()).rejects.toBeInstanceOf( ServiceUnavailableException, - ); - }); + ) + }) it('throws when no media server type is configured', async () => { settingsService.getSettings.mockResolvedValue({ status: 'NOK', code: 0, message: 'missing settings', - }); + }) await expect(factory.getService()).rejects.toThrow( 'No media server type configured', - ); - }); + ) + }) it('returns and initializes Jellyfin adapter when configured', async () => { settingsService.getSettings.mockResolvedValue( createSettings({ media_server_type: MediaServerType.JELLYFIN }), - ); - jellyfinAdapter.isSetup.mockReturnValue(false); + ) + jellyfinAdapter.isSetup.mockReturnValue(false) - const service = await factory.getService(); + const service = await factory.getService() - expect(jellyfinAdapter.initialize).toHaveBeenCalledTimes(1); - expect(service).toBe(jellyfinAdapter); - }); + expect(jellyfinAdapter.initialize).toHaveBeenCalledTimes(1) + expect(service).toBe(jellyfinAdapter) + }) it('returns Plex adapter without initialization if already setup', async () => { settingsService.getSettings.mockResolvedValue( createSettings({ media_server_type: MediaServerType.PLEX }), - ); - plexAdapter.isSetup.mockReturnValue(true); + ) + plexAdapter.isSetup.mockReturnValue(true) - const service = await factory.getService(); + const service = await factory.getService() - expect(plexAdapter.initialize).not.toHaveBeenCalled(); - expect(service).toBe(plexAdapter); - }); + expect(plexAdapter.initialize).not.toHaveBeenCalled() + expect(service).toBe(plexAdapter) + }) it('infers Jellyfin when only Jellyfin credentials exist and type is unset', async () => { settingsService.getSettings.mockResolvedValue( @@ -108,12 +108,12 @@ describe('MediaServerFactory', () => { jellyfin_url: 'http://jellyfin.local:8096', jellyfin_api_key: 'key', }), - ); + ) await expect(factory.getConfiguredServerType()).resolves.toBe( MediaServerType.JELLYFIN, - ); - }); + ) + }) it('prefers explicit configured type over inferred mismatch', async () => { settingsService.getSettings.mockResolvedValue( @@ -124,40 +124,40 @@ describe('MediaServerFactory', () => { plex_port: 32400, plex_auth_token: 'plex-token', }), - ); + ) await expect(factory.getConfiguredServerType()).resolves.toBe( MediaServerType.JELLYFIN, - ); - }); + ) + }) it('uninitializes the correct adapter by server type', () => { - factory.uninitializeServer(MediaServerType.PLEX); - factory.uninitializeServer(MediaServerType.JELLYFIN); + factory.uninitializeServer(MediaServerType.PLEX) + factory.uninitializeServer(MediaServerType.JELLYFIN) - expect(plexAdapter.uninitialize).toHaveBeenCalledTimes(1); - expect(jellyfinAdapter.uninitialize).toHaveBeenCalledTimes(1); - }); + expect(plexAdapter.uninitialize).toHaveBeenCalledTimes(1) + expect(jellyfinAdapter.uninitialize).toHaveBeenCalledTimes(1) + }) it('throws for unsupported type in getServiceByType', async () => { await expect( factory.getServiceByType('EMBY' as unknown as MediaServerType), - ).rejects.toThrow('Unsupported media server type: EMBY'); - }); + ).rejects.toThrow('Unsupported media server type: EMBY') + }) it('initialize does not throw when server type is not configured', async () => { jest .spyOn(factory, 'getService') - .mockRejectedValue(new Error('No media server type configured')); + .mockRejectedValue(new Error('No media server type configured')) - await expect(factory.initialize()).resolves.toBeUndefined(); - }); + await expect(factory.initialize()).resolves.toBeUndefined() + }) it('initialize does not throw on other initialization errors', async () => { jest .spyOn(factory, 'getService') - .mockRejectedValue(new Error('startup failure')); + .mockRejectedValue(new Error('startup failure')) - await expect(factory.initialize()).resolves.toBeUndefined(); - }); -}); + await expect(factory.initialize()).resolves.toBeUndefined() + }) +}) diff --git a/apps/server/src/modules/api/media-server/media-server.factory.ts b/apps/server/src/modules/api/media-server/media-server.factory.ts index 9e95d1dd..9d7895e5 100644 --- a/apps/server/src/modules/api/media-server/media-server.factory.ts +++ b/apps/server/src/modules/api/media-server/media-server.factory.ts @@ -1,23 +1,23 @@ -import { MediaServerType } from '@maintainerr/contracts'; +import { MediaServerType } from '@maintainerr/contracts' import { forwardRef, Inject, Injectable, Logger, ServiceUnavailableException, -} from '@nestjs/common'; -import { Settings } from '../../settings/entities/settings.entities'; -import { MediaServerSwitchService } from '../../settings/media-server-switch.service'; -import { SettingsService } from '../../settings/settings.service'; -import { JellyfinAdapterService } from './jellyfin/jellyfin-adapter.service'; -import { IMediaServerService } from './media-server.interface'; -import { PlexAdapterService } from './plex/plex-adapter.service'; +} from '@nestjs/common' +import { Settings } from '../../settings/entities/settings.entities' +import { MediaServerSwitchService } from '../../settings/media-server-switch.service' +import { SettingsService } from '../../settings/settings.service' +import { JellyfinAdapterService } from './jellyfin/jellyfin-adapter.service' +import { IMediaServerService } from './media-server.interface' +import { PlexAdapterService } from './plex/plex-adapter.service' /** * Type guard to check if settings response is a Settings object */ function isSettings(obj: unknown): obj is Settings { - return obj !== null && typeof obj === 'object' && 'media_server_type' in obj; + return obj !== null && typeof obj === 'object' && 'media_server_type' in obj } /** @@ -31,7 +31,7 @@ function isSettings(obj: unknown): obj is Settings { */ @Injectable() export class MediaServerFactory { - private readonly logger = new Logger(MediaServerFactory.name); + private readonly logger = new Logger(MediaServerFactory.name) constructor( @Inject(forwardRef(() => SettingsService)) @@ -48,18 +48,18 @@ export class MediaServerFactory { */ async initialize(): Promise { try { - await this.getService(); + await this.getService() } catch (err) { - const message = err instanceof Error ? err.message : ''; + const message = err instanceof Error ? err.message : '' if (message === 'No media server type configured') { this.logger.log( 'No media server configured yet - skipping initialization', - ); + ) } else { // Log the actual error for debugging, but don't crash the app this.logger.warn( `Media server could not be initialized during startup: ${message}`, - ); + ) } } } @@ -72,15 +72,15 @@ export class MediaServerFactory { if (this.mediaServerSwitchService.isSwitching()) { throw new ServiceUnavailableException( 'Media server switch is in progress. Please try again shortly.', - ); + ) } - const serverType = await this.getConfiguredServerType(); + const serverType = await this.getConfiguredServerType() if (!serverType) { - throw new Error('No media server type configured'); + throw new Error('No media server type configured') } - return await this.getServiceByType(serverType); + return await this.getServiceByType(serverType) } /** @@ -94,18 +94,18 @@ export class MediaServerFactory { switch (serverType) { case MediaServerType.JELLYFIN: if (!this.jellyfinAdapter.isSetup()) { - await this.jellyfinAdapter.initialize(); + await this.jellyfinAdapter.initialize() } - return this.jellyfinAdapter; + return this.jellyfinAdapter case MediaServerType.PLEX: if (!this.plexAdapter.isSetup()) { - await this.plexAdapter.initialize(); + await this.plexAdapter.initialize() } - return this.plexAdapter; + return this.plexAdapter default: - throw new Error(`Unsupported media server type: ${serverType}`); + throw new Error(`Unsupported media server type: ${serverType}`) } } @@ -113,29 +113,29 @@ export class MediaServerFactory { * Get the currently configured media server type. */ async getConfiguredServerType(): Promise { - const settings = await this.settingsService.getSettings(); + const settings = await this.settingsService.getSettings() if (!isSettings(settings)) { - return null; + return null } - const configuredType = settings.media_server_type as MediaServerType | null; + const configuredType = settings.media_server_type as MediaServerType | null const jellyfinConfigured = Boolean( settings.jellyfin_url && settings.jellyfin_api_key, - ); + ) const plexConfigured = Boolean( settings.plex_hostname && settings.plex_name && settings.plex_port && settings.plex_auth_token, - ); + ) const inferredType = this.resolveServerType( plexConfigured, jellyfinConfigured, - ); + ) if (!configuredType) { - return inferredType; + return inferredType } // Always respect the user's explicitly configured server type. @@ -143,10 +143,10 @@ export class MediaServerFactory { if (inferredType && configuredType !== inferredType) { this.logger.warn( `Configured server type '${configuredType}' differs from inferred type '${inferredType}'. Using configured type.`, - ); + ) } - return configuredType; + return configuredType } /** @@ -156,13 +156,13 @@ export class MediaServerFactory { uninitializeServer(serverType: MediaServerType): void { switch (serverType) { case MediaServerType.PLEX: - this.plexAdapter.uninitialize(); - break; + this.plexAdapter.uninitialize() + break case MediaServerType.JELLYFIN: - this.jellyfinAdapter.uninitialize(); - break; + this.jellyfinAdapter.uninitialize() + break default: - throw new Error(`Unsupported media server type: ${serverType}`); + throw new Error(`Unsupported media server type: ${serverType}`) } } @@ -174,13 +174,13 @@ export class MediaServerFactory { url: string, apiKey: string, ): Promise<{ - success: boolean; - serverName?: string; - version?: string; - error?: string; - users?: Array<{ id: string; name: string }>; + success: boolean + serverName?: string + version?: string + error?: string + users?: Array<{ id: string; name: string }> }> { - return this.jellyfinAdapter.testConnection(url, apiKey); + return this.jellyfinAdapter.testConnection(url, apiKey) } private resolveServerType( @@ -188,14 +188,14 @@ export class MediaServerFactory { jellyfinConfigured: boolean, ): MediaServerType | null { if (jellyfinConfigured && !plexConfigured) { - return MediaServerType.JELLYFIN; + return MediaServerType.JELLYFIN } if (plexConfigured && !jellyfinConfigured) { - return MediaServerType.PLEX; + return MediaServerType.PLEX } // Both configured or neither configured - can't infer - return null; + return null } } diff --git a/apps/server/src/modules/api/media-server/media-server.interface.ts b/apps/server/src/modules/api/media-server/media-server.interface.ts index 92554496..c68bcb62 100644 --- a/apps/server/src/modules/api/media-server/media-server.interface.ts +++ b/apps/server/src/modules/api/media-server/media-server.interface.ts @@ -15,7 +15,7 @@ import { RecentlyAddedOptions, UpdateCollectionParams, WatchRecord, -} from '@maintainerr/contracts'; +} from '@maintainerr/contracts' /** * Core interface for media server implementations. @@ -36,50 +36,50 @@ export interface IMediaServerService { * Initialize the connection to the media server. * Should validate connection and cache server info. */ - initialize(): Promise; + initialize(): Promise /** * Cleanup resources and connections. * Should clear caches and reset state. */ - uninitialize(): void; + uninitialize(): void /** * Check if the service is properly initialized and ready for use. */ - isSetup(): boolean; + isSetup(): boolean /** * Get the type of media server this service connects to. */ - getServerType(): MediaServerType; + getServerType(): MediaServerType /** * Check if a specific feature is supported by this media server. * Used to conditionally enable/disable functionality. */ - supportsFeature(feature: MediaServerFeature): boolean; + supportsFeature(feature: MediaServerFeature): boolean /** * Get server status and version information. * Returns undefined if server is unreachable. */ - getStatus(): Promise; + getStatus(): Promise /** * Get all users with access to the media server. */ - getUsers(): Promise; + getUsers(): Promise /** * Get a specific user by ID. */ - getUser(id: string): Promise; + getUser(id: string): Promise /** * Get all libraries available on the media server. */ - getLibraries(): Promise; + getLibraries(): Promise /** * Get contents of a specific library with optional pagination and filtering. @@ -87,7 +87,7 @@ export interface IMediaServerService { getLibraryContents( libraryId: string, options?: LibraryQueryOptions, - ): Promise>; + ): Promise> /** * Get total count of items in a library, optionally filtered by type. @@ -95,7 +95,7 @@ export interface IMediaServerService { getLibraryContentCount( libraryId: string, type?: MediaItemType, - ): Promise; + ): Promise /** * Search within a specific library. @@ -104,17 +104,17 @@ export interface IMediaServerService { libraryId: string, query: string, type?: MediaItemType, - ): Promise; + ): Promise /** * Get detailed metadata for a specific item. */ - getMetadata(itemId: string): Promise; + getMetadata(itemId: string): Promise /** * Get child items (seasons for shows, episodes for seasons). */ - getChildrenMetadata(parentId: string): Promise; + getChildrenMetadata(parentId: string): Promise /** * Get recently added items from a library. @@ -122,12 +122,12 @@ export interface IMediaServerService { getRecentlyAdded( libraryId: string, options?: RecentlyAddedOptions, - ): Promise; + ): Promise /** * Search across all content on the server. */ - searchContent(query: string): Promise; + searchContent(query: string): Promise /** * Get watch history for a specific item. @@ -135,59 +135,59 @@ export interface IMediaServerService { * - Plex: Single API call to history endpoint * - Jellyfin: Requires iterating over users */ - getWatchHistory(itemId: string): Promise; + getWatchHistory(itemId: string): Promise /** * Get list of user IDs who have watched/seen a specific item. * Convenience method built on top of getWatchHistory. */ - getItemSeenBy(itemId: string): Promise; + getItemSeenBy(itemId: string): Promise /** * Get all collections in a library. */ - getCollections(libraryId: string): Promise; + getCollections(libraryId: string): Promise /** * Get a specific collection by ID. */ - getCollection(collectionId: string): Promise; + getCollection(collectionId: string): Promise /** * Create a new collection. * @throws Error if creation fails */ - createCollection(params: CreateCollectionParams): Promise; + createCollection(params: CreateCollectionParams): Promise /** * Delete a collection. * @throws Error if deletion fails */ - deleteCollection(collectionId: string): Promise; + deleteCollection(collectionId: string): Promise /** * Get items in a collection. * Returns empty array if collection not found or on error. */ - getCollectionChildren(collectionId: string): Promise; + getCollectionChildren(collectionId: string): Promise /** * Add an item to a collection. * @throws Error if operation fails */ - addToCollection(collectionId: string, itemId: string): Promise; + addToCollection(collectionId: string, itemId: string): Promise /** * Remove an item from a collection. * @throws Error if operation fails */ - removeFromCollection(collectionId: string, itemId: string): Promise; + removeFromCollection(collectionId: string, itemId: string): Promise /** * Update a collection's metadata (title, summary, etc.) * @throws Error if not supported by media server or update fails */ - updateCollection(params: UpdateCollectionParams): Promise; + updateCollection(params: UpdateCollectionParams): Promise /** * Update collection visibility/hub settings. @@ -195,24 +195,24 @@ export interface IMediaServerService { */ updateCollectionVisibility( settings: CollectionVisibilitySettings, - ): Promise; + ): Promise /** * Get watchlist items for a user. * Only available on Plex (requires Plex.tv API). */ - getWatchlistForUser?(userId: string): Promise; + getWatchlistForUser?(userId: string): Promise /** * Get playlists in a library. */ - getPlaylists(libraryId: string): Promise; + getPlaylists(libraryId: string): Promise /** * Delete an item from disk. * This is a destructive operation! */ - deleteFromDisk(itemId: string): Promise; + deleteFromDisk(itemId: string): Promise /** * Get all media server IDs for a context action (add/remove from collection). @@ -227,11 +227,11 @@ export interface IMediaServerService { collectionType: MediaItemType | undefined, context: { type: MediaItemType; id: string }, mediaId: string, - ): Promise; + ): Promise /** * Reset metadata cache. * @param itemId - If provided, only reset cache for this item. Otherwise reset all. */ - resetMetadataCache(itemId?: string): void; + resetMetadataCache(itemId?: string): void } diff --git a/apps/server/src/modules/api/media-server/media-server.module.ts b/apps/server/src/modules/api/media-server/media-server.module.ts index 7acbf963..4e659950 100644 --- a/apps/server/src/modules/api/media-server/media-server.module.ts +++ b/apps/server/src/modules/api/media-server/media-server.module.ts @@ -1,12 +1,12 @@ -import { forwardRef, Module } from '@nestjs/common'; -import { SettingsModule } from '../../settings/settings.module'; -import { PlexApiModule } from '../plex-api/plex-api.module'; -import { MediaServerSetupGuard } from './guards/media-server-setup.guard'; -import { JellyfinAdapterService } from './jellyfin/jellyfin-adapter.service'; -import { JellyfinModule } from './jellyfin/jellyfin.module'; -import { MediaServerController } from './media-server.controller'; -import { MediaServerFactory } from './media-server.factory'; -import { PlexAdapterService } from './plex/plex-adapter.service'; +import { forwardRef, Module } from '@nestjs/common' +import { SettingsModule } from '../../settings/settings.module' +import { PlexApiModule } from '../plex-api/plex-api.module' +import { MediaServerSetupGuard } from './guards/media-server-setup.guard' +import { JellyfinAdapterService } from './jellyfin/jellyfin-adapter.service' +import { JellyfinModule } from './jellyfin/jellyfin.module' +import { MediaServerController } from './media-server.controller' +import { MediaServerFactory } from './media-server.factory' +import { PlexAdapterService } from './plex/plex-adapter.service' /** * Media Server Module diff --git a/apps/server/src/modules/api/media-server/plex/plex-adapter.service.spec.ts b/apps/server/src/modules/api/media-server/plex/plex-adapter.service.spec.ts index 1bb5f535..776e6938 100644 --- a/apps/server/src/modules/api/media-server/plex/plex-adapter.service.spec.ts +++ b/apps/server/src/modules/api/media-server/plex/plex-adapter.service.spec.ts @@ -1,44 +1,44 @@ -import { MediaServerFeature, MediaServerType } from '@maintainerr/contracts'; -import { Mocked, TestBed } from '@suites/unit'; -import { PlexApiService } from '../../plex-api/plex-api.service'; -import { PlexAdapterService } from './plex-adapter.service'; +import { MediaServerFeature, MediaServerType } from '@maintainerr/contracts' +import { Mocked, TestBed } from '@suites/unit' +import { PlexApiService } from '../../plex-api/plex-api.service' +import { PlexAdapterService } from './plex-adapter.service' describe('PlexAdapterService', () => { - let service: PlexAdapterService; - let plexApi: Mocked; + let service: PlexAdapterService + let plexApi: Mocked beforeEach(async () => { const { unit, unitRef } = - await TestBed.solitary(PlexAdapterService).compile(); + await TestBed.solitary(PlexAdapterService).compile() - service = unit; - plexApi = unitRef.get(PlexApiService); - }); + service = unit + plexApi = unitRef.get(PlexApiService) + }) describe('lifecycle', () => { it('should delegate isSetup to PlexApiService', () => { - plexApi.isPlexSetup.mockReturnValue(false); - expect(service.isSetup()).toBe(false); + plexApi.isPlexSetup.mockReturnValue(false) + expect(service.isSetup()).toBe(false) - plexApi.isPlexSetup.mockReturnValue(true); - expect(service.isSetup()).toBe(true); - }); + plexApi.isPlexSetup.mockReturnValue(true) + expect(service.isSetup()).toBe(true) + }) it('should return PLEX as server type', () => { - expect(service.getServerType()).toBe(MediaServerType.PLEX); - }); + expect(service.getServerType()).toBe(MediaServerType.PLEX) + }) it('should delegate initialize to PlexApiService', async () => { - plexApi.initialize.mockResolvedValue(undefined); - await service.initialize(); - expect(plexApi.initialize).toHaveBeenCalled(); - }); + plexApi.initialize.mockResolvedValue(undefined) + await service.initialize() + expect(plexApi.initialize).toHaveBeenCalled() + }) it('should delegate uninitialize to PlexApiService', () => { - service.uninitialize(); - expect(plexApi.uninitialize).toHaveBeenCalled(); - }); - }); + service.uninitialize() + expect(plexApi.uninitialize).toHaveBeenCalled() + }) + }) describe('feature detection', () => { it.each([ @@ -48,99 +48,99 @@ describe('PlexAdapterService', () => { [MediaServerFeature.WATCHLIST, true], [MediaServerFeature.CENTRAL_WATCH_HISTORY, true], ])('supportsFeature(%s) is %s', (feature, expected) => { - expect(service.supportsFeature(feature)).toBe(expected); - }); - }); + expect(service.supportsFeature(feature)).toBe(expected) + }) + }) describe('cache management', () => { it('should delegate resetMetadataCache to PlexApiService when itemId provided', () => { - service.resetMetadataCache('item123'); - expect(plexApi.resetMetadataCache).toHaveBeenCalledWith('item123'); - }); + service.resetMetadataCache('item123') + expect(plexApi.resetMetadataCache).toHaveBeenCalledWith('item123') + }) it('should not call PlexApiService when itemId is undefined', () => { - service.resetMetadataCache(); - expect(plexApi.resetMetadataCache).not.toHaveBeenCalled(); - }); - }); + service.resetMetadataCache() + expect(plexApi.resetMetadataCache).not.toHaveBeenCalled() + }) + }) describe('getStatus', () => { it('should return undefined when PlexApiService returns undefined', async () => { - plexApi.getStatus.mockResolvedValue(undefined); - const status = await service.getStatus(); - expect(status).toBeUndefined(); - }); + plexApi.getStatus.mockResolvedValue(undefined) + const status = await service.getStatus() + expect(status).toBeUndefined() + }) it('should map Plex status to MediaServerStatus', async () => { plexApi.getStatus.mockResolvedValue({ machineIdentifier: 'machine123', version: '1.25.0', - } as any); + } as any) - const status = await service.getStatus(); - expect(status).toBeDefined(); - expect(status?.machineId).toBe('machine123'); - expect(status?.version).toBe('1.25.0'); + const status = await service.getStatus() + expect(status).toBeDefined() + expect(status?.machineId).toBe('machine123') + expect(status?.version).toBe('1.25.0') // Note: name is passed separately to the mapper and is undefined in adapter - expect(status?.name).toBeUndefined(); - }); - }); + expect(status?.name).toBeUndefined() + }) + }) describe('getUsers', () => { it('should return empty array when PlexApiService returns undefined', async () => { - plexApi.getUsers.mockResolvedValue(undefined); - const users = await service.getUsers(); - expect(users).toEqual([]); - }); + plexApi.getUsers.mockResolvedValue(undefined) + const users = await service.getUsers() + expect(users).toEqual([]) + }) it('should map Plex users to MediaUser array', async () => { plexApi.getUsers.mockResolvedValue([ { id: 1, name: 'user1', thumb: '/thumb1' }, { id: 2, name: 'user2', thumb: '/thumb2' }, - ] as any); + ] as any) - const users = await service.getUsers(); - expect(users).toHaveLength(2); - expect(users[0].id).toBe('1'); - expect(users[0].name).toBe('user1'); - }); - }); + const users = await service.getUsers() + expect(users).toHaveLength(2) + expect(users[0].id).toBe('1') + expect(users[0].name).toBe('user1') + }) + }) describe('getLibraries', () => { it('should return empty array when PlexApiService returns undefined', async () => { - plexApi.getLibraries.mockResolvedValue(undefined); - const libraries = await service.getLibraries(); - expect(libraries).toEqual([]); - }); + plexApi.getLibraries.mockResolvedValue(undefined) + const libraries = await service.getLibraries() + expect(libraries).toEqual([]) + }) it('should map Plex libraries to MediaLibrary array', async () => { plexApi.getLibraries.mockResolvedValue([ { key: '1', title: 'Movies', type: 'movie' }, { key: '2', title: 'TV Shows', type: 'show' }, - ] as any); + ] as any) - const libraries = await service.getLibraries(); - expect(libraries).toHaveLength(2); - expect(libraries[0].id).toBe('1'); - expect(libraries[0].title).toBe('Movies'); - }); - }); + const libraries = await service.getLibraries() + expect(libraries).toHaveLength(2) + expect(libraries[0].id).toBe('1') + expect(libraries[0].title).toBe('Movies') + }) + }) describe('getLibraryContents', () => { it('should return empty result for empty libraryId', async () => { - const result = await service.getLibraryContents(''); - expect(result.items).toEqual([]); - expect(result.totalSize).toBe(0); - }); + const result = await service.getLibraryContents('') + expect(result.items).toEqual([]) + expect(result.totalSize).toBe(0) + }) it('should return empty result for Jellyfin-style UUID', async () => { // Jellyfin uses 32-char hex UUIDs const result = await service.getLibraryContents( 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', - ); - expect(result.items).toEqual([]); - expect(result.totalSize).toBe(0); - }); + ) + expect(result.items).toEqual([]) + expect(result.totalSize).toBe(0) + }) it('should call PlexApiService with correct parameters', async () => { plexApi.getLibraryContents.mockResolvedValue({ @@ -148,19 +148,19 @@ describe('PlexAdapterService', () => { Metadata: [], totalSize: 0, }, - } as any); + } as any) - await service.getLibraryContents('1', { offset: 0, limit: 50 }); - expect(plexApi.getLibraryContents).toHaveBeenCalled(); - }); - }); + await service.getLibraryContents('1', { offset: 0, limit: 50 }) + expect(plexApi.getLibraryContents).toHaveBeenCalled() + }) + }) describe('getWatchHistory', () => { it('should return empty array when PlexApiService returns undefined', async () => { - plexApi.getWatchHistory.mockResolvedValue(undefined); - const history = await service.getWatchHistory('item123'); - expect(history).toEqual([]); - }); + plexApi.getWatchHistory.mockResolvedValue(undefined) + const history = await service.getWatchHistory('item123') + expect(history).toEqual([]) + }) it('should map Plex watch history to WatchRecord array', async () => { plexApi.getWatchHistory.mockResolvedValue([ @@ -169,50 +169,50 @@ describe('PlexAdapterService', () => { ratingKey: 'item123', viewedAt: 1609459200, }, - ] as any); + ] as any) - const history = await service.getWatchHistory('item123'); - expect(history).toHaveLength(1); - expect(history[0].userId).toBe('1'); - expect(history[0].itemId).toBe('item123'); - }); - }); + const history = await service.getWatchHistory('item123') + expect(history).toHaveLength(1) + expect(history[0].userId).toBe('1') + expect(history[0].itemId).toBe('item123') + }) + }) describe('getCollections', () => { it('should return empty array when PlexApiService returns undefined', async () => { - plexApi.getCollections.mockResolvedValue(undefined); - const collections = await service.getCollections('lib123'); - expect(collections).toEqual([]); - }); - }); + plexApi.getCollections.mockResolvedValue(undefined) + const collections = await service.getCollections('lib123') + expect(collections).toEqual([]) + }) + }) describe('searchContent', () => { it('should return empty array when PlexApiService returns undefined', async () => { - plexApi.searchContent.mockResolvedValue(undefined); - const results = await service.searchContent('test'); - expect(results).toEqual([]); - }); - }); + plexApi.searchContent.mockResolvedValue(undefined) + const results = await service.searchContent('test') + expect(results).toEqual([]) + }) + }) describe('collection operations', () => { it('should delegate createCollection to PlexApiService', async () => { plexApi.createCollection.mockResolvedValue({ ratingKey: 'col123', title: 'Test Collection', - } as any); + } as any) const result = await service.createCollection({ libraryId: 'lib1', title: 'Test Collection', type: 'movie', - }); + }) - expect(plexApi.createCollection).toHaveBeenCalled(); - expect(result.id).toBe('col123'); - }); + expect(plexApi.createCollection).toHaveBeenCalled() + expect(result.id).toBe('col123') + }) it('should throw error when collection creation fails', async () => { - plexApi.createCollection.mockResolvedValue(undefined); + plexApi.createCollection.mockResolvedValue(undefined) await expect( service.createCollection({ @@ -220,13 +220,13 @@ describe('PlexAdapterService', () => { title: 'Test Collection', type: 'movie', }), - ).rejects.toThrow('Failed to create collection'); - }); + ).rejects.toThrow('Failed to create collection') + }) it('should delegate deleteCollection to PlexApiService', async () => { - plexApi.deleteCollection.mockResolvedValue(undefined); - await service.deleteCollection('col123'); - expect(plexApi.deleteCollection).toHaveBeenCalledWith('col123'); - }); - }); -}); + plexApi.deleteCollection.mockResolvedValue(undefined) + await service.deleteCollection('col123') + expect(plexApi.deleteCollection).toHaveBeenCalledWith('col123') + }) + }) +}) diff --git a/apps/server/src/modules/api/media-server/plex/plex-adapter.service.ts b/apps/server/src/modules/api/media-server/plex/plex-adapter.service.ts index 631c2013..eeeb691e 100644 --- a/apps/server/src/modules/api/media-server/plex/plex-adapter.service.ts +++ b/apps/server/src/modules/api/media-server/plex/plex-adapter.service.ts @@ -15,14 +15,14 @@ import { RecentlyAddedOptions, UpdateCollectionParams, WatchRecord, -} from '@maintainerr/contracts'; -import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; -import { EPlexDataType } from '../../plex-api/enums/plex-data-type-enum'; -import { PlexApiService } from '../../plex-api/plex-api.service'; -import { supportsFeature } from '../media-server.constants'; -import { IMediaServerService } from '../media-server.interface'; -import { toPlexSort } from './plex.constants'; -import { PlexMapper } from './plex.mapper'; +} from '@maintainerr/contracts' +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common' +import { EPlexDataType } from '../../plex-api/enums/plex-data-type-enum' +import { PlexApiService } from '../../plex-api/plex-api.service' +import { supportsFeature } from '../media-server.constants' +import { IMediaServerService } from '../media-server.interface' +import { toPlexSort } from './plex.constants' +import { PlexMapper } from './plex.mapper' /** * Adapter that wraps PlexApiService to implement IMediaServerService. @@ -33,7 +33,7 @@ import { PlexMapper } from './plex.mapper'; */ @Injectable() export class PlexAdapterService implements IMediaServerService { - private readonly logger = new Logger(PlexAdapterService.name); + private readonly logger = new Logger(PlexAdapterService.name) constructor( @Inject(forwardRef(() => PlexApiService)) @@ -41,47 +41,47 @@ export class PlexAdapterService implements IMediaServerService { ) {} async initialize(): Promise { - await this.plexApi.initialize(); + await this.plexApi.initialize() } uninitialize(): void { - this.plexApi.uninitialize(); + this.plexApi.uninitialize() } isSetup(): boolean { - return this.plexApi.isPlexSetup(); + return this.plexApi.isPlexSetup() } getServerType(): MediaServerType { - return MediaServerType.PLEX; + return MediaServerType.PLEX } supportsFeature(feature: MediaServerFeature): boolean { - return supportsFeature(MediaServerType.PLEX, feature); + return supportsFeature(MediaServerType.PLEX, feature) } async getStatus(): Promise { - const status = await this.plexApi.getStatus(); - if (!status) return undefined; - return PlexMapper.toMediaServerStatus(status); + const status = await this.plexApi.getStatus() + if (!status) return undefined + return PlexMapper.toMediaServerStatus(status) } async getUsers(): Promise { - const users = await this.plexApi.getUsers(); - if (!users) return []; - return users.map(PlexMapper.toMediaUser); + const users = await this.plexApi.getUsers() + if (!users) return [] + return users.map(PlexMapper.toMediaUser) } async getUser(id: string): Promise { - const user = await this.plexApi.getUser(parseInt(id, 10)); - if (!user) return undefined; - return PlexMapper.toMediaUser(user); + const user = await this.plexApi.getUser(parseInt(id, 10)) + if (!user) return undefined + return PlexMapper.toMediaUser(user) } async getLibraries(): Promise { - const libraries = await this.plexApi.getLibraries(); - if (!libraries) return []; - return libraries.map(PlexMapper.toMediaLibrary); + const libraries = await this.plexApi.getLibraries() + if (!libraries) return [] + return libraries.map(PlexMapper.toMediaLibrary) } async getLibraryContents( @@ -90,17 +90,17 @@ export class PlexAdapterService implements IMediaServerService { ): Promise> { // Check for migration issue: Jellyfin uses 32-char hex UUIDs, Plex uses numeric IDs // TODO: Extract migration ID detection to shared utility (see JellyfinAdapterService.isLikelyMigrationId) - const isJellyfinId = /^[a-f0-9]{32}$/i.test(libraryId); + const isJellyfinId = /^[a-f0-9]{32}$/i.test(libraryId) if (!libraryId || libraryId.trim() === '' || isJellyfinId) { this.logger.warn( `Library '${libraryId || '(empty)'}' appears to be from a different media server. Please update the library setting in your rules.`, - ); - return { items: [], totalSize: 0, offset: 0, limit: 50 }; + ) + return { items: [], totalSize: 0, offset: 0, limit: 50 } } const plexType = options?.type ? PlexMapper.toPlexDataType(options.type) - : undefined; + : undefined const response = await this.plexApi.getLibraryContents( libraryId, @@ -110,30 +110,27 @@ export class PlexAdapterService implements IMediaServerService { sort: toPlexSort(options?.sort, options?.sortOrder), }, plexType, - ); + ) const items = response?.items ? response.items.map(PlexMapper.toMediaItem) - : []; + : [] return { items, totalSize: response?.totalSize ?? items.length, offset: options?.offset ?? 0, limit: options?.limit ?? 50, - }; + } } async getLibraryContentCount( libraryId: string, type?: MediaItemType, ): Promise { - const plexType = type ? PlexMapper.toPlexDataType(type) : undefined; - const count = await this.plexApi.getLibraryContentCount( - libraryId, - plexType, - ); - return count ?? 0; + const plexType = type ? PlexMapper.toPlexDataType(type) : undefined + const count = await this.plexApi.getLibraryContentCount(libraryId, plexType) + return count ?? 0 } async searchLibraryContents( @@ -141,28 +138,28 @@ export class PlexAdapterService implements IMediaServerService { query: string, type?: MediaItemType, ): Promise { - const plexType = type ? PlexMapper.toPlexDataType(type) : undefined; + const plexType = type ? PlexMapper.toPlexDataType(type) : undefined const results = await this.plexApi.searchLibraryContents( libraryId, query, plexType, - ); + ) - if (!results) return []; + if (!results) return [] - return results.map(PlexMapper.toMediaItem); + return results.map(PlexMapper.toMediaItem) } async getMetadata(itemId: string): Promise { - const metadata = await this.plexApi.getMetadata(itemId); - if (!metadata) return undefined; - return PlexMapper.metadataToMediaItem(metadata); + const metadata = await this.plexApi.getMetadata(itemId) + if (!metadata) return undefined + return PlexMapper.metadataToMediaItem(metadata) } async getChildrenMetadata(parentId: string): Promise { - const children = await this.plexApi.getChildrenMetadata(parentId); - if (!children) return []; - return children.map(PlexMapper.metadataToMediaItem); + const children = await this.plexApi.getChildrenMetadata(parentId) + if (!children) return [] + return children.map(PlexMapper.metadataToMediaItem) } async getRecentlyAdded( @@ -178,96 +175,96 @@ export class PlexAdapterService implements IMediaServerService { }, undefined, false, - ); - const results = response?.items; + ) + const results = response?.items - if (!results) return []; + if (!results) return [] - const limited = options?.limit ? results.slice(0, options.limit) : results; - return limited.map(PlexMapper.toMediaItem); + const limited = options?.limit ? results.slice(0, options.limit) : results + return limited.map(PlexMapper.toMediaItem) } async searchContent(query: string): Promise { - const results = await this.plexApi.searchContent(query); - if (!results) return []; - return results.map(PlexMapper.metadataToMediaItem); + const results = await this.plexApi.searchContent(query) + if (!results) return [] + return results.map(PlexMapper.metadataToMediaItem) } async getWatchHistory(itemId: string): Promise { - const history = await this.plexApi.getWatchHistory(itemId); - if (!history) return []; - return history.map(PlexMapper.toWatchRecord); + const history = await this.plexApi.getWatchHistory(itemId) + if (!history) return [] + return history.map(PlexMapper.toWatchRecord) } async getItemSeenBy(itemId: string): Promise { - const history = await this.getWatchHistory(itemId); + const history = await this.getWatchHistory(itemId) // Extract unique user IDs - const userIds = new Set(history.map((record) => record.userId)); - return Array.from(userIds); + const userIds = new Set(history.map((record) => record.userId)) + return Array.from(userIds) } async getCollections(libraryId: string): Promise { - const collections = await this.plexApi.getCollections(libraryId); - if (!collections) return []; - return collections.map(PlexMapper.toMediaCollection); + const collections = await this.plexApi.getCollections(libraryId) + if (!collections) return [] + return collections.map(PlexMapper.toMediaCollection) } async getCollection( collectionId: string, ): Promise { - const collection = await this.plexApi.getCollection(collectionId); - if (!collection) return undefined; - return PlexMapper.toMediaCollection(collection); + const collection = await this.plexApi.getCollection(collectionId) + if (!collection) return undefined + return PlexMapper.toMediaCollection(collection) } async createCollection( params: CreateCollectionParams, ): Promise { - const plexType = PlexMapper.toPlexDataType(params.type); + const plexType = PlexMapper.toPlexDataType(params.type) const result = await this.plexApi.createCollection({ libraryId: params.libraryId, type: plexType, title: params.title, summary: params.summary, sortTitle: params.sortTitle, - }); + }) if (!result) { this.logger.error( `Failed to create collection "${params.title}" in library ${params.libraryId}`, - ); + ) throw new Error( `Failed to create collection "${params.title}" in library ${params.libraryId}`, - ); + ) } - return PlexMapper.toMediaCollection(result); + return PlexMapper.toMediaCollection(result) } async deleteCollection(collectionId: string): Promise { try { - await this.plexApi.deleteCollection(collectionId); + await this.plexApi.deleteCollection(collectionId) } catch (error) { - this.logger.error(`Failed to delete collection ${collectionId}`, error); - throw error; + this.logger.error(`Failed to delete collection ${collectionId}`, error) + throw error } } async getCollectionChildren(collectionId: string): Promise { - const children = await this.plexApi.getCollectionChildren(collectionId); - if (!children) return []; - return children.map(PlexMapper.toMediaItem); + const children = await this.plexApi.getCollectionChildren(collectionId) + if (!children) return [] + return children.map(PlexMapper.toMediaItem) } async addToCollection(collectionId: string, itemId: string): Promise { try { - await this.plexApi.addChildToCollection(collectionId, itemId); + await this.plexApi.addChildToCollection(collectionId, itemId) } catch (error) { this.logger.error( `Failed to add item ${itemId} to collection ${collectionId}`, error, - ); - throw error; + ) + throw error } } @@ -276,13 +273,13 @@ export class PlexAdapterService implements IMediaServerService { itemId: string, ): Promise { try { - await this.plexApi.deleteChildFromCollection(collectionId, itemId); + await this.plexApi.deleteChildFromCollection(collectionId, itemId) } catch (error) { this.logger.error( `Failed to remove item ${itemId} from collection ${collectionId}`, error, - ); - throw error; + ) + throw error } } @@ -298,18 +295,18 @@ export class PlexAdapterService implements IMediaServerService { title: params.title, summary: params.summary, sortTitle: params.sortTitle, - }); + }) if (!result) { this.logger.error( `Failed to update collection ${params.collectionId} in library ${params.libraryId}`, - ); + ) throw new Error( `Failed to update collection ${params.collectionId} in library ${params.libraryId}`, - ); + ) } - return PlexMapper.toMediaCollection(result); + return PlexMapper.toMediaCollection(result) } async updateCollectionVisibility( @@ -322,13 +319,13 @@ export class PlexAdapterService implements IMediaServerService { recommended: settings.recommended ?? false, ownHome: settings.ownHome ?? false, sharedHome: settings.sharedHome ?? false, - }); + }) } catch (error) { this.logger.error( `Failed to update visibility for collection ${settings.collectionId}`, error, - ); - throw error; + ) + throw error } } @@ -338,29 +335,29 @@ export class PlexAdapterService implements IMediaServerService { // For now, we can't call this without username - log for debugging this.logger.debug( `getWatchlistForUser called for user ${userId}, but this method requires username which is not available`, - ); - return []; + ) + return [] } async getPlaylists(libraryId: string): Promise { - const playlists = await this.plexApi.getPlaylists(libraryId); - if (!playlists) return []; - return playlists.map(PlexMapper.toMediaPlaylist); + const playlists = await this.plexApi.getPlaylists(libraryId) + if (!playlists) return [] + return playlists.map(PlexMapper.toMediaPlaylist) } async deleteFromDisk(itemId: string): Promise { if (!itemId || itemId.trim() === '') { throw new Error( 'deleteFromDisk called with empty itemId — aborting to prevent unintended deletion', - ); + ) } try { - await this.plexApi.deleteMediaFromDisk(itemId); - this.logger.log(`Successfully deleted Plex item ${itemId} from disk`); + await this.plexApi.deleteMediaFromDisk(itemId) + this.logger.log(`Successfully deleted Plex item ${itemId} from disk`) } catch (error) { - this.logger.error(`Failed to delete item ${itemId} from disk`, error); - throw error; + this.logger.error(`Failed to delete item ${itemId} from disk`, error) + throw error } } @@ -373,13 +370,13 @@ export class PlexAdapterService implements IMediaServerService { collectionType ? PlexMapper.toPlexDataType(collectionType) : undefined, { type: PlexMapper.toPlexDataType(context.type), id: Number(context.id) }, { plexId: Number(mediaId) }, - ); - return result.map((r) => String(r.plexId)); + ) + return result.map((r) => String(r.plexId)) } resetMetadataCache(itemId?: string): void { if (itemId) { - this.plexApi.resetMetadataCache(itemId); + this.plexApi.resetMetadataCache(itemId) } // Note: PlexApiService doesn't support full cache flush through this method // Only individual item cache reset is supported diff --git a/apps/server/src/modules/api/media-server/plex/plex.constants.ts b/apps/server/src/modules/api/media-server/plex/plex.constants.ts index 61c621bc..cdbd97bb 100644 --- a/apps/server/src/modules/api/media-server/plex/plex.constants.ts +++ b/apps/server/src/modules/api/media-server/plex/plex.constants.ts @@ -1,24 +1,24 @@ import { type MediaLibrarySortField, type MediaSortOrder, -} from '@maintainerr/contracts'; +} from '@maintainerr/contracts' const PLEX_SORT_FIELDS: Partial> = { airDate: 'originallyAvailableAt', rating: 'audienceRating', title: 'titleSort', watchCount: 'viewCount', -}; +} export function toPlexSort( sort?: MediaLibrarySortField, sortOrder?: MediaSortOrder, ): string | undefined { - const field = sort ? PLEX_SORT_FIELDS[sort] : undefined; + const field = sort ? PLEX_SORT_FIELDS[sort] : undefined if (!field) { - return undefined; + return undefined } - return `${field}:${sortOrder ?? 'asc'}`; + return `${field}:${sortOrder ?? 'asc'}` } diff --git a/apps/server/src/modules/api/media-server/plex/plex.mapper.spec.ts b/apps/server/src/modules/api/media-server/plex/plex.mapper.spec.ts index 9224fc5c..7cf8c0b6 100644 --- a/apps/server/src/modules/api/media-server/plex/plex.mapper.spec.ts +++ b/apps/server/src/modules/api/media-server/plex/plex.mapper.spec.ts @@ -1,12 +1,12 @@ -import { EPlexDataType } from '../../plex-api/enums/plex-data-type-enum'; -import { PlexCollection } from '../../plex-api/interfaces/collection.interface'; +import { EPlexDataType } from '../../plex-api/enums/plex-data-type-enum' +import { PlexCollection } from '../../plex-api/interfaces/collection.interface' import { PlexLibrary, PlexLibraryItem, PlexSeenBy, PlexUserAccount, -} from '../../plex-api/interfaces/library.interfaces'; -import { PlexMapper } from './plex.mapper'; +} from '../../plex-api/interfaces/library.interfaces' +import { PlexMapper } from './plex.mapper' describe('PlexMapper', () => { describe('toMediaItemType', () => { @@ -17,9 +17,9 @@ describe('PlexMapper', () => { ['episode', 'episode'], ['collection', 'movie'], ])('maps %s to %s', (input, expected) => { - expect(PlexMapper.toMediaItemType(input as any)).toBe(expected); - }); - }); + expect(PlexMapper.toMediaItemType(input as any)).toBe(expected) + }) + }) describe('toPlexDataType', () => { it.each([ @@ -28,74 +28,74 @@ describe('PlexMapper', () => { ['season', EPlexDataType.SEASONS], ['episode', EPlexDataType.EPISODES], ])('maps %s to %s', (input, expected) => { - expect(PlexMapper.toPlexDataType(input as any)).toBe(expected); - }); - }); + expect(PlexMapper.toPlexDataType(input as any)).toBe(expected) + }) + }) describe('plexDataTypeToMediaItemType', () => { it.each([ [EPlexDataType.MOVIES, 'movie'], [EPlexDataType.SHOWS, 'show'], ])('maps %s to %s', (input, expected) => { - expect(PlexMapper.plexDataTypeToMediaItemType(input)).toBe(expected); - }); - }); + expect(PlexMapper.plexDataTypeToMediaItemType(input)).toBe(expected) + }) + }) describe('extractProviderIds', () => { it('should extract IMDB id from guid', () => { - const guids = [{ id: 'imdb://tt1234567' }]; - const result = PlexMapper.extractProviderIds(guids); - expect(result.imdb).toEqual(['tt1234567']); - }); + const guids = [{ id: 'imdb://tt1234567' }] + const result = PlexMapper.extractProviderIds(guids) + expect(result.imdb).toEqual(['tt1234567']) + }) it('should extract TMDB id from guid', () => { - const guids = [{ id: 'tmdb://12345' }]; - const result = PlexMapper.extractProviderIds(guids); - expect(result.tmdb).toEqual(['12345']); - }); + const guids = [{ id: 'tmdb://12345' }] + const result = PlexMapper.extractProviderIds(guids) + expect(result.tmdb).toEqual(['12345']) + }) it('should extract TVDB id from guid', () => { - const guids = [{ id: 'tvdb://67890' }]; - const result = PlexMapper.extractProviderIds(guids); - expect(result.tvdb).toEqual(['67890']); - }); + const guids = [{ id: 'tvdb://67890' }] + const result = PlexMapper.extractProviderIds(guids) + expect(result.tvdb).toEqual(['67890']) + }) it('should extract multiple provider ids', () => { const guids = [ { id: 'imdb://tt1234567' }, { id: 'tmdb://12345' }, { id: 'tvdb://67890' }, - ]; - const result = PlexMapper.extractProviderIds(guids); - expect(result.imdb).toEqual(['tt1234567']); - expect(result.tmdb).toEqual(['12345']); - expect(result.tvdb).toEqual(['67890']); - }); + ] + const result = PlexMapper.extractProviderIds(guids) + expect(result.imdb).toEqual(['tt1234567']) + expect(result.tmdb).toEqual(['12345']) + expect(result.tvdb).toEqual(['67890']) + }) it('should ignore plex:// guids', () => { - const guids = [{ id: 'plex://movie/5d776830880197001ec7f3eb' }]; - const result = PlexMapper.extractProviderIds(guids); - expect(result.imdb).toEqual([]); - expect(result.tmdb).toEqual([]); - expect(result.tvdb).toEqual([]); - }); + const guids = [{ id: 'plex://movie/5d776830880197001ec7f3eb' }] + const result = PlexMapper.extractProviderIds(guids) + expect(result.imdb).toEqual([]) + expect(result.tmdb).toEqual([]) + expect(result.tvdb).toEqual([]) + }) it('should handle undefined guids', () => { - const result = PlexMapper.extractProviderIds(undefined); - expect(result).toEqual({ imdb: [], tmdb: [], tvdb: [] }); - }); + const result = PlexMapper.extractProviderIds(undefined) + expect(result).toEqual({ imdb: [], tmdb: [], tvdb: [] }) + }) it('should handle empty array', () => { - const result = PlexMapper.extractProviderIds([]); - expect(result).toEqual({ imdb: [], tmdb: [], tvdb: [] }); - }); + const result = PlexMapper.extractProviderIds([]) + expect(result).toEqual({ imdb: [], tmdb: [], tvdb: [] }) + }) it('should handle malformed guids', () => { - const guids = [{ id: 'malformed-id' }, { id: '' }]; - const result = PlexMapper.extractProviderIds(guids); - expect(result).toEqual({ imdb: [], tmdb: [], tvdb: [] }); - }); - }); + const guids = [{ id: 'malformed-id' }, { id: '' }] + const result = PlexMapper.extractProviderIds(guids) + expect(result).toEqual({ imdb: [], tmdb: [], tvdb: [] }) + }) + }) describe('toMediaItem', () => { const basePlexItem: PlexLibraryItem = { @@ -157,90 +157,90 @@ describe('PlexMapper', () => { parentIndex: 1, Collection: [{ tag: 'My Collection' }], Label: [{ tag: 'HD' }], - }; + } it('should convert all basic fields correctly', () => { - const result = PlexMapper.toMediaItem(basePlexItem); + const result = PlexMapper.toMediaItem(basePlexItem) - expect(result.id).toBe('12345'); - expect(result.parentId).toBe('1234'); - expect(result.grandparentId).toBe('123'); - expect(result.title).toBe('Test Movie'); - expect(result.parentTitle).toBe('Parent Title'); - expect(result.guid).toBe('plex://movie/abc'); - expect(result.type).toBe('movie'); - }); + expect(result.id).toBe('12345') + expect(result.parentId).toBe('1234') + expect(result.grandparentId).toBe('123') + expect(result.title).toBe('Test Movie') + expect(result.parentTitle).toBe('Parent Title') + expect(result.guid).toBe('plex://movie/abc') + expect(result.type).toBe('movie') + }) it('should convert timestamps to Date objects', () => { - const result = PlexMapper.toMediaItem(basePlexItem); + const result = PlexMapper.toMediaItem(basePlexItem) - expect(result.addedAt).toEqual(new Date(1609459200 * 1000)); - expect(result.updatedAt).toEqual(new Date(1609545600 * 1000)); - expect(result.lastViewedAt).toEqual(new Date(1609632000 * 1000)); - }); + expect(result.addedAt).toEqual(new Date(1609459200 * 1000)) + expect(result.updatedAt).toEqual(new Date(1609545600 * 1000)) + expect(result.lastViewedAt).toEqual(new Date(1609632000 * 1000)) + }) it('should extract provider IDs correctly', () => { - const result = PlexMapper.toMediaItem(basePlexItem); + const result = PlexMapper.toMediaItem(basePlexItem) - expect(result.providerIds.imdb).toEqual(['tt1234567']); - expect(result.providerIds.tmdb).toEqual(['12345']); - }); + expect(result.providerIds.imdb).toEqual(['tt1234567']) + expect(result.providerIds.tmdb).toEqual(['12345']) + }) it('should convert media sources correctly', () => { - const result = PlexMapper.toMediaItem(basePlexItem); + const result = PlexMapper.toMediaItem(basePlexItem) - expect(result.mediaSources).toHaveLength(1); - expect(result.mediaSources[0].id).toBe('1'); - expect(result.mediaSources[0].duration).toBe(7200000); - expect(result.mediaSources[0].videoCodec).toBe('h264'); - }); + expect(result.mediaSources).toHaveLength(1) + expect(result.mediaSources[0].id).toBe('1') + expect(result.mediaSources[0].duration).toBe(7200000) + expect(result.mediaSources[0].videoCodec).toBe('h264') + }) it('should convert library info correctly', () => { - const result = PlexMapper.toMediaItem(basePlexItem); + const result = PlexMapper.toMediaItem(basePlexItem) - expect(result.library.id).toBe('1'); - expect(result.library.title).toBe('Movies'); - }); + expect(result.library.id).toBe('1') + expect(result.library.title).toBe('Movies') + }) it('should convert genres correctly', () => { - const result = PlexMapper.toMediaItem(basePlexItem); + const result = PlexMapper.toMediaItem(basePlexItem) - expect(result.genres).toHaveLength(1); - expect(result.genres![0].name).toBe('Action'); - }); + expect(result.genres).toHaveLength(1) + expect(result.genres![0].name).toBe('Action') + }) it('should convert actors correctly', () => { - const result = PlexMapper.toMediaItem(basePlexItem); + const result = PlexMapper.toMediaItem(basePlexItem) - expect(result.actors).toHaveLength(1); - expect(result.actors![0].name).toBe('Actor Name'); - expect(result.actors![0].role).toBe('Hero'); - }); + expect(result.actors).toHaveLength(1) + expect(result.actors![0].name).toBe('Actor Name') + expect(result.actors![0].role).toBe('Hero') + }) it('should convert collections and labels', () => { - const result = PlexMapper.toMediaItem(basePlexItem); + const result = PlexMapper.toMediaItem(basePlexItem) - expect(result.collections).toEqual(['My Collection']); - expect(result.labels).toEqual(['HD']); - }); + expect(result.collections).toEqual(['My Collection']) + expect(result.labels).toEqual(['HD']) + }) it('should convert ratings correctly', () => { - const result = PlexMapper.toMediaItem(basePlexItem); + const result = PlexMapper.toMediaItem(basePlexItem) - expect(result.ratings).toHaveLength(2); + expect(result.ratings).toHaveLength(2) expect(result.ratings).toContainEqual({ source: 'critic', value: 8.5, type: 'critic', - }); + }) expect(result.ratings).toContainEqual({ source: 'audience', value: 9.0, type: 'audience', - }); - expect(result.userRating).toBe(10); - }); - }); + }) + expect(result.userRating).toBe(10) + }) + }) describe('toMediaLibrary', () => { it('should convert movie library correctly', () => { @@ -249,15 +249,15 @@ describe('PlexMapper', () => { key: '1', title: 'Movies', agent: 'com.plexapp.agents.themoviedb', - }; + } - const result = PlexMapper.toMediaLibrary(plexLibrary); + const result = PlexMapper.toMediaLibrary(plexLibrary) - expect(result.id).toBe('1'); - expect(result.title).toBe('Movies'); - expect(result.type).toBe('movie'); - expect(result.agent).toBe('com.plexapp.agents.themoviedb'); - }); + expect(result.id).toBe('1') + expect(result.title).toBe('Movies') + expect(result.type).toBe('movie') + expect(result.agent).toBe('com.plexapp.agents.themoviedb') + }) it('should convert show library correctly', () => { const plexLibrary: PlexLibrary = { @@ -265,13 +265,13 @@ describe('PlexMapper', () => { key: '2', title: 'TV Shows', agent: 'com.plexapp.agents.thetvdb', - }; + } - const result = PlexMapper.toMediaLibrary(plexLibrary); + const result = PlexMapper.toMediaLibrary(plexLibrary) - expect(result.type).toBe('show'); - }); - }); + expect(result.type).toBe('show') + }) + }) describe('toMediaUser', () => { it('should convert user correctly', () => { @@ -284,15 +284,15 @@ describe('PlexMapper', () => { defaultSubtitleLanguage: 'en', subtitleMode: 1, thumb: '/user/thumb', - }; + } - const result = PlexMapper.toMediaUser(plexUser); + const result = PlexMapper.toMediaUser(plexUser) - expect(result.id).toBe('123'); - expect(result.name).toBe('Test User'); - expect(result.thumb).toBe('/user/thumb'); - }); - }); + expect(result.id).toBe('123') + expect(result.name).toBe('Test User') + expect(result.thumb).toBe('/user/thumb') + }) + }) describe('toWatchRecord', () => { it('should convert watch record correctly', () => { @@ -326,16 +326,16 @@ describe('PlexMapper', () => { lastViewedAt: 0, year: 0, duration: 0, - }; + } - const result = PlexMapper.toWatchRecord(plexSeenBy); + const result = PlexMapper.toWatchRecord(plexSeenBy) - expect(result.userId).toBe('123'); - expect(result.itemId).toBe('12345'); - expect(result.watchedAt).toEqual(new Date(1609459200 * 1000)); - expect(result.progress).toBe(100); - }); - }); + expect(result.userId).toBe('123') + expect(result.itemId).toBe('12345') + expect(result.watchedAt).toEqual(new Date(1609459200 * 1000)) + expect(result.progress).toBe(100) + }) + }) describe('toMediaCollection', () => { it('should convert collection correctly', () => { @@ -356,18 +356,18 @@ describe('PlexMapper', () => { maxYear: '2021', minYear: '2020', smart: false, - }; + } - const result = PlexMapper.toMediaCollection(plexCollection); + const result = PlexMapper.toMediaCollection(plexCollection) - expect(result.id).toBe('99999'); - expect(result.title).toBe('My Collection'); - expect(result.summary).toBe('Collection summary'); - expect(result.thumb).toBe('/collection/thumb'); - expect(result.childCount).toBe(10); - expect(result.addedAt).toEqual(new Date(1609459200 * 1000)); - expect(result.smart).toBe(false); - }); + expect(result.id).toBe('99999') + expect(result.title).toBe('My Collection') + expect(result.summary).toBe('Collection summary') + expect(result.thumb).toBe('/collection/thumb') + expect(result.childCount).toBe(10) + expect(result.addedAt).toEqual(new Date(1609459200 * 1000)) + expect(result.smart).toBe(false) + }) it('should handle invalid childCount', () => { const plexCollection: PlexCollection = { @@ -386,26 +386,26 @@ describe('PlexMapper', () => { childCount: 'invalid', maxYear: '', minYear: '', - }; + } - const result = PlexMapper.toMediaCollection(plexCollection); + const result = PlexMapper.toMediaCollection(plexCollection) - expect(result.childCount).toBe(0); - }); - }); + expect(result.childCount).toBe(0) + }) + }) describe('toMediaServerStatus', () => { it('should convert server status correctly', () => { const plexStatus = { machineIdentifier: 'abc123', version: '1.25.0', - }; + } - const result = PlexMapper.toMediaServerStatus(plexStatus, 'My Server'); + const result = PlexMapper.toMediaServerStatus(plexStatus, 'My Server') - expect(result.machineId).toBe('abc123'); - expect(result.version).toBe('1.25.0'); - expect(result.name).toBe('My Server'); - }); - }); -}); + expect(result.machineId).toBe('abc123') + expect(result.version).toBe('1.25.0') + expect(result.name).toBe('My Server') + }) + }) +}) diff --git a/apps/server/src/modules/api/media-server/plex/plex.mapper.ts b/apps/server/src/modules/api/media-server/plex/plex.mapper.ts index 537815ea..f0f171a5 100644 --- a/apps/server/src/modules/api/media-server/plex/plex.mapper.ts +++ b/apps/server/src/modules/api/media-server/plex/plex.mapper.ts @@ -12,12 +12,12 @@ import { MediaSource, MediaUser, WatchRecord, -} from '@maintainerr/contracts'; -import { EPlexDataType } from '../../plex-api/enums/plex-data-type-enum'; +} from '@maintainerr/contracts' +import { EPlexDataType } from '../../plex-api/enums/plex-data-type-enum' import { PlexCollection, PlexPlaylist, -} from '../../plex-api/interfaces/collection.interface'; +} from '../../plex-api/interfaces/collection.interface' import { PlexActor, PlexGenre, @@ -25,8 +25,8 @@ import { PlexLibraryItem, PlexSeenBy, PlexUserAccount, -} from '../../plex-api/interfaces/library.interfaces'; -import { Media, PlexMetadata } from '../../plex-api/interfaces/media.interface'; +} from '../../plex-api/interfaces/library.interfaces' +import { Media, PlexMetadata } from '../../plex-api/interfaces/media.interface' /** * Mapper for converting Plex-specific types to server-agnostic MediaItem types. @@ -50,18 +50,18 @@ export class PlexMapper { ): MediaItemType { switch (plexType) { case 'movie': - return 'movie'; + return 'movie' case 'show': - return 'show'; + return 'show' case 'season': - return 'season'; + return 'season' case 'episode': - return 'episode'; + return 'episode' case 'collection': // Collections don't have a dedicated MediaItemType - default to movie for API consistency - return 'movie'; + return 'movie' default: - return 'movie'; + return 'movie' } } @@ -72,15 +72,15 @@ export class PlexMapper { static toPlexDataType(type: MediaItemType): EPlexDataType { switch (type) { case 'movie': - return EPlexDataType.MOVIES; + return EPlexDataType.MOVIES case 'show': - return EPlexDataType.SHOWS; + return EPlexDataType.SHOWS case 'season': - return EPlexDataType.SEASONS; + return EPlexDataType.SEASONS case 'episode': - return EPlexDataType.EPISODES; + return EPlexDataType.EPISODES default: - return EPlexDataType.MOVIES; + return EPlexDataType.MOVIES } } @@ -90,15 +90,15 @@ export class PlexMapper { static plexDataTypeToMediaItemType(plexType: EPlexDataType): MediaItemType { switch (plexType) { case EPlexDataType.MOVIES: - return 'movie'; + return 'movie' case EPlexDataType.SHOWS: - return 'show'; + return 'show' case EPlexDataType.SEASONS: - return 'season'; + return 'season' case EPlexDataType.EPISODES: - return 'episode'; + return 'episode' default: - return 'movie'; + return 'movie' } } @@ -118,35 +118,35 @@ export class PlexMapper { imdb: [], tmdb: [], tvdb: [], - }; + } if (!guids || !Array.isArray(guids)) { - return providerIds; + return providerIds } for (const guid of guids) { - if (!guid.id) continue; + if (!guid.id) continue - const match = guid.id.match(/^(\w+):\/\/(.+)$/); - if (!match) continue; + const match = guid.id.match(/^(\w+):\/\/(.+)$/) + if (!match) continue - const [, provider, id] = match; + const [, provider, id] = match switch (provider.toLowerCase()) { case 'imdb': - providerIds.imdb.push(id); - break; + providerIds.imdb.push(id) + break case 'tmdb': - providerIds.tmdb.push(id); - break; + providerIds.tmdb.push(id) + break case 'tvdb': - providerIds.tvdb.push(id); - break; + providerIds.tvdb.push(id) + break // Ignore plex:// and other unknown providers } } - return providerIds; + return providerIds } /** @@ -194,7 +194,7 @@ export class PlexMapper { parentIndex: plex.parentIndex, collections: plex.Collection?.map((c) => c.tag), labels: plex.Label?.map((l) => l.tag), - }; + } } /** @@ -241,7 +241,7 @@ export class PlexMapper { parentIndex: plex.parentIndex, collections: plex.Collection?.map((c) => c.tag), labels: plex.Label?.map((l) => l.tag), - }; + } } /** @@ -253,7 +253,7 @@ export class PlexMapper { title: plex.title, type: plex.type === 'movie' ? 'movie' : 'show', agent: plex.agent, - }; + } } /** @@ -264,7 +264,7 @@ export class PlexMapper { id: plex.id.toString(), name: plex.name, thumb: plex.thumb, - }; + } } /** @@ -276,7 +276,7 @@ export class PlexMapper { itemId: plex.ratingKey, watchedAt: new Date(plex.viewedAt * 1000), progress: 100, // Plex marks as "seen" when complete - }; + } } /** @@ -293,7 +293,7 @@ export class PlexMapper { updatedAt: plex.updatedAt ? new Date(plex.updatedAt * 1000) : undefined, smart: plex.smart, libraryId: undefined, // Not available on PlexCollection directly - }; + } } /** @@ -309,7 +309,7 @@ export class PlexMapper { durationMs: plex.duration, addedAt: plex.addedAt ? new Date(plex.addedAt * 1000) : undefined, updatedAt: plex.updatedAt ? new Date(plex.updatedAt * 1000) : undefined, - }; + } } /** @@ -324,12 +324,12 @@ export class PlexMapper { version: plex.version, name: serverName, platform: undefined, - }; + } } private static toMediaSources(media: Media[] | undefined): MediaSource[] { if (!media || !Array.isArray(media)) { - return []; + return [] } return media.map((m) => ({ @@ -346,23 +346,23 @@ export class PlexMapper { container: m.container, sizeBytes: m.Part?.reduce((sum, p) => sum + (p.size || 0), 0) || undefined, - })); + })) } private static toMediaGenres(genres: PlexGenre[] | undefined): MediaGenre[] { if (!genres || !Array.isArray(genres)) { - return []; + return [] } return genres.map((g) => ({ id: g.id, name: g.tag, - })); + })) } private static toMediaActors(actors: PlexActor[] | undefined): MediaActor[] { if (!actors || !Array.isArray(actors)) { - return []; + return [] } return actors.map((a) => ({ @@ -370,18 +370,18 @@ export class PlexMapper { name: a.tag, role: a.role, thumb: a.thumb, - })); + })) } private static toMediaRatings(plex: PlexLibraryItem): MediaRating[] { - const ratings: MediaRating[] = []; + const ratings: MediaRating[] = [] if (plex.rating !== undefined) { ratings.push({ source: 'critic', value: plex.rating, type: 'critic', - }); + }) } if (plex.audienceRating !== undefined) { @@ -389,21 +389,21 @@ export class PlexMapper { source: 'audience', value: plex.audienceRating, type: 'audience', - }); + }) } - return ratings; + return ratings } private static metadataToMediaRatings(plex: PlexMetadata): MediaRating[] { - const ratings: MediaRating[] = []; + const ratings: MediaRating[] = [] if (plex.rating !== undefined) { ratings.push({ source: 'critic', value: plex.rating, type: 'critic', - }); + }) } if (plex.audienceRating !== undefined) { @@ -411,7 +411,7 @@ export class PlexMapper { source: 'audience', value: plex.audienceRating, type: 'audience', - }); + }) } // PlexMetadata also has Rating[] array @@ -422,11 +422,11 @@ export class PlexMapper { source: r.image, value: r.value, type: r.type, - }); + }) } } } - return ratings; + return ratings } } diff --git a/apps/server/src/modules/api/plex-api/dto/collection-hub-settings.dto.ts b/apps/server/src/modules/api/plex-api/dto/collection-hub-settings.dto.ts index fc05f5c9..21ed9ff6 100644 --- a/apps/server/src/modules/api/plex-api/dto/collection-hub-settings.dto.ts +++ b/apps/server/src/modules/api/plex-api/dto/collection-hub-settings.dto.ts @@ -1,7 +1,7 @@ export class CollectionHubSettingsDto { - libraryId: string | number; - collectionId: string | number; - recommended: boolean; - ownHome: boolean; - sharedHome: boolean; + libraryId: string | number + collectionId: string | number + recommended: boolean + ownHome: boolean + sharedHome: boolean } diff --git a/apps/server/src/modules/api/plex-api/enums/plex-data-type-enum.ts b/apps/server/src/modules/api/plex-api/enums/plex-data-type-enum.ts index f6b02ef8..818d6a33 100644 --- a/apps/server/src/modules/api/plex-api/enums/plex-data-type-enum.ts +++ b/apps/server/src/modules/api/plex-api/enums/plex-data-type-enum.ts @@ -1 +1 @@ -export { EPlexDataType } from '@maintainerr/contracts'; +export { EPlexDataType } from '@maintainerr/contracts' diff --git a/apps/server/src/modules/api/plex-api/interfaces/collection.interface.ts b/apps/server/src/modules/api/plex-api/interfaces/collection.interface.ts index 74349d5a..9102ea60 100644 --- a/apps/server/src/modules/api/plex-api/interfaces/collection.interface.ts +++ b/apps/server/src/modules/api/plex-api/interfaces/collection.interface.ts @@ -1,50 +1,50 @@ -import { EPlexDataType } from '../enums/plex-data-type-enum'; +import { EPlexDataType } from '../enums/plex-data-type-enum' export class PlexCollection { - ratingKey: string; - key: string; - guid: string; - type: string; - title: string; - subtype: string; - summary: string; - index: number; - ratingCount: number; - thumb: string; - addedAt: number; - updatedAt: number; - childCount: string; - maxYear: string; - minYear: string; - smart?: boolean; - sortTitle?: string; + ratingKey: string + key: string + guid: string + type: string + title: string + subtype: string + summary: string + index: number + ratingCount: number + thumb: string + addedAt: number + updatedAt: number + childCount: string + maxYear: string + minYear: string + smart?: boolean + sortTitle?: string } export interface CreateUpdateCollection { - libraryId: string; - collectionId?: number | string; - type: EPlexDataType; - title?: string; - summary?: string; - child?: string; - sortTitle?: string; + libraryId: string + collectionId?: number | string + type: EPlexDataType + title?: string + summary?: string + child?: string + sortTitle?: string } export interface PlexPlaylist { - ratingKey: string; - key: string; - guid: string; - type: string; - title: string; - summary: string; - smart: boolean; - playlistType: string; - composite: string; - viewCount: number; - lastViewedAt: number; - duration: number; - leafCount: number; - addedAt: number; - updatedAt: number; - itemCount: number; + ratingKey: string + key: string + guid: string + type: string + title: string + summary: string + smart: boolean + playlistType: string + composite: string + viewCount: number + lastViewedAt: number + duration: number + leafCount: number + addedAt: number + updatedAt: number + itemCount: number } diff --git a/apps/server/src/modules/api/plex-api/interfaces/library.interfaces.ts b/apps/server/src/modules/api/plex-api/interfaces/library.interfaces.ts index 73ec6306..130e51ba 100644 --- a/apps/server/src/modules/api/plex-api/interfaces/library.interfaces.ts +++ b/apps/server/src/modules/api/plex-api/interfaces/library.interfaces.ts @@ -1,136 +1,136 @@ -import { PlexCollection, PlexPlaylist } from './collection.interface'; -import { Media } from './media.interface'; +import { PlexCollection, PlexPlaylist } from './collection.interface' +import { Media } from './media.interface' export interface PlexLibraryItem { - ratingKey: string; - parentRatingKey?: string; - grandparentRatingKey?: string; - title: string; - parentTitle?: string; - guid: string; - parentGuid?: string; - grandparentGuid?: string; - addedAt: number; - updatedAt: number; + ratingKey: string + parentRatingKey?: string + grandparentRatingKey?: string + title: string + parentTitle?: string + guid: string + parentGuid?: string + grandparentGuid?: string + addedAt: number + updatedAt: number Guid?: { - id: string; - }[]; - type: 'movie' | 'show' | 'season' | 'episode' | 'collection'; - Media: Media[]; - librarySectionTitle: string; - librarySectionID: number; - librarySectionKey: string; - summary: string; - viewCount: number; - skipCount: number; - lastViewedAt: number; - year: number; - duration: number; - originallyAvailableAt: string; - rating?: number; - audienceRating?: number; - userRating?: number; - Genre?: PlexGenre[]; - Role?: PlexActor[]; - leafCount?: number; - viewedLeafCount?: number; - index?: number; - parentIndex?: number; - Collection?: { tag: string }[]; - Label?: { tag: string }[]; - contentRating?: string; + id: string + }[] + type: 'movie' | 'show' | 'season' | 'episode' | 'collection' + Media: Media[] + librarySectionTitle: string + librarySectionID: number + librarySectionKey: string + summary: string + viewCount: number + skipCount: number + lastViewedAt: number + year: number + duration: number + originallyAvailableAt: string + rating?: number + audienceRating?: number + userRating?: number + Genre?: PlexGenre[] + Role?: PlexActor[] + leafCount?: number + viewedLeafCount?: number + index?: number + parentIndex?: number + Collection?: { tag: string }[] + Label?: { tag: string }[] + contentRating?: string } export interface PlexLibraryResponse { MediaContainer: { - size: number; - totalSize?: number; + size: number + totalSize?: number Metadata?: | PlexLibraryItem[] | PlexCollection[] | PlexCollection - | PlexPlaylist[]; - }; + | PlexPlaylist[] + } } export interface PlexGenre { - id: number; - filter: string; - tag: string; + id: number + filter: string + tag: string } export interface PlexActor { - id: number; - filter: string; - tag: string; // contains name - role: string; - thumb: string; + id: number + filter: string + tag: string // contains name + role: string + thumb: string } export interface PlexRating { - image: string; - value: number; - type: 'audience' | 'critic'; + image: string + value: number + type: 'audience' | 'critic' } export interface PlexLibrary { - type: 'show' | 'movie' | 'artist'; - key: string; - title: string; - agent: string; + type: 'show' | 'movie' | 'artist' + key: string + title: string + agent: string } export interface PlexLibrariesResponse { MediaContainer: { - totalSize: number; - Directory?: PlexLibrary[]; - }; + totalSize: number + Directory?: PlexLibrary[] + } } export interface PlexHubResponse { MediaContainer: { - Size: string; - Hub: PlexHub[]; - }; + Size: string + Hub: PlexHub[] + } } export interface PlexHub { - identifier: string; - title: string; - recommendationsVisibility: 'none' | string; - homeVisibility: 'none' | string; - promotedToRecommended: boolean; - promotedToOwnHome: boolean; - promotedToSharedHome: boolean; - deletable: boolean; + identifier: string + title: string + recommendationsVisibility: 'none' | string + homeVisibility: 'none' | string + promotedToRecommended: boolean + promotedToOwnHome: boolean + promotedToSharedHome: boolean + deletable: boolean } export interface SimplePlexUser { - plexId: number; - username: string; - uuid?: string; + plexId: number + username: string + uuid?: string } export interface PlexUserResponse { - Account: PlexUserAccount[]; + Account: PlexUserAccount[] } export interface PlexUserAccount { - id: number; - key: string; - name: string; - defaultAudioLanguage: string; - autoSelectAudio: true; - defaultSubtitleLanguage: string; - subtitleMode: number; - thumb: string; + id: number + key: string + name: string + defaultAudioLanguage: string + autoSelectAudio: true + defaultSubtitleLanguage: string + subtitleMode: number + thumb: string } export interface PlexSeenBy extends PlexLibraryItem { - historyKey: string; - key: string; - ratingKey: string; - title: string; - thumb: string; - originallyAvailableAt: string; - viewedAt: number; - accountID: number; - deviceID: number; + historyKey: string + key: string + ratingKey: string + title: string + thumb: string + originallyAvailableAt: string + viewedAt: number + accountID: number + deviceID: number } diff --git a/apps/server/src/modules/api/plex-api/interfaces/media.interface.ts b/apps/server/src/modules/api/plex-api/interfaces/media.interface.ts index c3ef2f30..045d209b 100644 --- a/apps/server/src/modules/api/plex-api/interfaces/media.interface.ts +++ b/apps/server/src/modules/api/plex-api/interfaces/media.interface.ts @@ -1,67 +1,67 @@ -import { PlexActor, PlexGenre, PlexRating } from './library.interfaces'; +import { PlexActor, PlexGenre, PlexRating } from './library.interfaces' export interface PlexMetadata { - ratingKey: string; - parentRatingKey?: string; - guid: string; - type: 'movie' | 'show' | 'season' | 'episode' | 'collection'; - title: string; + ratingKey: string + parentRatingKey?: string + guid: string + type: 'movie' | 'show' | 'season' | 'episode' | 'collection' + title: string Guid: { - id: string; - }[]; + id: string + }[] Children?: { - size: 12; - Metadata: PlexMetadata[]; - }; - index: number; - parentIndex?: number; - Collection?: { tag: string }[]; - leafCount: 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[]; - parentTitle?: string; - grandparentTitle?: string; - Rating?: PlexRating[]; - contentRating?: string; + size: 12 + Metadata: PlexMetadata[] + } + index: number + parentIndex?: number + Collection?: { tag: string }[] + leafCount: 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[] + parentTitle?: string + grandparentTitle?: string + Rating?: PlexRating[] + contentRating?: string } export interface PlexMediaPart { - id: number; - size: number; - container: string; - file?: string; + id: number + size: number + container: string + file?: string } export interface Media { - id: number; - duration: number; - bitrate: number; - width: number; - height: number; - aspectRatio: number; - audioChannels: number; - audioCodec: string; - videoCodec: string; - videoResolution: string; - container: string; - videoFrameRate: string; - videoProfile: string; - Part?: PlexMediaPart[]; + id: number + duration: number + bitrate: number + width: number + height: number + aspectRatio: number + audioChannels: number + audioCodec: string + videoCodec: string + videoResolution: string + container: string + videoFrameRate: string + videoProfile: string + Part?: PlexMediaPart[] } export interface PlexMetadataResponse { MediaContainer: { - Metadata: PlexMetadata[]; - }; + Metadata: PlexMetadata[] + } } diff --git a/apps/server/src/modules/api/plex-api/interfaces/server.interface.ts b/apps/server/src/modules/api/plex-api/interfaces/server.interface.ts index 48b59303..9d0e5bcb 100644 --- a/apps/server/src/modules/api/plex-api/interfaces/server.interface.ts +++ b/apps/server/src/modules/api/plex-api/interfaces/server.interface.ts @@ -1,49 +1,49 @@ -import { PlexUserAccount } from './library.interfaces'; +import { PlexUserAccount } from './library.interfaces' export interface PlexStatusResponse { MediaContainer: { - machineIdentifier: string; - version: string; - }; + machineIdentifier: string + version: string + } } export interface PlexAccountsResponse { - MediaContainer: { Account: PlexUserAccount[] }; + MediaContainer: { Account: PlexUserAccount[] } } export interface PlexDevice { - name: string; - product: string; - productVersion: string; - platform: string; - platformVersion: string; - device: string; - clientIdentifier: string; - createdAt: Date; - lastSeenAt: Date; - provides: string[]; - owned: boolean; - accessToken?: string; - publicAddress?: string; - httpsRequired?: boolean; - synced?: boolean; - relay?: boolean; - dnsRebindingProtection?: boolean; - natLoopbackSupported?: boolean; - publicAddressMatches?: boolean; - presence?: boolean; - ownerID?: string; - home?: boolean; - sourceTitle?: string; - connection: PlexConnection[]; + name: string + product: string + productVersion: string + platform: string + platformVersion: string + device: string + clientIdentifier: string + createdAt: Date + lastSeenAt: Date + provides: string[] + owned: boolean + accessToken?: string + publicAddress?: string + httpsRequired?: boolean + synced?: boolean + relay?: boolean + dnsRebindingProtection?: boolean + natLoopbackSupported?: boolean + publicAddressMatches?: boolean + presence?: boolean + ownerID?: string + home?: boolean + sourceTitle?: string + connection: PlexConnection[] } export interface PlexConnection { - protocol: string; - address: string; - port: number; - uri: string; - local: boolean; - status?: number; - message?: string; + protocol: string + address: string + port: number + uri: string + local: boolean + status?: number + message?: string } diff --git a/apps/server/src/modules/api/plex-api/plex-api-legacy.controller.ts b/apps/server/src/modules/api/plex-api/plex-api-legacy.controller.ts index 594cdc4e..2cf975b2 100644 --- a/apps/server/src/modules/api/plex-api/plex-api-legacy.controller.ts +++ b/apps/server/src/modules/api/plex-api/plex-api-legacy.controller.ts @@ -18,20 +18,20 @@ import { Query, UseGuards, UseInterceptors, -} from '@nestjs/common'; +} from '@nestjs/common' import { CallHandler, ExecutionContext, Injectable, NestInterceptor, -} from '@nestjs/common'; -import { Response } from 'express'; -import { Observable, tap } from 'rxjs'; -import { MediaServerFactory } from '../media-server/media-server.factory'; -import { MediaServerSetupGuard } from '../media-server/guards/media-server-setup.guard'; -import { PlexMapper } from '../media-server/plex/plex.mapper'; -import { CollectionHubSettingsDto } from './dto/collection-hub-settings.dto'; -import { CreateUpdateCollection } from './interfaces/collection.interface'; +} from '@nestjs/common' +import { Response } from 'express' +import { Observable, tap } from 'rxjs' +import { MediaServerFactory } from '../media-server/media-server.factory' +import { MediaServerSetupGuard } from '../media-server/guards/media-server-setup.guard' +import { PlexMapper } from '../media-server/plex/plex.mapper' +import { CollectionHubSettingsDto } from './dto/collection-hub-settings.dto' +import { CreateUpdateCollection } from './interfaces/collection.interface' /** * Interceptor that adds deprecation warning header to all responses @@ -41,18 +41,18 @@ class DeprecationInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { return next.handle().pipe( tap(() => { - const response = context.switchToHttp().getResponse(); + const response = context.switchToHttp().getResponse() response.setHeader( 'X-Deprecated', 'This endpoint is deprecated. Use /api/media-server instead.', - ); - response.setHeader('Deprecation', 'true'); + ) + response.setHeader('Deprecation', 'true') response.setHeader( 'Link', '; rel="successor-version"', - ); + ) }), - ); + ) } } @@ -74,23 +74,23 @@ export class PlexApiLegacyController { /** @deprecated Use GET /api/media-server instead */ @Get() async getStatus() { - const mediaServer = await this.mediaServerFactory.getService(); - const status = await mediaServer.getStatus(); + const mediaServer = await this.mediaServerFactory.getService() + const status = await mediaServer.getStatus() if (status == null) { - throw new InternalServerErrorException('Could not fetch Plex status'); + throw new InternalServerErrorException('Could not fetch Plex status') } - return status; + return status } /** @deprecated Use GET /api/media-server/libraries instead */ @Get('libraries') async getLibraries() { - const mediaServer = await this.mediaServerFactory.getService(); - const libraries = await mediaServer.getLibraries(); + const mediaServer = await this.mediaServerFactory.getService() + const libraries = await mediaServer.getLibraries() if (libraries == null) { - throw new InternalServerErrorException('Could not fetch Plex libraries'); + throw new InternalServerErrorException('Could not fetch Plex libraries') } - return libraries; + return libraries } /** @deprecated Use GET /api/media-server/library/:id/content?page=X&limit=Y instead */ @@ -100,19 +100,19 @@ export class PlexApiLegacyController { @Param('page', ParseIntPipe) page: number, @Query('amount', new ParseIntPipe({ optional: true })) amount?: number, ) { - const mediaServer = await this.mediaServerFactory.getService(); - const size = amount ?? 50; - const offset = (page - 1) * size; + const mediaServer = await this.mediaServerFactory.getService() + const size = amount ?? 50 + const offset = (page - 1) * size const result = await mediaServer.getLibraryContents(id, { offset, limit: size, - }); + }) if (result == null) { throw new InternalServerErrorException( 'Could not fetch Plex library contents', - ); + ) } - return result; + return result } /** @deprecated Use GET /api/media-server/library/:id/content/search/:query instead */ @@ -122,127 +122,125 @@ export class PlexApiLegacyController { @Param('query') query: string, @Query('type') type?: string, ) { - const mediaServer = await this.mediaServerFactory.getService(); + const mediaServer = await this.mediaServerFactory.getService() const result = await mediaServer.searchLibraryContents( id, query, type as any, - ); + ) if (result == null) { throw new InternalServerErrorException( 'Could not search Plex library contents', - ); + ) } - return result; + return result } /** @deprecated Use GET /api/media-server/meta/:id instead */ @Get('meta/:id') async getMetadata(@Param('id') id: string) { - const mediaServer = await this.mediaServerFactory.getService(); - const result = await mediaServer.getMetadata(id); + const mediaServer = await this.mediaServerFactory.getService() + const result = await mediaServer.getMetadata(id) if (result == null) { - throw new InternalServerErrorException('Could not fetch Plex metadata'); + throw new InternalServerErrorException('Could not fetch Plex metadata') } - return result; + return result } /** @deprecated Use GET /api/media-server/meta/:id/seen instead */ @Get('meta/:id/seen') async getSeenBy(@Param('id') id: string) { - const mediaServer = await this.mediaServerFactory.getService(); - const result = await mediaServer.getWatchHistory(id); + const mediaServer = await this.mediaServerFactory.getService() + const result = await mediaServer.getWatchHistory(id) if (result == null) { throw new InternalServerErrorException( 'Could not fetch Plex watch history', - ); + ) } - return result; + return result } /** @deprecated Use GET /api/media-server/users instead */ @Get('users') async getUsers() { - const mediaServer = await this.mediaServerFactory.getService(); - const result = await mediaServer.getUsers(); + const mediaServer = await this.mediaServerFactory.getService() + const result = await mediaServer.getUsers() if (result == null) { - throw new InternalServerErrorException('Could not fetch Plex users'); + throw new InternalServerErrorException('Could not fetch Plex users') } - return result; + return result } /** @deprecated Use GET /api/media-server/meta/:id/children instead */ @Get('meta/:id/children') async getChildrenMetadata(@Param('id') id: string) { - const mediaServer = await this.mediaServerFactory.getService(); - const result = await mediaServer.getChildrenMetadata(id); + const mediaServer = await this.mediaServerFactory.getService() + const result = await mediaServer.getChildrenMetadata(id) if (result == null) { throw new InternalServerErrorException( 'Could not fetch Plex children metadata', - ); + ) } - return result; + return result } /** @deprecated Use GET /api/media-server/library/:id/recent instead */ @Get('library/:id/recent') async getRecentlyAdded(@Param('id') id: string) { - const mediaServer = await this.mediaServerFactory.getService(); - const result = await mediaServer.getRecentlyAdded(id); + const mediaServer = await this.mediaServerFactory.getService() + const result = await mediaServer.getRecentlyAdded(id) if (result == null) { throw new InternalServerErrorException( 'Could not fetch recently added items', - ); + ) } - return result; + return result } /** @deprecated Use GET /api/media-server/library/:id/collections instead */ @Get('library/:id/collections') async getCollections(@Param('id') id: string) { - const mediaServer = await this.mediaServerFactory.getService(); - const collections = await mediaServer.getCollections(id); + const mediaServer = await this.mediaServerFactory.getService() + const collections = await mediaServer.getCollections(id) if (collections == null) { - throw new InternalServerErrorException( - 'Could not fetch Plex collections', - ); + throw new InternalServerErrorException('Could not fetch Plex collections') } - return collections; + return collections } /** @deprecated Use GET /api/media-server/collection/:id instead */ @Get('library/collection/:collectionId') async getCollection(@Param('collectionId') collectionId: string) { - const mediaServer = await this.mediaServerFactory.getService(); - const collection = await mediaServer.getCollection(collectionId); + const mediaServer = await this.mediaServerFactory.getService() + const collection = await mediaServer.getCollection(collectionId) if (collection == null) { - throw new InternalServerErrorException('Could not fetch Plex collection'); + throw new InternalServerErrorException('Could not fetch Plex collection') } - return collection; + return collection } /** @deprecated Use GET /api/media-server/collection/:id/children instead */ @Get('library/collection/:collectionId/children') async getCollectionChildren(@Param('collectionId') collectionId: string) { - const mediaServer = await this.mediaServerFactory.getService(); - const children = await mediaServer.getCollectionChildren(collectionId); + const mediaServer = await this.mediaServerFactory.getService() + const children = await mediaServer.getCollectionChildren(collectionId) if (children == null) { throw new InternalServerErrorException( 'Could not fetch Plex collection children', - ); + ) } - return children; + return children } /** @deprecated Use GET /api/media-server/search/:query instead */ @Get('search/:input') async searchLibrary(@Param('input') input: string) { - const mediaServer = await this.mediaServerFactory.getService(); - const result = await mediaServer.searchContent(input); + const mediaServer = await this.mediaServerFactory.getService() + const result = await mediaServer.searchContent(input) if (result == null) { - throw new InternalServerErrorException('Could not search Plex library'); + throw new InternalServerErrorException('Could not search Plex library') } - return result; + return result } /** @deprecated Use PUT /api/media-server/collection/:collectionId/item/:itemId instead */ @@ -251,10 +249,10 @@ export class PlexApiLegacyController { @Param('collectionId') collectionId: string, @Param('childId') childId: string, ) { - const mediaServer = await this.mediaServerFactory.getService(); - await mediaServer.addToCollection(collectionId, childId); + const mediaServer = await this.mediaServerFactory.getService() + await mediaServer.addToCollection(collectionId, childId) // Return format compatible with old API - return { status: 'OK', message: 'Item added to collection' }; + return { status: 'OK', message: 'Item added to collection' } } /** @deprecated Use DELETE /api/media-server/collection/:collectionId/item/:itemId instead */ @@ -263,55 +261,51 @@ export class PlexApiLegacyController { @Param('collectionId') collectionId: string, @Param('childId') childId: string, ) { - const mediaServer = await this.mediaServerFactory.getService(); - await mediaServer.removeFromCollection(collectionId, childId); - return { status: 'OK', message: 'Item removed from collection' }; + const mediaServer = await this.mediaServerFactory.getService() + await mediaServer.removeFromCollection(collectionId, childId) + return { status: 'OK', message: 'Item removed from collection' } } /** @deprecated Use PUT /api/media-server/collection instead */ @Put('library/collection/update') async updateCollection(@Body() body: CreateUpdateCollection) { - const mediaServer = await this.mediaServerFactory.getService(); + const mediaServer = await this.mediaServerFactory.getService() const collection = await mediaServer.updateCollection({ libraryId: body.libraryId?.toString() ?? '', collectionId: body.collectionId?.toString() ?? '', title: body.title, summary: body.summary, sortTitle: body.sortTitle, - }); + }) if (collection == null) { - throw new InternalServerErrorException( - 'Could not update Plex collection', - ); + throw new InternalServerErrorException('Could not update Plex collection') } - return collection; + return collection } /** @deprecated Use POST /api/media-server/collection instead */ @Post('library/collection/create') async createCollection(@Body() body: CreateUpdateCollection) { - const mediaServer = await this.mediaServerFactory.getService(); + const mediaServer = await this.mediaServerFactory.getService() const collection = await mediaServer.createCollection({ libraryId: body.libraryId?.toString() ?? '', title: body.title ?? '', summary: body.summary, type: PlexMapper.plexDataTypeToMediaItemType(body.type), sortTitle: body.sortTitle, - }); + }) if (collection == null) { - throw new InternalServerErrorException( - 'Could not create Plex collection', - ); + throw new InternalServerErrorException('Could not create Plex collection') } - return collection; + return collection } /** @deprecated Use DELETE /api/media-server/collection/:id instead */ @Delete('library/collection/:collectionId') async deleteCollection(@Param('collectionId') collectionId: string) { - const mediaServer = await this.mediaServerFactory.getService(); - await mediaServer.deleteCollection(collectionId); - return { status: 'OK', message: 'Collection deleted' }; + const mediaServer = await this.mediaServerFactory.getService() + await mediaServer.deleteCollection(collectionId) + return { status: 'OK', message: 'Collection deleted' } } /** @deprecated Use PUT /api/media-server/collection/visibility instead */ @@ -324,17 +318,17 @@ export class PlexApiLegacyController { body.sharedHome !== undefined && body.ownHome !== undefined ) { - const mediaServer = await this.mediaServerFactory.getService(); + const mediaServer = await this.mediaServerFactory.getService() await mediaServer.updateCollectionVisibility({ libraryId: body.libraryId.toString(), collectionId: body.collectionId.toString(), recommended: body.recommended, ownHome: body.ownHome, sharedHome: body.sharedHome, - }); - return { status: 'OK', message: 'Collection settings updated' }; + }) + return { status: 'OK', message: 'Collection settings updated' } } else { - return 'Incorrect input parameters supplied.'; + return 'Incorrect input parameters supplied.' } } } diff --git a/apps/server/src/modules/api/plex-api/plex-api.module.ts b/apps/server/src/modules/api/plex-api/plex-api.module.ts index e61363d4..bda8fb9d 100644 --- a/apps/server/src/modules/api/plex-api/plex-api.module.ts +++ b/apps/server/src/modules/api/plex-api/plex-api.module.ts @@ -1,8 +1,8 @@ -import { forwardRef, Module } from '@nestjs/common'; -import { SettingsModule } from '../../../modules/settings/settings.module'; -import { MediaServerModule } from '../media-server/media-server.module'; -import { PlexApiLegacyController } from './plex-api-legacy.controller'; -import { PlexApiService } from './plex-api.service'; +import { forwardRef, Module } from '@nestjs/common' +import { SettingsModule } from '../../../modules/settings/settings.module' +import { MediaServerModule } from '../media-server/media-server.module' +import { PlexApiLegacyController } from './plex-api-legacy.controller' +import { PlexApiService } from './plex-api.service' /** * PlexApiModule diff --git a/apps/server/src/modules/api/plex-api/plex-api.service.ts b/apps/server/src/modules/api/plex-api/plex-api.service.ts index 4c330ea4..8871692f 100644 --- a/apps/server/src/modules/api/plex-api/plex-api.service.ts +++ b/apps/server/src/modules/api/plex-api/plex-api.service.ts @@ -1,27 +1,27 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import axios from 'axios'; -import cacheManager from '../../api/lib/cache'; +import { forwardRef, Inject, Injectable } from '@nestjs/common' +import axios from 'axios' +import cacheManager from '../../api/lib/cache' import PlexCommunityApi, { PlexCommunityErrorResponse, PlexCommunityWatchList, PlexCommunityWatchListResponse, -} from '../../api/lib/plexCommunityApi'; +} from '../../api/lib/plexCommunityApi' import { MaintainerrLogger, MaintainerrLoggerFactory, -} from '../../logging/logs.service'; -import { BasicResponseDto, PlexSetting } from '@maintainerr/contracts'; -import { Settings } from '../../settings/entities/settings.entities'; -import { SettingsService } from '../../settings/settings.service'; -import PlexApi from '../lib/plexApi'; -import PlexTvApi, { PlexUser } from '../lib/plextvApi'; -import { CollectionHubSettingsDto } from './dto/collection-hub-settings.dto'; -import { EPlexDataType } from './enums/plex-data-type-enum'; +} from '../../logging/logs.service' +import { BasicResponseDto, PlexSetting } from '@maintainerr/contracts' +import { Settings } from '../../settings/entities/settings.entities' +import { SettingsService } from '../../settings/settings.service' +import PlexApi from '../lib/plexApi' +import PlexTvApi, { PlexUser } from '../lib/plextvApi' +import { CollectionHubSettingsDto } from './dto/collection-hub-settings.dto' +import { EPlexDataType } from './enums/plex-data-type-enum' import { CreateUpdateCollection, PlexCollection, PlexPlaylist, -} from './interfaces/collection.interface'; +} from './interfaces/collection.interface' import { PlexHub, PlexHubResponse, @@ -32,23 +32,23 @@ import { PlexSeenBy, PlexUserAccount, SimplePlexUser, -} from './interfaces/library.interfaces'; +} from './interfaces/library.interfaces' import { PlexMetadata, PlexMetadataResponse, -} from './interfaces/media.interface'; +} from './interfaces/media.interface' import { PlexAccountsResponse, PlexDevice, PlexStatusResponse, -} from './interfaces/server.interface'; +} from './interfaces/server.interface' @Injectable() export class PlexApiService { - private plexClient: PlexApi; - private plexTvClient: PlexTvApi; - private plexCommunityClient: PlexCommunityApi; - private machineId: string; + private plexClient: PlexApi + private plexTvClient: PlexTvApi + private plexCommunityClient: PlexCommunityApi + private machineId: string constructor( @Inject(forwardRef(() => SettingsService)) @@ -56,7 +56,7 @@ export class PlexApiService { private readonly logger: MaintainerrLogger, private readonly loggerFactory: MaintainerrLoggerFactory, ) { - this.logger.setContext(PlexApiService.name); + this.logger.setContext(PlexApiService.name) } private getDbSettings(): PlexSetting { @@ -68,75 +68,75 @@ export class PlexApiService { auth_token: this.settings.plex_auth_token, useSsl: this.settings.plex_ssl === 1 ? true : false, webAppUrl: this.settings.plex_hostname, - }; + } } public isPlexSetup(): boolean { - return this.plexClient != null; + return this.plexClient != null } public uninitialize() { - this.plexClient = undefined; - this.plexCommunityClient = undefined; - this.plexTvClient = undefined; - cacheManager.getCache('plexguid').data.flushAll(); - cacheManager.getCache('plextv').data.flushAll(); - cacheManager.getCache('plexcommunity').data.flushAll(); + this.plexClient = undefined + this.plexCommunityClient = undefined + this.plexTvClient = undefined + cacheManager.getCache('plexguid').data.flushAll() + cacheManager.getCache('plextv').data.flushAll() + cacheManager.getCache('plexcommunity').data.flushAll() } public async initialize() { try { - this.uninitialize(); - const settingsPlex = this.getDbSettings(); - const plexToken = settingsPlex.auth_token; + this.uninitialize() + const settingsPlex = this.getDbSettings() + const plexToken = settingsPlex.auth_token if (settingsPlex.ip && plexToken) { this.plexClient = new PlexApi({ hostname: settingsPlex.ip, port: settingsPlex.port, https: settingsPlex.useSsl, token: plexToken, - }); + }) this.plexTvClient = new PlexTvApi( plexToken, this.loggerFactory.createLogger(), - ); + ) this.plexCommunityClient = new PlexCommunityApi( plexToken, this.loggerFactory.createLogger(), - ); + ) - await this.setMachineId(); + await this.setMachineId() } else { this.logger.warn( "Plex API isn't fully initialized, required settings aren't set", - ); + ) } } catch (err) { this.logger.error( `Couldn't connect to Plex.. Please check your settings`, err, - ); + ) } } public async getStatus() { try { if (!this.isPlexSetup()) { - this.logger.debug('Plex client not initialized, skipping getStatus'); - return undefined; + this.logger.debug('Plex client not initialized, skipping getStatus') + return undefined } const response: PlexStatusResponse = await this.plexClient.query( '/', false, - ); - return response.MediaContainer; + ) + return response.MediaContainer } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -144,7 +144,7 @@ export class PlexApiService { try { const response: PlexMetadataResponse = await this.plexClient.query( `/search?query=${encodeURIComponent(input)}&includeGuids=1`, - ); + ) const results = response.MediaContainer.Metadata ? Promise.all( response.MediaContainer.Metadata.filter( @@ -152,27 +152,27 @@ export class PlexApiService { ).map(async (el: PlexMetadata) => { return el.grandparentRatingKey ? await this.getMetadata(el.grandparentRatingKey.toString()) - : el; + : el }), ) - : []; - const filteredResults: PlexMetadata[] = []; - (await results).forEach((el: PlexMetadata) => { + : [] + const filteredResults: PlexMetadata[] = [] + ;(await results).forEach((el: PlexMetadata) => { if ( filteredResults.find( (e: PlexMetadata) => e.ratingKey === el.ratingKey, ) === undefined ) { - filteredResults.push(el); + filteredResults.push(el) } - }); - return filteredResults; + }) + return filteredResults } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -180,14 +180,14 @@ export class PlexApiService { try { const response: PlexAccountsResponse = await this.plexClient.queryAll({ uri: '/accounts', - }); - return response.MediaContainer.Account; + }) + return response.MediaContainer.Account } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -195,14 +195,14 @@ export class PlexApiService { try { const response: PlexAccountsResponse = await this.plexClient.queryAll({ uri: `/accounts/${id}`, - }); - return response?.MediaContainer?.Account[0]; + }) + return response?.MediaContainer?.Account[0] } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -210,19 +210,19 @@ export class PlexApiService { try { const response = await this.plexClient.queryAll({ uri: '/library/sections', - }); + }) return ( response.MediaContainer.Directory?.filter( (x) => x.type == 'movie' || x.type == 'show', ) ?? [] - ); + ) } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -231,22 +231,22 @@ export class PlexApiService { datatype?: EPlexDataType, ): Promise { try { - const type = datatype ? '?type=' + datatype : ''; + const type = datatype ? '?type=' + datatype : '' const response = await this.plexClient.query({ uri: `/library/sections/${id}/all${type}`, extraHeaders: { 'X-Plex-Container-Start': '0', 'X-Plex-Container-Size': '0', }, - }); + }) - return response.MediaContainer.totalSize; + return response.MediaContainer.totalSize } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -265,7 +265,7 @@ export class PlexApiService { includeGuids: '1', ...(datatype ? { type: datatype.toString() } : {}), ...(sort ? { sort } : {}), - }); + }) const response = await this.plexClient.query( { uri: `/library/sections/${id}/all?${params.toString()}`, @@ -275,18 +275,18 @@ export class PlexApiService { }, }, useCache, - ); + ) return { totalSize: response.MediaContainer.totalSize, items: (response.MediaContainer.Metadata as PlexLibraryItem[]) ?? [], - }; + } } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -300,19 +300,19 @@ export class PlexApiService { includeGuids: '1', title: query, ...(datatype ? { type: datatype.toString() } : {}), - }); + }) const response = await this.plexClient.query({ uri: `/library/sections/${id}/all?${params.toString()}`, - }); + }) - return response.MediaContainer.Metadata as PlexLibraryItem[]; + return response.MediaContainer.Metadata as PlexLibraryItem[] } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -329,18 +329,18 @@ export class PlexApiService { : '' }`, useCache, - ); + ) if (response) { - return response.MediaContainer.Metadata[0]; + return response.MediaContainer.Metadata[0] } else { - return undefined; + return undefined } } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -349,13 +349,13 @@ export class PlexApiService { JSON.stringify({ uri: `/library/metadata/${mediaId}`, }), - ); + ) } public async getDiscoverDataUserState( metaDataRatingKey: string, ): Promise { - const settings = this.getDbSettings(); + const settings = this.getDbSettings() try { const response = await axios.get( @@ -366,40 +366,40 @@ export class PlexApiService { 'X-Plex-Token': settings.auth_token, }, }, - ); + ) - return response.data.MediaContainer.UserState; + return response.data.MediaContainer.UserState } catch (err) { this.logger.error( "Outbound call to discover.provider.plex.tv failed. Couldn't fetch userState", err, - ); - return undefined; + ) + return undefined } } public async getUserDataFromPlexTv(): Promise { try { - const response = await this.plexTvClient.getUsers(); - return response.MediaContainer.User; + const response = await this.plexTvClient.getUsers() + return response.MediaContainer.User } catch (err) { this.logger.error( "Outbound call to plex.tv failed. Couldn't fetch users", err, - ); - return undefined; + ) + return undefined } } public async getOwnerDataFromPlexTv(): Promise { try { - return await this.plexTvClient.getUser(); + return await this.plexTvClient.getUser() } catch (err) { this.logger.error( "Outbound call to plex.tv failed. Couldn't fetch owner", err, - ); - return undefined; + ) + return undefined } } @@ -407,15 +407,15 @@ export class PlexApiService { try { const response = await this.plexClient.queryAll({ uri: `/library/metadata/${key}/children`, - }); + }) - return response.MediaContainer.Metadata; + return response.MediaContainer.Metadata } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -430,14 +430,14 @@ export class PlexApiService { uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor( options.addedAt / 1000, )}`, - }); - return response.MediaContainer.Metadata as PlexLibraryItem[]; + }) + return response.MediaContainer.Metadata as PlexLibraryItem[] } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -446,14 +446,14 @@ export class PlexApiService { const response: PlexLibraryResponse = await this.plexClient.queryAll({ uri: `/status/sessions/history/all?sort=viewedAt:desc&metadataItemID=${itemId}`, - }); - return response.MediaContainer.Metadata as PlexSeenBy[]; + }) + return response.MediaContainer.Metadata as PlexSeenBy[] } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -464,17 +464,17 @@ export class PlexApiService { try { const response = await this.plexClient.queryAll({ uri: `/library/sections/${libraryId}/collections?${subType ? `subtype=${subType}` : ''}`, - }); + }) const collection: PlexCollection[] = response.MediaContainer - .Metadata as PlexCollection[]; + .Metadata as PlexCollection[] - return collection; + return collection } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -485,37 +485,37 @@ export class PlexApiService { */ public async getPlaylists(libraryId: string): Promise { try { - const filteredItems: PlexPlaylist[] = []; + const filteredItems: PlexPlaylist[] = [] const response = await this.plexClient.queryAll({ uri: `/playlists?playlistType=video&includeCollections=1&includeExternalMedia=1&includeAdvanced=1&includeMeta=1`, - }); + }) const items = response.MediaContainer.Metadata ? (response.MediaContainer.Metadata as PlexPlaylist[]) - : []; + : [] for (const item of items) { const itemResp = await this.plexClient.query({ uri: item.key, - }); + }) const filteredForRatingKey = ( itemResp?.MediaContainer?.Metadata as PlexLibraryItem[] - )?.filter((i) => i.ratingKey === libraryId); + )?.filter((i) => i.ratingKey === libraryId) if (filteredForRatingKey && filteredForRatingKey.length > 0) { - filteredItems.push(item); + filteredItems.push(item) } } - return filteredItems; + return filteredItems } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -523,15 +523,15 @@ export class PlexApiService { try { await this.plexClient.deleteQuery({ uri: `/library/metadata/${plexId}`, - }); + }) this.logger.log( `[Plex] Removed media with ID ${plexId} from Plex library.`, - ); + ) } catch (e) { this.logger.error( `Something went wrong while removing media ${plexId} from Plex.`, e, - ); + ) } } @@ -544,20 +544,20 @@ export class PlexApiService { uri: `/library/collections/${+collectionId}?`, }, false, - ); + ) // Metadata can be a single object or an array - handle both - const metadata = response.MediaContainer.Metadata; + const metadata = response.MediaContainer.Metadata const collection = ( Array.isArray(metadata) ? metadata[0] : metadata - ) as PlexCollection; + ) as PlexCollection - return collection; + return collection } catch (err) { this.logger.debug( `Couldn't find collection with id ${+collectionId}`, err, - ); - return undefined; + ) + return undefined } } @@ -569,48 +569,48 @@ export class PlexApiService { }&title=${encodeURIComponent(params.title)}§ionId=${ params.libraryId }`, - }); + }) const collection: PlexCollection = response.MediaContainer - .Metadata[0] as PlexCollection; + .Metadata[0] as PlexCollection if (params.summary || params.sortTitle) { - params.collectionId = collection.ratingKey; - return this.updateCollection(params); + params.collectionId = collection.ratingKey + return this.updateCollection(params) } - return collection; + return collection } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } public async updateCollection(body: CreateUpdateCollection) { try { - let uri = `/library/sections/${body.libraryId}/all?type=18&id=${body.collectionId}`; + let uri = `/library/sections/${body.libraryId}/all?type=18&id=${body.collectionId}` if (body.title) { - uri += `&title.value=${encodeURIComponent(body.title)}`; + uri += `&title.value=${encodeURIComponent(body.title)}` } if (body.summary) { - uri += `&summary.value=${encodeURIComponent(body.summary)}`; + uri += `&summary.value=${encodeURIComponent(body.summary)}` } if (body.sortTitle) { // Lock sort title so Plex keeps the custom value. - uri += `&titleSort.value=${encodeURIComponent(body.sortTitle)}&titleSort.locked=1`; + uri += `&titleSort.value=${encodeURIComponent(body.sortTitle)}&titleSort.locked=1` } else if (body.title) { // Clear custom sort title and fall back to the regular title. - uri += `&titleSort.value=${encodeURIComponent(body.title)}&titleSort.locked=0`; + uri += `&titleSort.value=${encodeURIComponent(body.title)}&titleSort.locked=0` } - await this.plexClient.putQuery({ uri }); - return await this.getCollection(+body.collectionId); + await this.plexClient.putQuery({ uri }) + return await this.getCollection(+body.collectionId) } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -620,24 +620,24 @@ export class PlexApiService { try { await this.plexClient.deleteQuery({ uri: `/library/collections/${collectionId}`, - }); + }) } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); + ) return { status: 'NOK', code: 0, message: `Something went wrong while deleting the collection from Plex: ${err}`, - }; + } } - this.logger.log('Removed collection from Plex'); + this.logger.log('Removed collection from Plex') return { status: 'OK', code: 1, message: 'Success', - }; + } } public async getCollectionChildren( @@ -651,20 +651,20 @@ export class PlexApiService { uri: `/library/collections/${collectionId}/children`, }, useCache, - ); + ) // Empty collections return no Metadata node if (response.MediaContainer.Metadata === undefined) { - return []; + return [] } - return response.MediaContainer.Metadata as PlexLibraryItem[]; + return response.MediaContainer.Metadata as PlexLibraryItem[] } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } @@ -673,22 +673,22 @@ export class PlexApiService { childId: string, ): Promise { try { - await this.forceMachineId(); + await this.forceMachineId() const response: PlexLibraryResponse = await this.plexClient.putQuery({ // uri: `/library/collections/${collectionId}/items?uri=\/library\/metadata\/${childId}`, uri: `/library/collections/${collectionId}/items?uri=server:\/\/${this.machineId}\/com.plexapp.plugins.library\/library\/metadata\/${childId}`, - }); - return response.MediaContainer.Metadata[0] as PlexCollection; + }) + return response.MediaContainer.Metadata[0] as PlexCollection } catch (e) { this.logger.error( 'Plex api communication failure.. Is the application running?', e, - ); + ) return { status: 'NOK', code: 0, message: e, - } as BasicResponseDto; + } as BasicResponseDto } } @@ -699,22 +699,22 @@ export class PlexApiService { try { await this.plexClient.deleteQuery({ uri: `/library/collections/${collectionId}/children/${childId}`, - }); + }) return { status: 'OK', code: 1, message: `successfully deleted child with id ${childId}`, - } as BasicResponseDto; + } as BasicResponseDto } catch (e) { this.logger.error( 'Plex api communication failure.. Is the application running?', e, - ); + ) return { status: 'NOK', code: 0, message: e.message, - } as BasicResponseDto; + } as BasicResponseDto } } @@ -726,46 +726,46 @@ export class PlexApiService { uri: `/hubs/sections/${params.libraryId}/manage?metadataItemId=${ params.collectionId }&promotedToRecommended=${+params.recommended}&promotedToOwnHome=${+params.ownHome}&promotedToSharedHome=${+params.sharedHome}`, - }); - return response.MediaContainer.Hub[0] as PlexHub; + }) + return response.MediaContainer.Hub[0] as PlexHub } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } public async getAvailableServers(): Promise { try { // reload requirements, auth token might have changed - const settings = (await this.settings.getSettings()) as Settings; + const settings = (await this.settings.getSettings()) as Settings this.plexTvClient = new PlexTvApi( settings.plex_auth_token, this.loggerFactory.createLogger(), - ); + ) const devices = (await this.plexTvClient?.getDevices())?.filter( (device) => { - return device.provides.includes('server') && device.owned; + return device.provides.includes('server') && device.owned }, - ); + ) if (devices) { await Promise.all( devices.map(async (device) => { device.connection.map((connection) => { - const url = new URL(connection.uri); + const url = new URL(connection.uri) if (url.hostname !== connection.address) { const plexDirectConnection = { ...connection, address: url.hostname, - }; - device.connection.push(plexDirectConnection); - connection.protocol = 'http'; + } + device.connection.push(plexDirectConnection) + connection.protocol = 'http' } - }); + }) const filteredConnectionPromises = device.connection.map( async (connection) => { @@ -775,26 +775,26 @@ export class PlexApiService { https: connection.protocol === 'https', timeout: 5000, token: settings.plex_auth_token, - }); + }) // test connection - return (await newClient.getStatus()) ? connection : null; + return (await newClient.getStatus()) ? connection : null }, - ); + ) device.connection = ( await Promise.all(filteredConnectionPromises) - ).filter(Boolean); + ).filter(Boolean) }), - ); + ) } - return devices; + return devices } catch (e) { this.logger.warn( 'Plex api communication failure.. Is the application running?', - ); - this.logger.debug(e); - return []; + ) + this.logger.debug(e) + return [] } } @@ -803,10 +803,10 @@ export class PlexApiService { username: string, ): Promise { try { - let result: PlexCommunityWatchList[] = []; - let next = true; - let page: string | null = null; - const size = 100; + let result: PlexCommunityWatchList[] = [] + let next = true + let page: string | null = null + const size = 100 while (next) { const resp = await this.plexCommunityClient.query< @@ -836,35 +836,35 @@ export class PlexApiService { skipUserState: true, after: page, }, - }); + }) if (!resp) { this.logger.warn( `Failure while fetching watchlist of user ${userId} (${username})`, - ); - return undefined; + ) + return undefined } else if (resp.errors) { this.logger.warn( `Failure while fetching watchlist of user ${userId} (${username}): ${resp.errors.map((x) => x.message).join(', ')}`, - ); - return undefined; + ) + return undefined } - const watchlist = resp.data.user.watchlist; - result = [...result, ...watchlist.nodes]; + const watchlist = resp.data.user.watchlist + result = [...result, ...watchlist.nodes] if (!watchlist.pageInfo?.hasNextPage) { - next = false; + next = false } else { - page = watchlist.pageInfo?.endCursor; + page = watchlist.pageInfo?.endCursor } } - return result; + return result } catch (e) { this.logger.warn( `Failure while fetching watchlist of user ${userId} (${username})`, - ); - this.logger.debug(e); + ) + this.logger.debug(e) } } @@ -873,7 +873,7 @@ export class PlexApiService { context: { type: EPlexDataType; id: number }, media: { plexId: number }, ) { - const handleMedia: { plexId: number }[] = []; + const handleMedia: { plexId: number }[] = [] if (collectionType && media) { // switch based on collection type @@ -883,70 +883,70 @@ export class PlexApiService { switch (context.type) { // and context type is seasons case EPlexDataType.SEASONS: - handleMedia.push({ plexId: context.id }); - break; + handleMedia.push({ plexId: context.id }) + break // and content type is episodes case EPlexDataType.EPISODES: // this is not allowed this.logger.warn( 'Tried to add episodes to a collection of type season. This is not allowed.', - ); - break; + ) + break // and context type is full show default: const data = await this.getChildrenMetadata( media.plexId.toString(), - ); + ) // transform & add season data.forEach((el) => { handleMedia.push({ plexId: +el.ratingKey, - }); - }); - break; + }) + }) + break } - break; + break // when collection type is episodes case EPlexDataType.EPISODES: switch (context.type) { // and context type is seasons case EPlexDataType.SEASONS: - const eps = await this.getChildrenMetadata(context.id.toString()); + const eps = await this.getChildrenMetadata(context.id.toString()) // transform & add episodes eps.forEach((el) => { handleMedia.push({ plexId: +el.ratingKey, - }); - }); - break; + }) + }) + break // and context type is episodes case EPlexDataType.EPISODES: - handleMedia.push({ plexId: context.id }); - break; + handleMedia.push({ plexId: context.id }) + break // and context type is full show default: // get all seasons const seasons = await this.getChildrenMetadata( media.plexId.toString(), - ); + ) // get and add all episodes for each season for (const season of seasons) { - const eps = await this.getChildrenMetadata(season.ratingKey); + const eps = await this.getChildrenMetadata(season.ratingKey) eps.forEach((ep) => { handleMedia.push({ plexId: +ep.ratingKey, - }); - }); + }) + }) } - break; + break } - break; + break // when collection type is SHOW or MOVIE default: // just add media item - handleMedia.push({ plexId: media.plexId }); - break; + handleMedia.push({ plexId: media.plexId }) + break } } // for all collections @@ -954,10 +954,10 @@ export class PlexApiService { switch (context.type) { case EPlexDataType.SEASONS: // for seasons, add all episode ID's + the season media item - handleMedia.push({ plexId: context.id }); + handleMedia.push({ plexId: context.id }) // get all episodes - const data = await this.getChildrenMetadata(context.id.toString()); + const data = await this.getChildrenMetadata(context.id.toString()) // transform & add eps if (data) { @@ -965,115 +965,115 @@ export class PlexApiService { ...data.map((el) => { return { plexId: +el.ratingKey, - }; + } }), - ); + ) } - break; + break case EPlexDataType.EPISODES: // transform & push episode handleMedia.push({ plexId: +context.id, - }); - break; + }) + break case EPlexDataType.SHOWS: // add show id handleMedia.push({ plexId: +media.plexId, - }); + }) // get all seasons const seasons = await this.getChildrenMetadata( media.plexId.toString(), - ); + ) for (const season of seasons) { // transform & add season handleMedia.push({ plexId: +season.ratingKey, - }); + }) // get all eps of season const eps = await this.getChildrenMetadata( season.ratingKey.toString(), - ); + ) // transform & add eps if (eps) { handleMedia.push( ...eps.map((el) => { return { plexId: +el.ratingKey, - }; + } }), - ); + ) } } - break; + break case EPlexDataType.MOVIES: handleMedia.push({ plexId: +media.plexId, - }); + }) } } - return handleMedia; + return handleMedia } public async getCorrectedUsers( realOwnerId: boolean = true, ): Promise { - const thumbRegex = /https:\/\/plex\.tv\/users\/([a-z0-9]+)\/avatar\?c=\d+/; + const thumbRegex = /https:\/\/plex\.tv\/users\/([a-z0-9]+)\/avatar\?c=\d+/ - const plexTvUsers = await this.getUserDataFromPlexTv(); - const owner = await this.getOwnerDataFromPlexTv(); + const plexTvUsers = await this.getUserDataFromPlexTv() + const owner = await this.getOwnerDataFromPlexTv() return (await this.getUsers()).map((el) => { - const plextv = plexTvUsers?.find((tvEl) => tvEl.$?.id == el.id); - const ownerUser = owner?.username === el.name ? owner : undefined; + const plextv = plexTvUsers?.find((tvEl) => tvEl.$?.id == el.id) + const ownerUser = owner?.username === el.name ? owner : undefined // use the username from plex.tv if available, since Overseerr also does this if (ownerUser) { - const match = ownerUser.thumb?.match(thumbRegex); - const uuid = match ? match[1] : undefined; + const match = ownerUser.thumb?.match(thumbRegex) + const uuid = match ? match[1] : undefined return { plexId: realOwnerId ? +ownerUser.id : el.id, username: ownerUser.username, uuid: uuid, - } as SimplePlexUser; + } as SimplePlexUser } else if (plextv && plextv.$ && plextv.$.username) { - const match = plextv.$.thumb?.match(thumbRegex); - const uuid = match ? match[1] : undefined; + const match = plextv.$.thumb?.match(thumbRegex) + const uuid = match ? match[1] : undefined return { plexId: +plextv.$.id, username: plextv.$.username, uuid: uuid, - } as SimplePlexUser; + } as SimplePlexUser } - return { plexId: +el.id, username: el.name } as SimplePlexUser; - }); + return { plexId: +el.id, username: el.name } as SimplePlexUser + }) } private async setMachineId() { try { - const response = await this.getStatus(); + const response = await this.getStatus() if (response?.machineIdentifier) { - this.machineId = response.machineIdentifier; - return response.machineIdentifier; + this.machineId = response.machineIdentifier + return response.machineIdentifier } else { - this.logger.warn("Couldn't reach Plex"); - return null; + this.logger.warn("Couldn't reach Plex") + return null } } catch (err) { this.logger.error( 'Plex api communication failure.. Is the application running?', err, - ); - return undefined; + ) + return undefined } } private async forceMachineId() { if (!this.machineId) { - await this.setMachineId(); + await this.setMachineId() } } } diff --git a/apps/server/src/modules/api/seerr-api/helpers/seerr-api.helper.ts b/apps/server/src/modules/api/seerr-api/helpers/seerr-api.helper.ts index 0299bec4..ca19cc60 100644 --- a/apps/server/src/modules/api/seerr-api/helpers/seerr-api.helper.ts +++ b/apps/server/src/modules/api/seerr-api/helpers/seerr-api.helper.ts @@ -1,18 +1,18 @@ -import { MaintainerrLogger } from '../../../logging/logs.service'; -import { ExternalApiService } from '../../external-api/external-api.service'; -import cacheManager from '../../lib/cache'; +import { MaintainerrLogger } from '../../../logging/logs.service' +import { ExternalApiService } from '../../external-api/external-api.service' +import cacheManager from '../../lib/cache' export class SeerrApi extends ExternalApiService { constructor( { url, apiKey }: { url: string; apiKey: string }, protected readonly logger: MaintainerrLogger, ) { - logger.setContext(SeerrApi.name); + logger.setContext(SeerrApi.name) super(url, {}, logger, { headers: { 'X-Api-Key': apiKey, }, nodeCache: cacheManager.getCache('seerr').data, - }); + }) } } diff --git a/apps/server/src/modules/api/seerr-api/seerr-api.controller.ts b/apps/server/src/modules/api/seerr-api/seerr-api.controller.ts index 879d16f7..75c98e7b 100644 --- a/apps/server/src/modules/api/seerr-api/seerr-api.controller.ts +++ b/apps/server/src/modules/api/seerr-api/seerr-api.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Delete, Get, Param } from '@nestjs/common'; -import { SeerrApiService } from './seerr-api.service'; +import { Controller, Delete, Get, Param } from '@nestjs/common' +import { SeerrApiService } from './seerr-api.service' @Controller(['api/seerr', 'api/overseerr', 'api/jellyseerr']) export class SeerrApiController { @@ -7,26 +7,26 @@ export class SeerrApiController { @Get('movie/:id') getMovie(@Param('id') id: string) { - return this.seerrApi.getMovie(id); + return this.seerrApi.getMovie(id) } @Get('show/:id') getShow(@Param('id') id: string) { - return this.seerrApi.getShow(id); + return this.seerrApi.getShow(id) } @Delete('request/:requestId') deleteRequest(@Param('requestId') requestId: string) { - return this.seerrApi.deleteRequest(requestId); + return this.seerrApi.deleteRequest(requestId) } @Delete('media/:mediaId') deleteMedia(@Param('mediaId') mediaId: string) { - return this.seerrApi.deleteMediaItem(mediaId); + return this.seerrApi.deleteMediaItem(mediaId) } @Delete('media/tmdb/:mediaId') removeMediaByTmdbId(@Param('mediaId') mediaId: string) { - return this.seerrApi.removeMediaByTmdbId(mediaId, 'movie'); + return this.seerrApi.removeMediaByTmdbId(mediaId, 'movie') } } diff --git a/apps/server/src/modules/api/seerr-api/seerr-api.module.ts b/apps/server/src/modules/api/seerr-api/seerr-api.module.ts index d66a9714..bfa64662 100644 --- a/apps/server/src/modules/api/seerr-api/seerr-api.module.ts +++ b/apps/server/src/modules/api/seerr-api/seerr-api.module.ts @@ -1,7 +1,7 @@ -import { Module } from '@nestjs/common'; -import { SeerrApiService } from './seerr-api.service'; -import { SeerrApiController } from './seerr-api.controller'; -import { ExternalApiModule } from '../external-api/external-api.module'; +import { Module } from '@nestjs/common' +import { SeerrApiService } from './seerr-api.service' +import { SeerrApiController } from './seerr-api.controller' +import { ExternalApiModule } from '../external-api/external-api.module' @Module({ imports: [ExternalApiModule], diff --git a/apps/server/src/modules/api/seerr-api/seerr-api.service.ts b/apps/server/src/modules/api/seerr-api/seerr-api.service.ts index abce37b5..144a1313 100644 --- a/apps/server/src/modules/api/seerr-api/seerr-api.service.ts +++ b/apps/server/src/modules/api/seerr-api/seerr-api.service.ts @@ -1,155 +1,155 @@ -import { BasicResponseDto } from '@maintainerr/contracts'; -import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { AxiosError } from 'axios'; -import { SettingsService } from '../../../modules/settings/settings.service'; +import { BasicResponseDto } from '@maintainerr/contracts' +import { forwardRef, Inject, Injectable } from '@nestjs/common' +import { AxiosError } from 'axios' +import { SettingsService } from '../../../modules/settings/settings.service' import { MaintainerrLogger, MaintainerrLoggerFactory, -} from '../../logging/logs.service'; -import { SeerrApi } from './helpers/seerr-api.helper'; +} from '../../logging/logs.service' +import { SeerrApi } from './helpers/seerr-api.helper' interface SeerrMediaInfo { - id: number; - tmdbId: number; - tvdbId: number; - status: number; - updatedAt: string; - mediaAddedAt: string; - externalServiceId: number; - externalServiceId4k: number; + id: number + tmdbId: number + tvdbId: number + status: number + updatedAt: string + mediaAddedAt: string + externalServiceId: number + externalServiceId4k: number } export interface SeerrMovieResponse { - id: number; - mediaInfo?: SeerrMovieInfo; - releaseDate?: Date; + id: number + mediaInfo?: SeerrMovieInfo + releaseDate?: Date } interface SeerrMovieInfo extends SeerrMediaInfo { - mediaType: 'movie'; - requests?: SeerrMovieRequest[]; + mediaType: 'movie' + requests?: SeerrMovieRequest[] } export interface SeerrTVResponse { - id: number; - mediaInfo?: SeerrTVInfo; - firstAirDate?: Date; + id: number + mediaInfo?: SeerrTVInfo + firstAirDate?: Date } interface SeerrTVInfo extends SeerrMediaInfo { - mediaType: 'tv'; - requests?: SeerrTVRequest[]; - seasons?: SeerrSeasonResponse[]; + mediaType: 'tv' + requests?: SeerrTVRequest[] + seasons?: SeerrSeasonResponse[] } export interface SeerrSeasonResponse { - id: number; - name: string; - airDate?: string; - seasonNumber: number; - episodes: SeerrEpisode[]; + id: number + name: string + airDate?: string + seasonNumber: number + episodes: SeerrEpisode[] } interface SeerrEpisode { - id: number; - name: string; - airDate?: string; - seasonNumber: number; - episodeNumber: number; + id: number + name: string + airDate?: string + seasonNumber: number + episodeNumber: number } export type SeerrBaseRequest = { - id: number; - status: number; - createdAt: string; - updatedAt: string; - requestedBy: SeerrUser; - modifiedBy: SeerrUser; - is4k: false; - serverId: number; - profileId: number; - rootFolder: string; -}; + id: number + status: number + createdAt: string + updatedAt: string + requestedBy: SeerrUser + modifiedBy: SeerrUser + is4k: false + serverId: number + profileId: number + rootFolder: string +} export type SeerrTVRequest = SeerrBaseRequest & { - type: 'tv'; - media: SeerrTVInfo; - seasons: SeerrSeasonRequest[]; -}; + type: 'tv' + media: SeerrTVInfo + seasons: SeerrSeasonRequest[] +} export type SeerrMovieRequest = SeerrBaseRequest & { - type: 'movie'; - media: SeerrMovieInfo; -}; + type: 'movie' + media: SeerrMovieInfo +} -export type SeerrRequest = SeerrMovieRequest | SeerrTVRequest; +export type SeerrRequest = SeerrMovieRequest | SeerrTVRequest interface SeerrUser { - id: number; - email: string; - username: string; - plexToken: string; - plexId?: number; - plexUsername: string; - jellyfinUsername?: string; - userType: number; - permissions: number; - avatar: string; - createdAt: string; - updatedAt: string; - requestCount: number; + id: number + email: string + username: string + plexToken: string + plexId?: number + plexUsername: string + jellyfinUsername?: string + userType: number + permissions: number + avatar: string + createdAt: string + updatedAt: string + requestCount: number } export interface SeerrSeasonRequest { - id: number; - name: string; - seasonNumber: number; + id: number + name: string + seasonNumber: number } interface SeerrStatus { - version: string; - commitTag: string; - updateAvailable: boolean; - commitsBehind: number; + version: string + commitTag: string + updateAvailable: boolean + commitsBehind: number } interface SeerrAbout { - version: string; + version: string } export interface SeerrBasicApiResponse { - code: string; - description: string; + code: string + description: string } interface SeerrUserResponse { pageInfo: { - pages: number; - pageSize: number; - results: number; - page: number; - }; - results: SeerrUserResponseResult[]; + pages: number + pageSize: number + results: number + page: number + } + results: SeerrUserResponseResult[] } interface SeerrUserResponseResult { - permissions: number; - id: number; - email: string; - plexUsername: string; - username: string; - userType: number; - plexId: number; - avatar: string; - createdAt: string; - updatedAt: string; - requestCount: number; - displayName: string; + permissions: number + id: number + email: string + plexUsername: string + username: string + userType: number + plexId: number + avatar: string + createdAt: string + updatedAt: string + requestCount: number + displayName: string } @Injectable() export class SeerrApiService { - api: SeerrApi; + api: SeerrApi constructor( @Inject(forwardRef(() => SettingsService)) @@ -157,12 +157,12 @@ export class SeerrApiService { private readonly logger: MaintainerrLogger, private readonly loggerFactory: MaintainerrLoggerFactory, ) { - this.logger.setContext(SeerrApiService.name); + this.logger.setContext(SeerrApiService.name) } public init() { if (!this.settings.seerr_url) { - return; + return } this.api = new SeerrApi( @@ -171,35 +171,35 @@ export class SeerrApiService { apiKey: `${this.settings.seerr_api_key}`, }, this.loggerFactory.createLogger(), - ); + ) } public async getMovie(id: string | number): Promise { try { - const response: SeerrMovieResponse = await this.api.get(`/movie/${id}`); - return response; + const response: SeerrMovieResponse = await this.api.get(`/movie/${id}`) + return response } catch (err) { this.logger.warn( 'Seerr communication failed. Is the application running?', - ); - this.logger.debug(err); - return undefined; + ) + this.logger.debug(err) + return undefined } } public async getShow(showId: string | number): Promise { try { if (showId) { - const response: SeerrTVResponse = await this.api.get(`/tv/${showId}`); - return response; + const response: SeerrTVResponse = await this.api.get(`/tv/${showId}`) + return response } - return undefined; + return undefined } catch (err) { this.logger.warn( 'Seerr communication failed. Is the application running?', - ); - this.logger.debug(err); - return undefined; + ) + this.logger.debug(err) + return undefined } } @@ -211,47 +211,47 @@ export class SeerrApiService { if (showId) { const response: SeerrSeasonResponse = await this.api.get( `/tv/${showId}/season/${season}`, - ); - return response; + ) + return response } - return undefined; + return undefined } catch (err) { this.logger.warn( 'Seerr communication failed. Is the application running?', - ); - this.logger.debug(err); - return undefined; + ) + this.logger.debug(err) + return undefined } } public async getUsers(): Promise { try { - const size = 50; - let hasNext = true; - let skip = 0; + const size = 50 + let hasNext = true + let skip = 0 - const users: SeerrUserResponseResult[] = []; + const users: SeerrUserResponseResult[] = [] while (hasNext) { const resp: SeerrUserResponse = await this.api.get( `/user?take=${size}&skip=${skip}`, - ); + ) - users.push(...resp.results); + users.push(...resp.results) if (resp?.pageInfo?.page < resp?.pageInfo?.pages) { - skip = skip + size; + skip = skip + size } else { - hasNext = false; + hasNext = false } } - return users; + return users } catch (err) { this.logger.warn( `Couldn't fetch Seerr users. Is the application running?`, - ); - this.logger.debug(err); - return []; + ) + this.logger.debug(err) + return [] } } @@ -259,40 +259,40 @@ export class SeerrApiService { try { const response: SeerrBasicApiResponse = await this.api.delete( `/request/${requestId}`, - ); - return response; + ) + return response } catch (err) { this.logger.warn( 'Seerr communication failed. Is the application running?', - ); - this.logger.debug(err); - return undefined; + ) + this.logger.debug(err) + return undefined } } public async removeSeasonRequest(tmdbid: string | number, season: number) { try { - const media = await this.getShow(tmdbid); + const media = await this.getShow(tmdbid) if (media?.mediaInfo) { const requests = media.mediaInfo.requests.filter((el) => el.seasons.find((s) => s.seasonNumber === season), - ); + ) if (requests.length > 0) { for (const el of requests) { - await this.deleteRequest(el.id.toString()); + await this.deleteRequest(el.id.toString()) } } else { // no requests? clear data and let Seerr refetch. - await this.api.delete(`/media/${media.id}`); + await this.api.delete(`/media/${media.id}`) } } } catch (err) { this.logger.warn( 'Seerr communication failed. Is the application running?', - ); - this.logger.debug(err); - return undefined; + ) + this.logger.debug(err) + return undefined } } @@ -300,43 +300,43 @@ export class SeerrApiService { try { const response: SeerrBasicApiResponse = await this.api.delete( `/media/${mediaId}`, - ); - return response; + ) + return response } catch (e) { this.logger.log( `Couldn't delete media ${mediaId}. Does it exist in Seerr? ${e.message}`, - ); - this.logger.debug(e); - return null; + ) + this.logger.debug(e) + return null } } public async removeMediaByTmdbId(id: string | number, type: 'movie' | 'tv') { try { - let media: SeerrMovieResponse | SeerrTVResponse; + let media: SeerrMovieResponse | SeerrTVResponse if (type === 'movie') { - media = await this.getMovie(id); + media = await this.getMovie(id) } else { - media = await this.getShow(id); + media = await this.getShow(id) } if (!media.mediaInfo?.id) { - return undefined; + return undefined } try { - await this.deleteMediaItem(media.mediaInfo.id.toString()); + await this.deleteMediaItem(media.mediaInfo.id.toString()) } catch (e) { this.logger.log( `Couldn't delete media by TMDB ID ${id}. Does it exist in Seerr? ${e.message}`, - ); + ) } } catch (err) { this.logger.warn( 'Seerr communication failed. Is the application running?', - ); - this.logger.debug(err); - return undefined; + ) + this.logger.debug(err) + return undefined } } @@ -344,12 +344,12 @@ export class SeerrApiService { try { const response: SeerrStatus = await this.api.getWithoutCache(`/status`, { signal: AbortSignal.timeout(10000), - }); - return response; + }) + return response } catch (e) { - this.logger.log(`Couldn't fetch Seerr status: ${e.message}`); - this.logger.debug(e); - return null; + this.logger.log(`Couldn't fetch Seerr status: ${e.message}`) + this.logger.debug(e) + return null } } @@ -364,7 +364,7 @@ export class SeerrApiService { }, this.loggerFactory.createLogger(), ) - : this.api; + : this.api try { const response = await api.getRawWithoutCache( @@ -372,7 +372,7 @@ export class SeerrApiService { { signal: AbortSignal.timeout(10000), }, - ); + ) if (!response.data?.version) { return { @@ -380,16 +380,16 @@ export class SeerrApiService { code: 0, message: 'Failure, an unexpected response was returned. The URL is likely incorrect.', - }; + } } return { status: 'OK', code: 1, message: response.data.version, - }; + } } catch (e) { - this.logger.warn(`A failure occurred testing Seerr connectivity: ${e}`); + this.logger.warn(`A failure occurred testing Seerr connectivity: ${e}`) if (e instanceof AxiosError) { if (e.response?.status === 403) { @@ -397,13 +397,13 @@ export class SeerrApiService { status: 'NOK', code: 0, message: 'Invalid API key', - }; + } } else if (e.response?.status) { return { status: 'NOK', code: 0, message: `Failure, received response: ${e.response?.status} ${e.response?.statusText}.`, - }; + } } } @@ -411,7 +411,7 @@ export class SeerrApiService { status: 'NOK', code: 0, message: `Failure: ${e.message}`, - }; + } } } } diff --git a/apps/server/src/modules/api/servarr-api/common/servarr-api.service.ts b/apps/server/src/modules/api/servarr-api/common/servarr-api.service.ts index b1445c87..e3b9295c 100644 --- a/apps/server/src/modules/api/servarr-api/common/servarr-api.service.ts +++ b/apps/server/src/modules/api/servarr-api/common/servarr-api.service.ts @@ -1,7 +1,7 @@ -import { ExternalApiService } from '../../../../modules/api/external-api/external-api.service'; -import { DVRSettings } from '../../../../modules/settings/interfaces/dvr-settings.interface'; -import { MaintainerrLogger } from '../../../logging/logs.service'; -import cacheManager from '../../lib/cache'; +import { ExternalApiService } from '../../../../modules/api/external-api/external-api.service' +import { DVRSettings } from '../../../../modules/settings/interfaces/dvr-settings.interface' +import { MaintainerrLogger } from '../../../logging/logs.service' +import cacheManager from '../../lib/cache' import { DiskSpaceResource, QualityProfile, @@ -10,14 +10,14 @@ import { RootFolder, SystemStatus, Tag, -} from '../interfaces/servarr.interface'; +} from '../interfaces/servarr.interface' export abstract class ServarrApi extends ExternalApiService { static buildUrl(settings: DVRSettings, path?: string): string { - return `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${settings.port}${settings.baseUrl ?? ''}${path}`; + return `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${settings.port}${settings.baseUrl ?? ''}${path}` } - protected apiName: string; + protected apiName: string constructor( { @@ -25,9 +25,9 @@ export abstract class ServarrApi extends ExternalApiService { apiKey, cacheName, }: { - url: string; - apiKey: string; - cacheName?: string; + url: string + apiKey: string + cacheName?: string }, protected readonly logger: MaintainerrLogger, ) { @@ -40,18 +40,18 @@ export abstract class ServarrApi extends ExternalApiService { cacheName ? { nodeCache: cacheManager.getCache(cacheName).data } : undefined, - ); + ) } public getSystemStatus = async (): Promise => { try { - const response = await this.axios.get('/system/status'); + const response = await this.axios.get('/system/status') - return response.data; + return response.data } catch (e) { - this.logger.warn(`Failed to retrieve system status: ${e.message}`); + this.logger.warn(`Failed to retrieve system status: ${e.message}`) } - }; + } public getProfiles = async (): Promise => { try { @@ -59,13 +59,13 @@ export abstract class ServarrApi extends ExternalApiService { `/qualityProfile`, undefined, 3600, - ); + ) - return data; + return data } catch (e) { - this.logger.warn(`Failed to retrieve profiles: ${e.message}`); + this.logger.warn(`Failed to retrieve profiles: ${e.message}`) } - }; + } public getRootFolders = async (): Promise => { try { @@ -73,13 +73,13 @@ export abstract class ServarrApi extends ExternalApiService { `/rootfolder`, undefined, 3600, - ); + ) - return data; + return data } catch (e) { - this.logger.warn(`Failed to retrieve root folders: ${e.message}`); + this.logger.warn(`Failed to retrieve root folders: ${e.message}`) } - }; + } public getDiskspace = async (): Promise => { try { @@ -87,46 +87,46 @@ export abstract class ServarrApi extends ExternalApiService { `/diskspace`, undefined, 3600, - ); + ) - return data; + return data } catch (e) { - this.logger.warn(`Failed to retrieve disk space: ${e.message}`); + this.logger.warn(`Failed to retrieve disk space: ${e.message}`) } - }; + } public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => { try { const response = - await this.axios.get>(`/queue`); + await this.axios.get>(`/queue`) - return response.data.records; + return response.data.records } catch (e) { - this.logger.warn(`Failed to retrieve queue: ${e.message}`); + this.logger.warn(`Failed to retrieve queue: ${e.message}`) } - }; + } public getTags = async (): Promise => { try { - const response = await this.axios.get(`/tag`); + const response = await this.axios.get(`/tag`) - return response.data; + return response.data } catch (e) { - this.logger.warn(`Failed to retrieve tags: ${e.message}`); + this.logger.warn(`Failed to retrieve tags: ${e.message}`) } - }; + } public createTag = async ({ label }: { label: string }): Promise => { try { const response = await this.axios.post(`/tag`, { label, - }); + }) - return response.data; + return response.data } catch (e) { - this.logger.warn(`Failed to create tag: ${e.message}`); + this.logger.warn(`Failed to create tag: ${e.message}`) } - }; + } public async runCommand( commandName: string, @@ -137,30 +137,30 @@ export abstract class ServarrApi extends ExternalApiService { const resp = await this.axios.post(`/command`, { name: commandName, ...options, - }); + }) if (wait && resp.data) { while (resp.data.status !== 'failed' && resp.data.status !== 'finished') - resp.data = await this.get('/command/' + resp.data.id); + resp.data = await this.get('/command/' + resp.data.id) } - return resp ? resp.data : undefined; + return resp ? resp.data : undefined } catch (e) { - this.logger.warn(`Failed to run command: ${e.message}`); + this.logger.warn(`Failed to run command: ${e.message}`) } } protected async runDelete(command: string): Promise { try { - await this.delete(`/${command}`); + await this.delete(`/${command}`) } catch (e) { - this.logger.warn(`Failed to run DELETE: ${e.message}`); + this.logger.warn(`Failed to run DELETE: ${e.message}`) } } protected async runPut(command: string, body: string): Promise { try { - await this.put(`/${command}`, body); + await this.put(`/${command}`, body) } catch (e) { - this.logger.warn(`Failed to run PUT: ${e.message}`); + this.logger.warn(`Failed to run PUT: ${e.message}`) } } } diff --git a/apps/server/src/modules/api/servarr-api/helpers/radarr.helper.ts b/apps/server/src/modules/api/servarr-api/helpers/radarr.helper.ts index d731aead..86fe9544 100644 --- a/apps/server/src/modules/api/servarr-api/helpers/radarr.helper.ts +++ b/apps/server/src/modules/api/servarr-api/helpers/radarr.helper.ts @@ -1,11 +1,11 @@ -import { MaintainerrLogger } from '../../../logging/logs.service'; -import { ServarrApi } from '../common/servarr-api.service'; +import { MaintainerrLogger } from '../../../logging/logs.service' +import { ServarrApi } from '../common/servarr-api.service' import { RadarrImportListExclusion, RadarrInfo, RadarrMovie, RadarrMovieFile, -} from '../interfaces/radarr.interface'; +} from '../interfaces/radarr.interface' export class RadarrApi extends ServarrApi<{ movieId: number }> { constructor( @@ -14,62 +14,62 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> { apiKey, cacheName, }: { - url: string; - apiKey: string; - cacheName?: string; + url: string + apiKey: string + cacheName?: string }, protected readonly logger: MaintainerrLogger, ) { - super({ url, apiKey, cacheName }, logger); - this.logger.setContext(ServarrApi.name); + super({ url, apiKey, cacheName }, logger) + this.logger.setContext(ServarrApi.name) } public getMovies = async (): Promise => { try { - const response = await this.get('/movie'); + const response = await this.get('/movie') - return response; + return response } catch (e) { - this.logger.warn(`Failed to retrieve movies`); - this.logger.debug(`Failed to retrieve movies: ${e.message}`); + this.logger.warn(`Failed to retrieve movies`) + this.logger.debug(`Failed to retrieve movies: ${e.message}`) } - }; + } public getMovie = async ({ id }: { id: number }): Promise => { try { - const response = await this.get(`/movie/${id}`); - return response; + const response = await this.get(`/movie/${id}`) + return response } catch (e) { - this.logger.warn(`Failed to retrieve movie with id ${id}`); - this.logger.debug(`Failed to retrieve movie: ${e.message}`); + this.logger.warn(`Failed to retrieve movie with id ${id}`) + this.logger.debug(`Failed to retrieve movie: ${e.message}`) } - }; + } public async getMovieByTmdbId(id: number): Promise { try { - const response = await this.get(`/movie?tmdbId=${id}`); + const response = await this.get(`/movie?tmdbId=${id}`) if (!response[0]) { - this.logger.warn(`Could not find Movie with TMDb id ${id} in Radarr`); + this.logger.warn(`Could not find Movie with TMDb id ${id} in Radarr`) } - return response[0]; + return response[0] } catch (e) { - this.logger.warn(`Error retrieving movie by TMDb ID ${id}`); - this.logger.debug(e); + this.logger.warn(`Error retrieving movie by TMDb ID ${id}`) + this.logger.debug(e) } } public async searchMovie(movieId: number): Promise { - this.logger.log('Executing movie search command'); + this.logger.log('Executing movie search command') try { - await this.runCommand('MoviesSearch', { movieIds: [movieId] }); + await this.runCommand('MoviesSearch', { movieIds: [movieId] }) } catch (e) { this.logger.warn( 'Something went wrong while executing Radarr movie search.', - ); - this.logger.debug(e); + ) + this.logger.debug(e) } } @@ -81,34 +81,34 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> { try { await this.runDelete( `movie/${movieId}?deleteFiles=${deleteFiles}&addImportExclusion=${importExclusion}`, - ); + ) } catch (e) { - this.logger.log("Couldn't delete movie. Does it exist in radarr?"); - this.logger.debug(e); + this.logger.log("Couldn't delete movie. Does it exist in radarr?") + this.logger.debug(e) } } public async updateMovie( movieId: number, options: { - deleteFiles?: boolean; - monitored?: boolean; - addImportExclusion?: boolean; + deleteFiles?: boolean + monitored?: boolean + addImportExclusion?: boolean }, ) { try { - const movieData: RadarrMovie = await this.get(`movie/${movieId}`); + const movieData: RadarrMovie = await this.get(`movie/${movieId}`) if (options?.monitored !== undefined) { - movieData.monitored = options.monitored; + movieData.monitored = options.monitored } - await this.runPut(`movie/${movieId}`, JSON.stringify(movieData)); + await this.runPut(`movie/${movieId}`, JSON.stringify(movieData)) if (options?.deleteFiles) { const movieFiles: RadarrMovieFile[] = await this.get( `moviefile?movieId=${movieId}`, - ); + ) for (const movieFile of movieFiles) { - await this.runDelete(`moviefile/${movieFile.id}`); + await this.runDelete(`moviefile/${movieFile.id}`) } } @@ -117,11 +117,11 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> { tmdbId: movieData.tmdbId, movieTitle: movieData.title, movieYear: movieData.year, - } satisfies RadarrImportListExclusion); + } satisfies RadarrImportListExclusion) } } catch (e) { - this.logger.warn("Couldn't unmonitor movie. Does it exist in radarr?"); - this.logger.debug(e); + this.logger.warn("Couldn't unmonitor movie. Does it exist in radarr?") + this.logger.debug(e) } } @@ -131,12 +131,12 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> { await this.axios.get(`system/status`, { signal: AbortSignal.timeout(10000), // aborts request after 10 seconds }) - ).data; - return info ? info : null; + ).data + return info ? info : null } catch (e) { - this.logger.warn("Couldn't fetch Radarr info.. Is Radarr up?"); - this.logger.debug(e); - return null; + this.logger.warn("Couldn't fetch Radarr info.. Is Radarr up?") + this.logger.debug(e) + return null } } } diff --git a/apps/server/src/modules/api/servarr-api/helpers/sonarr.helper.ts b/apps/server/src/modules/api/servarr-api/helpers/sonarr.helper.ts index 7a3d0142..a613caa6 100644 --- a/apps/server/src/modules/api/servarr-api/helpers/sonarr.helper.ts +++ b/apps/server/src/modules/api/servarr-api/helpers/sonarr.helper.ts @@ -1,16 +1,16 @@ -import { MaintainerrLogger } from '../../../logging/logs.service'; -import { ServarrApi } from '../common/servarr-api.service'; +import { MaintainerrLogger } from '../../../logging/logs.service' +import { ServarrApi } from '../common/servarr-api.service' import { SonarrEpisode, SonarrEpisodeFile, SonarrInfo, SonarrSeason, SonarrSeries, -} from '../interfaces/sonarr.interface'; +} from '../interfaces/sonarr.interface' export class SonarrApi extends ServarrApi<{ - seriesId: number; - episodeId: number; + seriesId: number + episodeId: number }> { constructor( { @@ -18,24 +18,24 @@ export class SonarrApi extends ServarrApi<{ apiKey, cacheName, }: { - url: string; - apiKey: string; - cacheName?: string; + url: string + apiKey: string + cacheName?: string }, protected readonly logger: MaintainerrLogger, ) { - super({ url, apiKey, cacheName }, logger); - this.logger.setContext(SonarrApi.name); + super({ url, apiKey, cacheName }, logger) + this.logger.setContext(SonarrApi.name) } public async getSeries(): Promise { try { - const response = await this.get('/series'); + const response = await this.get('/series') - return response; + return response } catch (e) { - this.logger.warn(`Failed to retrieve series: ${e.message}`); - this.logger.debug(e); + this.logger.warn(`Failed to retrieve series: ${e.message}`) + this.logger.debug(e) } } @@ -49,16 +49,16 @@ export class SonarrApi extends ServarrApi<{ `/episode?seriesId=${seriesID}${ seasonNumber ? `&seasonNumber=${seasonNumber}` : '' }`, - ); + ) return episodeNumbers ? response.filter((el) => episodeNumbers.includes(el.episodeNumber)) - : response; + : response } catch (e) { this.logger.warn( `Failed to retrieve show ${seriesID}'s episodes ${episodeNumbers.join(', ')}: ${e.message}`, - ); - this.logger.debug(e); + ) + this.logger.debug(e) } } public async getEpisodeFile( @@ -67,14 +67,14 @@ export class SonarrApi extends ServarrApi<{ try { const response = await this.get( `/episodefile/${episodeFileId}`, - ); + ) - return response; + return response } catch (e) { this.logger.warn( `Failed to retrieve episode file id ${episodeFileId}: ${e.message}`, - ); - this.logger.debug(e); + ) + this.logger.debug(e) } } @@ -84,53 +84,51 @@ export class SonarrApi extends ServarrApi<{ params: { term: title, }, - }); + }) if (!response[0]) { - this.logger.warn(`Series not found`); + this.logger.warn(`Series not found`) } - return response; + return response } catch (e) { this.logger.warn( `Error retrieving series by series title '${title}': ${e.message}`, - ); - this.logger.debug(e); + ) + this.logger.debug(e) } } public async getSeriesByTvdbId(id: number): Promise { try { - const response = await this.get(`/series?tvdbId=${id}`); + const response = await this.get(`/series?tvdbId=${id}`) if (!response?.[0]) { - this.logger.warn(`Could not retrieve show by tvdb ID ${id}`); - return undefined; + this.logger.warn(`Could not retrieve show by tvdb ID ${id}`) + return undefined } - return response[0]; + return response[0] } catch (e) { - this.logger.warn(`Error retrieving show by tvdb ID ${id}. ${e.message}`); - this.logger.debug(e); + this.logger.warn(`Error retrieving show by tvdb ID ${id}. ${e.message}`) + this.logger.debug(e) } } public async updateSeries(series: SonarrSeries) { - await this.axios.put('/series', series); + await this.axios.put('/series', series) } public async searchSeries(seriesId: number): Promise { - this.logger.log( - `Executing series search command for seriesId ${seriesId}.`, - ); + this.logger.log(`Executing series search command for seriesId ${seriesId}.`) try { - await this.runCommand('SeriesSearch', { seriesId }); + await this.runCommand('SeriesSearch', { seriesId }) } catch (e) { this.logger.log( `Something went wrong while executing Sonarr series search for series Id ${seriesId}: ${e.message}`, - ); - this.logger.debug(e); + ) + this.logger.debug(e) } } @@ -139,16 +137,16 @@ export class SonarrApi extends ServarrApi<{ deleteFiles = true, importListExclusion = false, ) { - this.logger.log(`Deleting show with ID ${seriesId} from Sonarr.`); + this.logger.log(`Deleting show with ID ${seriesId} from Sonarr.`) try { await this.runDelete( `series/${seriesId}?deleteFiles=${deleteFiles}&addImportListExclusion=${importListExclusion}`, - ); + ) } catch (e) { this.logger.log( `Couldn't delete show by ID ${seriesId}. Does it exist in Sonarr? ${e.message}`, - ); - this.logger.debug(e); + ) + this.logger.debug(e) } } @@ -162,30 +160,30 @@ export class SonarrApi extends ServarrApi<{ `${!deleteFiles ? 'Unmonitoring' : 'Deleting'} ${ episodeIds.length } episode(s) from show with ID ${seriesId} from Sonarr.`, - ); + ) try { const episodes = await this.getEpisodes( seriesId, seasonNumber, episodeIds, - ); + ) for (const e of episodes) { // unmonitor await this.runPut( `episode/${e.id}`, JSON.stringify({ ...e, monitored: false }), - ); + ) // also delete if required if (deleteFiles) { - await this.runDelete(`episodefile/${e.episodeFileId}`); + await this.runDelete(`episodefile/${e.episodeFileId}`) } } } catch (e) { this.logger.warn( `Couldn't remove/unmonitor episodes: ${episodeIds.join(', ')} for series ID: ${seriesId}`, - ); - this.logger.debug(e); + ) + this.logger.debug(e) } } @@ -197,17 +195,17 @@ export class SonarrApi extends ServarrApi<{ ): Promise { try { const data: SonarrSeries = (await this.axios.get(`series/${seriesId}`)) - .data; + .data const episodes: SonarrEpisode[] = await this.get( `episodefile?seriesId=${seriesId}`, - ); + ) // loop seasons data.seasons = await Promise.all( data.seasons.map(async (s) => { if (type === 'all') { - s.monitored = false; + s.monitored = false } else if ( type === 'existing' || (forceExisting && type === s.seasonNumber) @@ -220,29 +218,29 @@ export class SonarrApi extends ServarrApi<{ e.seasonNumber, [e.id], false, - ); + ) } } } else if (typeof type === 'number') { // specific season if (s.seasonNumber === type) { - s.monitored = false; + s.monitored = false } } - return s; + return s }), - ); - await this.runPut(`series/`, JSON.stringify(data)); + ) + await this.runPut(`series/`, JSON.stringify(data)) // delete files if (deleteFiles) { for (const e of episodes) { if (typeof type === 'number') { if (e.seasonNumber === type) { - await this.runDelete(`episodefile/${e.id}`); + await this.runDelete(`episodefile/${e.id}`) } } else { - await this.runDelete(`episodefile/${e.id}`); + await this.runDelete(`episodefile/${e.id}`) } } } @@ -251,14 +249,14 @@ export class SonarrApi extends ServarrApi<{ `Unmonitored ${ typeof type === 'number' ? `season ${type}` : 'seasons' } from Sonarr show with ID ${seriesId}`, - ); + ) - return data; + return data } catch (e) { this.logger.log( `Couldn't unmonitor/delete seasons for series ID ${seriesId}. Does it exist in Sonarr?`, - ); - this.logger.debug(e); + ) + this.logger.debug(e) } } @@ -269,12 +267,12 @@ export class SonarrApi extends ServarrApi<{ if (existingSeasons) { const newSeasons = existingSeasons.map((season) => { if (seasons.includes(season.seasonNumber)) { - season.monitored = true; + season.monitored = true } - return season; - }); + return season + }) - return newSeasons; + return newSeasons } const newSeasons = seasons.map( @@ -282,9 +280,9 @@ export class SonarrApi extends ServarrApi<{ seasonNumber, monitored: true, }), - ); + ) - return newSeasons; + return newSeasons } public async info(): Promise { @@ -293,12 +291,12 @@ export class SonarrApi extends ServarrApi<{ await this.axios.get(`system/status`, { signal: AbortSignal.timeout(10000), // aborts request after 10 seconds }) - ).data; - return info ? info : null; + ).data + return info ? info : null } catch (e) { - this.logger.warn("Couldn't fetch Sonarr info.. Is Sonarr up?"); - this.logger.debug(e); - return null; + this.logger.warn("Couldn't fetch Sonarr info.. Is Sonarr up?") + this.logger.debug(e) + return null } } } diff --git a/apps/server/src/modules/api/servarr-api/interfaces/radarr.interface.ts b/apps/server/src/modules/api/servarr-api/interfaces/radarr.interface.ts index 34420afb..ed72993a 100644 --- a/apps/server/src/modules/api/servarr-api/interfaces/radarr.interface.ts +++ b/apps/server/src/modules/api/servarr-api/interfaces/radarr.interface.ts @@ -1,131 +1,131 @@ export interface RadarrMovieOptions { - title: string; - qualityProfileId: number; - minimumAvailability: string; - tags: number[]; - profileId: number; - year: number; - rootFolderPath: string; - tmdbId: number; - monitored?: boolean; - searchNow?: boolean; + title: string + qualityProfileId: number + minimumAvailability: string + tags: number[] + profileId: number + year: number + rootFolderPath: string + tmdbId: number + monitored?: boolean + searchNow?: boolean } export interface RadarrMovie { - id: number; - title: string; - originalLanguage: RadarrLanguage; - isAvailable: boolean; - monitored: boolean; - tmdbId: number; - imdbId: string; - titleSlug: string; - folderName: string; - path: string; - qualityProfileId: number; - added: string; - downloaded: boolean; - hasFile: boolean; - movieFile?: RadarrMovieFile; - sizeOnDisk: number; - physicalRelease?: string; - digitalRelease?: string; - inCinemas?: string; - tags: number[]; - ratings: RadarrRatings; - year: number; + id: number + title: string + originalLanguage: RadarrLanguage + isAvailable: boolean + monitored: boolean + tmdbId: number + imdbId: string + titleSlug: string + folderName: string + path: string + qualityProfileId: number + added: string + downloaded: boolean + hasFile: boolean + movieFile?: RadarrMovieFile + sizeOnDisk: number + physicalRelease?: string + digitalRelease?: string + inCinemas?: string + tags: number[] + ratings: RadarrRatings + year: number } export interface RadarrLanguage { - id: number; - name: string | null; + id: number + name: string | null } interface RadarrRatings { - imdb?: RatingChild; - tmdb?: RatingChild; - metacritic?: RatingChild; - rottenTomatoes?: RatingChild; - trakt?: RatingChild; + imdb?: RatingChild + tmdb?: RatingChild + metacritic?: RatingChild + rottenTomatoes?: RatingChild + trakt?: RatingChild } interface RatingChild { - votes: number; - value: number; - type: 'user' | 'critic'; + votes: number + value: number + type: 'user' | 'critic' } export interface RadarrInfo { - appName: string; - version: string; - buildTime: string; - isDebug: boolean; - isProduction: boolean; - isAdmin: boolean; - isUserInteractive: boolean; - startupPath: string; - appData: string; - osName: string; - osVersion: string; - isNetCore: boolean; - isLinux: boolean; - isOsx: boolean; - isWindows: boolean; - isDocker: boolean; - mode: string; - branch: string; - authentication: string; - sqliteVersion: string; - migrationVersion: number; - urlBase: string; - runtimeVersion: string; - runtimeName: string; - startTime: string; - packageVersion: string; - packageAuthor: string; - packageUpdateMechanism: string; + appName: string + version: string + buildTime: string + isDebug: boolean + isProduction: boolean + isAdmin: boolean + isUserInteractive: boolean + startupPath: string + appData: string + osName: string + osVersion: string + isNetCore: boolean + isLinux: boolean + isOsx: boolean + isWindows: boolean + isDocker: boolean + mode: string + branch: string + authentication: string + sqliteVersion: string + migrationVersion: number + urlBase: string + runtimeVersion: string + runtimeName: string + startTime: string + packageVersion: string + packageAuthor: string + packageUpdateMechanism: string } export interface RadarrMediaInfo { - audioBitrate: number; - audioChannels: number; - audioCodec: string; - audioLanguages: string; - audioStreamCount: number; - videoBitDepth: number; - videoBitrate: number; - videoCodec: string; - videoFps: number; - resolution: string; - runTime: string; - scanType: string; - subtitles: string; + audioBitrate: number + audioChannels: number + audioCodec: string + audioLanguages: string + audioStreamCount: number + videoBitDepth: number + videoBitrate: number + videoCodec: string + videoFps: number + resolution: string + runTime: string + scanType: string + subtitles: string } export interface RadarrMovieFile { - id: number; - dateAdded: string; - quality: RadarrQualityContainer; - size: number; - mediaInfo: RadarrMediaInfo; - path: string; - qualityCutoffNotMet: boolean; + id: number + dateAdded: string + quality: RadarrQualityContainer + size: number + mediaInfo: RadarrMediaInfo + path: string + qualityCutoffNotMet: boolean } export interface RadarrQualityContainer { - quality: RadarrQuality; + quality: RadarrQuality } export interface RadarrQuality { - id: number; - name: string; - source: string; - resolution: number; - modifier: string; + id: number + name: string + source: string + resolution: number + modifier: string } export interface RadarrImportListExclusion { - tmdbId: number; - movieTitle: string; - movieYear: number; + tmdbId: number + movieTitle: string + movieYear: number } diff --git a/apps/server/src/modules/api/servarr-api/interfaces/servarr.interface.ts b/apps/server/src/modules/api/servarr-api/interfaces/servarr.interface.ts index bf91ad1a..4d194bea 100644 --- a/apps/server/src/modules/api/servarr-api/interfaces/servarr.interface.ts +++ b/apps/server/src/modules/api/servarr-api/interfaces/servarr.interface.ts @@ -1,82 +1,82 @@ export interface SystemStatus { - version: string; - buildTime: Date; - isDebug: boolean; - isProduction: boolean; - isAdmin: boolean; - isUserInteractive: boolean; - startupPath: string; - appData: string; - osName: string; - osVersion: string; - isNetCore: boolean; - isMono: boolean; - isLinux: boolean; - isOsx: boolean; - isWindows: boolean; - isDocker: boolean; - mode: string; - branch: string; - authentication: string; - sqliteVersion: string; - migrationVersion: number; - urlBase: string; - runtimeVersion: string; - runtimeName: string; - startTime: Date; - packageUpdateMechanism: string; + version: string + buildTime: Date + isDebug: boolean + isProduction: boolean + isAdmin: boolean + isUserInteractive: boolean + startupPath: string + appData: string + osName: string + osVersion: string + isNetCore: boolean + isMono: boolean + isLinux: boolean + isOsx: boolean + isWindows: boolean + isDocker: boolean + mode: string + branch: string + authentication: string + sqliteVersion: string + migrationVersion: number + urlBase: string + runtimeVersion: string + runtimeName: string + startTime: Date + packageUpdateMechanism: string } export interface RootFolder { - id: number; - path: string; - freeSpace: number; - totalSpace: number; + id: number + path: string + freeSpace: number + totalSpace: number unmappedFolders: { - name: string; - path: string; - }[]; + name: string + path: string + }[] } export interface DiskSpaceResource { - id: number; - path: string | null; - label: string | null; - freeSpace: number; - totalSpace: number; + id: number + path: string | null + label: string | null + freeSpace: number + totalSpace: number } export interface QualityProfile { - id: number; - name: string; + id: number + name: string } export interface QueueItem { - size: number; - title: string; - sizeleft: number; - timeleft: string; - estimatedCompletionTime: string; - status: string; - trackedDownloadStatus: string; - trackedDownloadState: string; - downloadId: string; - protocol: string; - downloadClient: string; - indexer: string; - id: number; + size: number + title: string + sizeleft: number + timeleft: string + estimatedCompletionTime: string + status: string + trackedDownloadStatus: string + trackedDownloadState: string + downloadId: string + protocol: string + downloadClient: string + indexer: string + id: number } export interface Tag { - id: number; - label: string; + id: number + label: string } export interface QueueResponse { - page: number; - pageSize: number; - sortKey: string; - sortDirection: string; - totalRecords: number; - records: (QueueItem & QueueItemAppendT)[]; + page: number + pageSize: number + sortKey: string + sortDirection: string + totalRecords: number + records: (QueueItem & QueueItemAppendT)[] } diff --git a/apps/server/src/modules/api/servarr-api/interfaces/sonarr.interface.ts b/apps/server/src/modules/api/servarr-api/interfaces/sonarr.interface.ts index 3b4c14b3..152d4411 100644 --- a/apps/server/src/modules/api/servarr-api/interfaces/sonarr.interface.ts +++ b/apps/server/src/modules/api/servarr-api/interfaces/sonarr.interface.ts @@ -1,88 +1,88 @@ export interface SonarrSeason { - seasonNumber: number; - monitored: boolean; + seasonNumber: number + monitored: boolean statistics?: { - previousAiring?: string; - nextAiring?: string; - episodeFileCount: number; - episodeCount: number; - totalEpisodeCount: number; - sizeOnDisk: number; - percentOfEpisodes: number; - }; + previousAiring?: string + nextAiring?: string + episodeFileCount: number + episodeCount: number + totalEpisodeCount: number + sizeOnDisk: number + percentOfEpisodes: number + } } export interface SonarrInfo { - appName: string; - version: string; - buildTime: string; - isDebug: boolean; - isProduction: boolean; - isAdmin: boolean; - isUserInteractive: boolean; - startupPath: string; - appData: string; - osVersion: string; - isMono: boolean; - isLinux: boolean; - isWindows: boolean; - branch: string; - authentication: boolean; - startOfWeek: number; - urlBase: string; + appName: string + version: string + buildTime: string + isDebug: boolean + isProduction: boolean + isAdmin: boolean + isUserInteractive: boolean + startupPath: string + appData: string + osVersion: string + isMono: boolean + isLinux: boolean + isWindows: boolean + branch: string + authentication: boolean + startOfWeek: number + urlBase: string } export interface SonarrEpisode { - id: number; - airDate: string; - airDateUtc: string; - seriesId: number; - seasonNumber: number; - episodeNumber: number; - episodeFileId: number; // 0 if not downloaded - hasFile: boolean; - monitored: boolean; - finaleType?: 'series' | 'season' | 'midseason'; + id: number + airDate: string + airDateUtc: string + seriesId: number + seasonNumber: number + episodeNumber: number + episodeFileId: number // 0 if not downloaded + hasFile: boolean + monitored: boolean + finaleType?: 'series' | 'season' | 'midseason' } export interface SonarrEpisodeFile { - id: number; - seriesId: number; - seasonNumber: number; - relativePath: string; - path: string; - size: number; - dateAdded: Date; - sceneName?: string; - releaseGroup?: string; + id: number + seriesId: number + seasonNumber: number + relativePath: string + path: string + size: number + dateAdded: Date + sceneName?: string + releaseGroup?: string quality?: { quality: { - id: number; - name: string; - source: string; - resolution: number; - }; + id: number + name: string + source: string + resolution: number + } revision?: { - version: number; - real: number; - isRepack: boolean; - }; - }; + version: number + real: number + isRepack: boolean + } + } mediaInfo?: { - audioBitrate: number; - audioChannels: number; - audioCodec: string; - audioLanguages: string; - audioStreamCount: number; - videoBitDepth: number; - videoBitrate: number; - videoCodec: string; - videoFps: number; - resolution: string; - runTime: string; - scanType: string; - subtitles: string; - }; - qualityCutoffNotMet: boolean; + audioBitrate: number + audioChannels: number + audioCodec: string + audioLanguages: string + audioStreamCount: number + videoBitDepth: number + videoBitrate: number + videoCodec: string + videoFps: number + resolution: string + runTime: string + scanType: string + subtitles: string + } + qualityCutoffNotMet: boolean } export const SonarrSeriesStatusTypes = [ @@ -90,71 +90,71 @@ export const SonarrSeriesStatusTypes = [ 'ended', 'continuing', 'upcoming', -] as const; -export type SonarrSeriesStatusType = (typeof SonarrSeriesStatusTypes)[number]; +] as const +export type SonarrSeriesStatusType = (typeof SonarrSeriesStatusTypes)[number] -export const SonarrSeriesTypes = ['standard', 'daily', 'anime'] as const; -export type SonarrSeriesType = (typeof SonarrSeriesTypes)[number]; +export const SonarrSeriesTypes = ['standard', 'daily', 'anime'] as const +export type SonarrSeriesType = (typeof SonarrSeriesTypes)[number] export interface SonarrSeries { - title: string; - sortTitle: string; - originalLanguage: SonarrLanguage; - status: SonarrSeriesStatusType | null; - overview: string; - network: string; - airTime: string; + title: string + sortTitle: string + originalLanguage: SonarrLanguage + status: SonarrSeriesStatusType | null + overview: string + network: string + airTime: string images: { - coverType: string; - url: string; - }[]; - remotePoster: string; - seasons: SonarrSeason[]; - year: number; - path: string; - seasonFolder: boolean; - monitored: boolean; - useSceneNumbering: boolean; - runtime: number; - tvdbId: number; - tvRageId: number; - tvMazeId: number; - firstAired: string; - lastInfoSync?: string; - seriesType: SonarrSeriesType; - cleanTitle: string; - imdbId: string; - titleSlug: string; - certification: string; - genres: string[]; - tags: number[]; - added: string; + coverType: string + url: string + }[] + remotePoster: string + seasons: SonarrSeason[] + year: number + path: string + seasonFolder: boolean + monitored: boolean + useSceneNumbering: boolean + runtime: number + tvdbId: number + tvRageId: number + tvMazeId: number + firstAired: string + lastInfoSync?: string + seriesType: SonarrSeriesType + cleanTitle: string + imdbId: string + titleSlug: string + certification: string + genres: string[] + tags: number[] + added: string ratings: { - votes: number; - value: number; - }; - qualityProfileId: number; - id?: number; - rootFolderPath?: string; + votes: number + value: number + } + qualityProfileId: number + id?: number + rootFolderPath?: string addOptions?: { - ignoreEpisodesWithFiles?: boolean; - ignoreEpisodesWithoutFiles?: boolean; - searchForMissingEpisodes?: boolean; - }; - statistics?: SonarrStatistics; - ended?: boolean; + ignoreEpisodesWithFiles?: boolean + ignoreEpisodesWithoutFiles?: boolean + searchForMissingEpisodes?: boolean + } + statistics?: SonarrStatistics + ended?: boolean } export interface SonarrLanguage { - id: number; - name: string | null; + id: number + name: string | null } export interface SonarrStatistics { - seasonCount: number; - episodeFileCount: number; - episodeCount: number; - totalEpisodeCount: number; - sizeOnDisk: number; - percentOfEpisodes: number; + seasonCount: number + episodeFileCount: number + episodeCount: number + totalEpisodeCount: number + sizeOnDisk: number + percentOfEpisodes: number } diff --git a/apps/server/src/modules/api/servarr-api/servarr-api.controller.ts b/apps/server/src/modules/api/servarr-api/servarr-api.controller.ts index 22429d7c..a9ba99e1 100644 --- a/apps/server/src/modules/api/servarr-api/servarr-api.controller.ts +++ b/apps/server/src/modules/api/servarr-api/servarr-api.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common'; -import { ServarrService } from './servarr.service'; +import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common' +import { ServarrService } from './servarr.service' @Controller('api/servarr') export class ServarrApiController { @@ -7,13 +7,13 @@ export class ServarrApiController { @Get('sonarr/:id/diskspace') async getSonarrDiskspace(@Param('id', ParseIntPipe) id: number) { - const client = await this.servarrService.getSonarrApiClient(id); - return await client.getDiskspace(); + const client = await this.servarrService.getSonarrApiClient(id) + return await client.getDiskspace() } @Get('radarr/:id/diskspace') async getRadarrDiskspace(@Param('id', ParseIntPipe) id: number) { - const client = await this.servarrService.getRadarrApiClient(id); - return await client.getDiskspace(); + const client = await this.servarrService.getRadarrApiClient(id) + return await client.getDiskspace() } } diff --git a/apps/server/src/modules/api/servarr-api/servarr-api.module.ts b/apps/server/src/modules/api/servarr-api/servarr-api.module.ts index 1f24ff80..389d45d6 100644 --- a/apps/server/src/modules/api/servarr-api/servarr-api.module.ts +++ b/apps/server/src/modules/api/servarr-api/servarr-api.module.ts @@ -1,7 +1,7 @@ -import { Module } from '@nestjs/common'; -import { ExternalApiModule } from '../external-api/external-api.module'; -import { ServarrService } from './servarr.service'; -import { ServarrApiController } from './servarr-api.controller'; +import { Module } from '@nestjs/common' +import { ExternalApiModule } from '../external-api/external-api.module' +import { ServarrService } from './servarr.service' +import { ServarrApiController } from './servarr-api.controller' @Module({ imports: [ExternalApiModule], diff --git a/apps/server/src/modules/api/servarr-api/servarr.service.ts b/apps/server/src/modules/api/servarr-api/servarr.service.ts index 8dfe97df..e8a41d00 100644 --- a/apps/server/src/modules/api/servarr-api/servarr.service.ts +++ b/apps/server/src/modules/api/servarr-api/servarr.service.ts @@ -1,17 +1,17 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { SettingsService } from '../../../modules/settings/settings.service'; -import { MaintainerrLoggerFactory } from '../../logging/logs.service'; -import { RadarrSettingRawDto } from "../../settings/dto's/radarr-setting.dto"; -import { SonarrSettingRawDto } from "../../settings/dto's/sonarr-setting.dto"; -import cacheManager from '../lib/cache'; -import { RadarrApi } from './helpers/radarr.helper'; -import { SonarrApi } from './helpers/sonarr.helper'; +import { forwardRef, Inject, Injectable } from '@nestjs/common' +import { SettingsService } from '../../../modules/settings/settings.service' +import { MaintainerrLoggerFactory } from '../../logging/logs.service' +import { RadarrSettingRawDto } from "../../settings/dto's/radarr-setting.dto" +import { SonarrSettingRawDto } from "../../settings/dto's/sonarr-setting.dto" +import cacheManager from '../lib/cache' +import { RadarrApi } from './helpers/radarr.helper' +import { SonarrApi } from './helpers/sonarr.helper' @Injectable() export class ServarrService { - SonarrApi: SonarrApi; - private radarrApiCache: Record = {}; - private sonarrApiCache: Record = {}; + SonarrApi: SonarrApi + private radarrApiCache: Record = {} + private sonarrApiCache: Record = {} constructor( @Inject(forwardRef(() => SettingsService)) @@ -27,18 +27,18 @@ export class ServarrService { apiKey: `${id.apiKey}`, }, this.loggerFactory.createLogger(), - ); + ) } else { if (!this.sonarrApiCache[id]) { - const setting = await this.settings.getSonarrSetting(id); + const setting = await this.settings.getSonarrSetting(id) if (setting == null || !('id' in setting)) { - throw new Error('Sonarr setting not found'); + throw new Error('Sonarr setting not found') } - const cacheKey = `sonarr-${id}`; + const cacheKey = `sonarr-${id}` if (!cacheManager.getCache(cacheKey)) { - cacheManager.createCache(cacheKey, `Sonarr-${id}`, 'sonarr'); + cacheManager.createCache(cacheKey, `Sonarr-${id}`, 'sonarr') } this.sonarrApiCache[id] = new SonarrApi( @@ -48,10 +48,10 @@ export class ServarrService { cacheName: cacheKey, }, this.loggerFactory.createLogger(), - ); + ) } - return this.sonarrApiCache[id]; + return this.sonarrApiCache[id] } } @@ -63,18 +63,18 @@ export class ServarrService { apiKey: `${id.apiKey}`, }, this.loggerFactory.createLogger(), - ); + ) } else { if (!this.radarrApiCache[id]) { - const setting = await this.settings.getRadarrSetting(id); + const setting = await this.settings.getRadarrSetting(id) if (setting == null || !('id' in setting)) { - throw new Error('Radarr setting not found'); + throw new Error('Radarr setting not found') } - const cacheKey = `radarr-${id}`; + const cacheKey = `radarr-${id}` if (!cacheManager.getCache(cacheKey)) { - cacheManager.createCache(cacheKey, `Radarr-${id}`, 'radarr'); + cacheManager.createCache(cacheKey, `Radarr-${id}`, 'radarr') } this.radarrApiCache[id] = new RadarrApi( @@ -84,22 +84,22 @@ export class ServarrService { cacheName: cacheKey, }, this.loggerFactory.createLogger(), - ); + ) } - return this.radarrApiCache[id]; + return this.radarrApiCache[id] } } public deleteCachedRadarrApiClient(id: number) { if (this.radarrApiCache[id]) { - delete this.radarrApiCache[id]; + delete this.radarrApiCache[id] } } public deleteCachedSonarrApiClient(id: number) { if (this.sonarrApiCache[id]) { - delete this.sonarrApiCache[id]; + delete this.sonarrApiCache[id] } } } diff --git a/apps/server/src/modules/api/tautulli-api/helpers/tautulli-api.helper.ts b/apps/server/src/modules/api/tautulli-api/helpers/tautulli-api.helper.ts index beaf4c67..7c9f8527 100644 --- a/apps/server/src/modules/api/tautulli-api/helpers/tautulli-api.helper.ts +++ b/apps/server/src/modules/api/tautulli-api/helpers/tautulli-api.helper.ts @@ -1,13 +1,13 @@ -import { MaintainerrLogger } from '../../../logging/logs.service'; -import { ExternalApiService } from '../../external-api/external-api.service'; -import cacheManager from '../../lib/cache'; +import { MaintainerrLogger } from '../../../logging/logs.service' +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 }, protected readonly logger: MaintainerrLogger, ) { - logger.setContext(TautulliApi.name); + logger.setContext(TautulliApi.name) super( url, { @@ -17,6 +17,6 @@ export class TautulliApi extends ExternalApiService { { nodeCache: cacheManager.getCache('tautulli').data, }, - ); + ) } } diff --git a/apps/server/src/modules/api/tautulli-api/tautulli-api.controller.ts b/apps/server/src/modules/api/tautulli-api/tautulli-api.controller.ts index 0f224f44..266b499c 100644 --- a/apps/server/src/modules/api/tautulli-api/tautulli-api.controller.ts +++ b/apps/server/src/modules/api/tautulli-api/tautulli-api.controller.ts @@ -1,5 +1,5 @@ -import { Controller } from '@nestjs/common'; -import { TautulliApiService } from './tautulli-api.service'; +import { Controller } from '@nestjs/common' +import { TautulliApiService } from './tautulli-api.service' @Controller('api/tautulli') export class TautulliApiController { diff --git a/apps/server/src/modules/api/tautulli-api/tautulli-api.module.ts b/apps/server/src/modules/api/tautulli-api/tautulli-api.module.ts index b19d1737..ef1e59d8 100644 --- a/apps/server/src/modules/api/tautulli-api/tautulli-api.module.ts +++ b/apps/server/src/modules/api/tautulli-api/tautulli-api.module.ts @@ -1,7 +1,7 @@ -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'; +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], diff --git a/apps/server/src/modules/api/tautulli-api/tautulli-api.service.ts b/apps/server/src/modules/api/tautulli-api/tautulli-api.service.ts index 265d12e9..6b0ff342 100644 --- a/apps/server/src/modules/api/tautulli-api/tautulli-api.service.ts +++ b/apps/server/src/modules/api/tautulli-api/tautulli-api.service.ts @@ -1,21 +1,21 @@ -import { BasicResponseDto } from '@maintainerr/contracts'; -import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { AxiosError, CanceledError } from 'axios'; -import _ from 'lodash'; -import { SettingsService } from '../../..//modules/settings/settings.service'; +import { BasicResponseDto } from '@maintainerr/contracts' +import { forwardRef, Inject, Injectable } from '@nestjs/common' +import { AxiosError, CanceledError } from 'axios' +import _ from 'lodash' +import { SettingsService } from '../../..//modules/settings/settings.service' import { MaintainerrLogger, MaintainerrLoggerFactory, -} from '../../logging/logs.service'; -import { TautulliApi } from './helpers/tautulli-api.helper'; +} from '../../logging/logs.service' +import { TautulliApi } from './helpers/tautulli-api.helper' interface TautulliInfo { - tautulli_version: string; + tautulli_version: string } export interface TautulliUser { - user_id: number; - username: string; + user_id: number + username: string } export interface TautulliMetadata { @@ -26,79 +26,79 @@ export interface TautulliMetadata { | 'track' | 'album' | 'artist' - | 'show'; - rating_key: string; - parent_rating_key: string; - grandparent_rating_key: string; - added_at: string; + | 'show' + rating_key: string + parent_rating_key: string + grandparent_rating_key: string + added_at: string } interface TautulliChildrenMetadata { - children_count: number; - children_list: TautulliMetadata[]; + children_count: number + children_list: TautulliMetadata[] } interface TautulliHistory { - recordsFiltered: number; - recordsTotal: number; - data: TautulliHistoryItem[]; - draw: number; - filter_duration: string; - total_duration: string; + recordsFiltered: number + recordsTotal: number + data: TautulliHistoryItem[] + draw: number + filter_duration: string + total_duration: string } interface TautulliHistoryItem { - user_id: number; - user: string; - watched_status: number; - percent_complete: number; - stopped: number; - rating_key: number; - media_index: number; - parent_media_index: number; + user_id: number + user: string + watched_status: number + percent_complete: number + stopped: number + rating_key: number + media_index: number + parent_media_index: number } export 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; + 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: 'success' + data: T } | { - message: string | null; - result: 'error'; - data: object; - }; + message: string | null + result: 'error' + data: object + } } -const MAX_PAGE_SIZE = 100; +const MAX_PAGE_SIZE = 100 @Injectable() export class TautulliApiService { - api: TautulliApi; + api: TautulliApi constructor( @Inject(forwardRef(() => SettingsService)) @@ -106,12 +106,12 @@ export class TautulliApiService { private readonly logger: MaintainerrLogger, private readonly loggerFactory: MaintainerrLoggerFactory, ) { - logger.setContext(TautulliApiService.name); + logger.setContext(TautulliApiService.name) } public init() { if (!this.settings.tautulli_url) { - return; + return } this.api = new TautulliApi( @@ -120,7 +120,7 @@ export class TautulliApiService { apiKey: this.settings.tautulli_api_key, }, this.loggerFactory.createLogger(), - ); + ) } public async info(): Promise | null> { @@ -133,12 +133,12 @@ export class TautulliApiService { cmd: 'get_tautulli_info', }, }, - ); - return response; + ) + return response } catch (e) { - this.logger.log(`Couldn't fetch Tautulli info: ${e.message}`); - this.logger.debug(e); - return null; + this.logger.log(`Couldn't fetch Tautulli info: ${e.message}`) + this.logger.debug(e) + return null } } @@ -146,29 +146,27 @@ export class TautulliApiService { options?: TautulliHistoryRequestOptions, ): Promise { try { - options.length = options.length ? options.length : MAX_PAGE_SIZE; - options.start = options.start || options.start === 0 ? options.start : 0; + 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 paginated history', - ); + ) } - return response.response.data; + return response.response.data } catch (e) { - this.logger.log( - `Couldn't fetch Tautulli paginated history: ${e.message}`, - ); - this.logger.debug(e); - return null; + this.logger.log(`Couldn't fetch Tautulli paginated history: ${e.message}`) + this.logger.debug(e) + return null } } @@ -180,47 +178,47 @@ export class TautulliApiService { ...options, length: MAX_PAGE_SIZE, start: 0, - }; + } - let data = await this.getPaginatedHistory(newOptions); - const pageSize: number = MAX_PAGE_SIZE; + 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; + data && data && data.recordsFiltered ? data.recordsFiltered : 0 + const pageCount: number = Math.ceil(totalCount / pageSize) + let currentPage = 1 - let results: TautulliHistoryItem[] = []; + 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); + newOptions.start = currentPage * pageSize + data = await this.getPaginatedHistory(newOptions) - currentPage++; + currentPage++ results = _.unionBy( results, data && data.data && data.data && data.data.length ? data.data : [], 'id', - ); + ) if (results.length === totalCount) { - break; + break } } } - return results; + return results } catch (e) { - this.logger.log(`Couldn't fetch Tautulli history: ${e.message}`); - this.logger.debug(e); - return null; + this.logger.log(`Couldn't fetch Tautulli history: ${e.message}`) + this.logger.debug(e) + return null } } @@ -233,17 +231,17 @@ export class TautulliApiService { cmd: 'get_metadata', rating_key: ratingKey, }, - }); + }) if (response.response.result !== 'success') { - throw new Error('Non-success response when fetching Tautulli metadata'); + throw new Error('Non-success response when fetching Tautulli metadata') } - return response.response.data; + return response.response.data } catch (e) { - this.logger.log(`Couldn't fetch Tautulli metadata: ${e.message}`); - this.logger.debug(e); - return null; + this.logger.log(`Couldn't fetch Tautulli metadata: ${e.message}`) + this.logger.debug(e) + return null } } @@ -259,21 +257,19 @@ export class TautulliApiService { rating_key: ratingKey, }, }, - ); + ) if (response.response.result !== 'success') { throw new Error( 'Non-success response when fetching Tautulli children metadata', - ); + ) } - return response.response.data.children_list; + return response.response.data.children_list } catch (e) { - this.logger.log( - `Couldn't fetch Tautulli children metadata: ${e.message}`, - ); - this.logger.debug(e); - return null; + this.logger.log(`Couldn't fetch Tautulli children metadata: ${e.message}`) + this.logger.debug(e) + return null } } @@ -283,17 +279,17 @@ export class TautulliApiService { params: { cmd: 'get_users', }, - }); + }) if (response.response.result !== 'success') { - throw new Error('Non-success response when fetching Tautulli users'); + throw new Error('Non-success response when fetching Tautulli users') } - return response.response.data; + return response.response.data } catch (e) { - this.logger.log(`Couldn't fetch Tautulli users: ${e.message}`); - this.logger.debug(e); - return null; + this.logger.log(`Couldn't fetch Tautulli users: ${e.message}`) + this.logger.debug(e) + return null } } @@ -306,7 +302,7 @@ export class TautulliApiService { url: `${params.url}/api/v2`, }, this.loggerFactory.createLogger(), - ); + ) try { const response = await api.getRawWithoutCache< @@ -316,7 +312,7 @@ export class TautulliApiService { params: { cmd: 'get_tautulli_info', }, - }); + }) if ( typeof response.data !== 'object' || @@ -326,7 +322,7 @@ export class TautulliApiService { const message = typeof response.data === 'object' ? response.data.response?.message - : undefined; + : undefined return { status: 'NOK', @@ -334,18 +330,16 @@ export class TautulliApiService { message: message ?? 'Failure, an unexpected response was returned. The URL is likely incorrect.', - }; + } } else { return { status: 'OK', code: 1, message: response.data.response.data.tautulli_version, - }; + } } } catch (e) { - this.logger.warn( - `A failure occurred testing Tautulli connectivity: ${e}`, - ); + this.logger.warn(`A failure occurred testing Tautulli connectivity: ${e}`) if (e instanceof CanceledError) { return { @@ -353,10 +347,10 @@ export class TautulliApiService { code: 0, message: 'Failured, connection timed out after 10 seconds with no response.', - }; + } } else if (e instanceof AxiosError) { if (e.response?.status === 400) { - const data = e.response.data as Response; + const data = e.response.data as Response // Surface a Tautulli looking response to the user if (data.response?.message && data.response?.result === 'error') { @@ -364,14 +358,14 @@ export class TautulliApiService { status: 'NOK', code: 0, message: data.response.message, - }; + } } } else if (e.response?.status) { return { status: 'NOK', code: 0, message: `Failure, received response: ${e.response?.status} ${e.response?.statusText}.`, - }; + } } } @@ -379,7 +373,7 @@ export class TautulliApiService { status: 'NOK', code: 0, message: `Failure: ${e.message}`, - }; + } } } } diff --git a/apps/server/src/modules/api/tmdb-api/interfaces/tmdb.interface.ts b/apps/server/src/modules/api/tmdb-api/interfaces/tmdb.interface.ts index c117212c..75ac66f2 100644 --- a/apps/server/src/modules/api/tmdb-api/interfaces/tmdb.interface.ts +++ b/apps/server/src/modules/api/tmdb-api/interfaces/tmdb.interface.ts @@ -1,148 +1,148 @@ export interface TmdbMediaResult { - id: number; - media_type: string; - popularity: number; - poster_path?: string; - backdrop_path?: string; - vote_count: number; - vote_average: number; - genre_ids: number[]; - overview: string; - original_language: string; + id: number + media_type: string + popularity: number + poster_path?: string + backdrop_path?: string + vote_count: number + vote_average: number + genre_ids: number[] + overview: string + original_language: string } export interface TmdbMovieResult extends TmdbMediaResult { - media_type: 'movie'; - title: string; - original_title: string; - release_date: string; - adult: boolean; - video: boolean; + media_type: 'movie' + title: string + original_title: string + release_date: string + adult: boolean + video: boolean } export interface TmdbTvResult extends TmdbMediaResult { - media_type: 'tv'; - name: string; - original_name: string; - origin_country: string[]; - first_air_date: string; + media_type: 'tv' + name: string + original_name: string + origin_country: string[] + first_air_date: string } export interface TmdbExternalIdResponse { - movie_results: TmdbMovieResult[]; - tv_results: TmdbTvResult[]; + movie_results: TmdbMovieResult[] + tv_results: TmdbTvResult[] } export interface TmdbCreditCast { - cast_id: number; - character: string; - credit_id: string; - gender?: number; - id: number; - name: string; - order: number; - profile_path?: string; + cast_id: number + character: string + credit_id: string + gender?: number + id: number + name: string + order: number + profile_path?: string } export interface TmdbAggregateCreditCast extends TmdbCreditCast { roles: { - credit_id: string; - character: string; - episode_count: number; - }[]; + credit_id: string + character: string + episode_count: number + }[] } export interface TmdbCreditCrew { - credit_id: string; - gender?: number; - id: number; - name: string; - profile_path?: string; - job: string; - department: string; + credit_id: string + gender?: number + id: number + name: string + profile_path?: string + job: string + department: string } export interface TmdbExternalIds { - imdb_id?: string; - freebase_mid?: string; - freebase_id?: string; - tvdb_id?: number; - tvrage_id?: string; - facebook_id?: string; - instagram_id?: string; - twitter_id?: string; + imdb_id?: string + freebase_mid?: string + freebase_id?: string + tvdb_id?: number + tvrage_id?: string + facebook_id?: string + instagram_id?: string + twitter_id?: string } export interface TmdbProductionCompany { - id: number; - logo_path?: string; - name: string; - origin_country: string; - homepage?: string; - headquarters?: string; - description?: string; + id: number + logo_path?: string + name: string + origin_country: string + homepage?: string + headquarters?: string + description?: string } export interface TmdbMovieDetails { - id: number; - success?: boolean; - imdb_id?: string; - adult: boolean; - backdrop_path?: string; - poster_path?: string; - budget: number; + id: number + success?: boolean + imdb_id?: string + adult: boolean + backdrop_path?: string + poster_path?: string + budget: number genres: { - id: number; - name: string; - }[]; - homepage?: string; - original_language: string; - original_title: string; - overview?: string; - popularity: number; - production_companies: TmdbProductionCompany[]; + id: number + name: string + }[] + homepage?: string + original_language: string + original_title: string + overview?: string + popularity: number + production_companies: TmdbProductionCompany[] production_countries: { - iso_3166_1: string; - name: string; - }[]; - release_date: string; - release_dates: TmdbMovieReleaseResult; - revenue: number; - runtime?: number; + iso_3166_1: string + name: string + }[] + release_date: string + release_dates: TmdbMovieReleaseResult + revenue: number + runtime?: number spoken_languages: { - iso_639_1: string; - name: string; - }[]; - status: string; - tagline?: string; - title: string; - video: boolean; - vote_average: number; - vote_count: number; + iso_639_1: string + name: string + }[] + status: string + tagline?: string + title: string + video: boolean + vote_average: number + vote_count: number credits: { - cast: TmdbCreditCast[]; - crew: TmdbCreditCrew[]; - }; + cast: TmdbCreditCast[] + crew: TmdbCreditCrew[] + } belongs_to_collection?: { - id: number; - name: string; - poster_path?: string; - backdrop_path?: string; - }; - external_ids: TmdbExternalIds; - videos: TmdbVideoResult; + id: number + name: string + poster_path?: string + backdrop_path?: string + } + external_ids: TmdbExternalIds + videos: TmdbVideoResult 'watch/providers'?: { - id: number; - results?: { [iso_3166_1: string]: TmdbWatchProviders }; - }; + id: number + results?: { [iso_3166_1: string]: TmdbWatchProviders } + } } export interface TmdbVideo { - id: string; - key: string; - name: string; - site: 'YouTube'; - size: number; + id: string + key: string + name: string + site: 'YouTube' + size: number type: | 'Clip' | 'Teaser' @@ -150,195 +150,195 @@ export interface TmdbVideo { | 'Featurette' | 'Opening Credits' | 'Behind the Scenes' - | 'Bloopers'; + | 'Bloopers' } export interface TmdbTvEpisodeResult { - id: number; - air_date: string; - episode_number: number; - name: string; - overview: string; - production_code: string; - season_number: number; - show_id: number; - still_path: string; - vote_average: number; - vote_cuont: number; + id: number + air_date: string + episode_number: number + name: string + overview: string + production_code: string + season_number: number + show_id: number + still_path: string + vote_average: number + vote_cuont: number } export interface TmdbTvSeasonResult { - id: number; - air_date: string; - episode_count: number; - name: string; - overview: string; - poster_path?: string; - season_number: number; + id: number + air_date: string + episode_count: number + name: string + overview: string + poster_path?: string + season_number: number } export interface TmdbTvDetails { - id: number; - backdrop_path?: string; - content_ratings: TmdbTvRatingResult; + id: number + backdrop_path?: string + content_ratings: TmdbTvRatingResult created_by: { - id: number; - credit_id: string; - name: string; - gender: number; - profile_path?: string; - }[]; - episode_run_time: number[]; - first_air_date: string; + id: number + credit_id: string + name: string + gender: number + profile_path?: string + }[] + episode_run_time: number[] + first_air_date: string genres: { - id: number; - name: string; - }[]; - homepage: string; - in_production: boolean; - languages: string[]; - last_air_date: string; - last_episode_to_air?: TmdbTvEpisodeResult; - name: string; - next_episode_to_air?: TmdbTvEpisodeResult; - networks: TmdbNetwork[]; - number_of_episodes: number; - number_of_seasons: number; - origin_country: string[]; - original_language: string; - original_name: string; - overview: string; - popularity: number; - poster_path?: string; + id: number + name: string + }[] + homepage: string + in_production: boolean + languages: string[] + last_air_date: string + last_episode_to_air?: TmdbTvEpisodeResult + name: string + next_episode_to_air?: TmdbTvEpisodeResult + networks: TmdbNetwork[] + number_of_episodes: number + number_of_seasons: number + origin_country: string[] + original_language: string + original_name: string + overview: string + popularity: number + poster_path?: string production_companies: { - id: number; - logo_path?: string; - name: string; - origin_country: string; - }[]; + id: number + logo_path?: string + name: string + origin_country: string + }[] production_countries: { - iso_3166_1: string; - name: string; - }[]; + iso_3166_1: string + name: string + }[] spoken_languages: { - english_name: string; - iso_639_1: string; - name: string; - }[]; - seasons: TmdbTvSeasonResult[]; - status: string; - tagline?: string; - type: string; - vote_average: number; - vote_count: number; + english_name: string + iso_639_1: string + name: string + }[] + seasons: TmdbTvSeasonResult[] + status: string + tagline?: string + type: string + vote_average: number + vote_count: number aggregate_credits: { - cast: TmdbAggregateCreditCast[]; - }; + cast: TmdbAggregateCreditCast[] + } credits: { - crew: TmdbCreditCrew[]; - }; - external_ids: TmdbExternalIds; + crew: TmdbCreditCrew[] + } + external_ids: TmdbExternalIds keywords: { - results: TmdbKeyword[]; - }; - videos: TmdbVideoResult; + results: TmdbKeyword[] + } + videos: TmdbVideoResult 'watch/providers'?: { - id: number; - results?: { [iso_3166_1: string]: TmdbWatchProviders }; - }; + id: number + results?: { [iso_3166_1: string]: TmdbWatchProviders } + } } export interface TmdbVideoResult { - results: TmdbVideo[]; + results: TmdbVideo[] } export interface TmdbTvRatingResult { - results: TmdbRating[]; + results: TmdbRating[] } export interface TmdbRating { - iso_3166_1: string; - rating: string; + iso_3166_1: string + rating: string } export interface TmdbMovieReleaseResult { - results: TmdbRelease[]; + results: TmdbRelease[] } export interface TmdbRelease extends TmdbRating { release_dates: { - certification: string; - iso_639_1?: string; - note?: string; - release_date: string; - type: number; - }[]; + certification: string + iso_639_1?: string + note?: string + release_date: string + type: number + }[] } export interface TmdbKeyword { - id: number; - name: string; + id: number + name: string } export interface TmdbPersonDetail { - id: number; - name: string; - birthday: string; - deathday: string; - known_for_department: string; - also_known_as?: string[]; - gender: number; - biography: string; - popularity: string; - place_of_birth?: string; - profile_path?: string; - adult: boolean; - imdb_id?: string; - homepage?: string; + id: number + name: string + birthday: string + deathday: string + known_for_department: string + also_known_as?: string[] + gender: number + biography: string + popularity: string + place_of_birth?: string + profile_path?: string + adult: boolean + imdb_id?: string + homepage?: string } export interface TmdbPersonCredit { - id: number; - original_language: string; - episode_count: number; - overview: string; - origin_country: string[]; - original_name: string; - vote_count: number; - name: string; - media_type?: string; - popularity: number; - credit_id: string; - backdrop_path?: string; - first_air_date: string; - vote_average: number; - genre_ids?: number[]; - poster_path?: string; - original_title: string; - video?: boolean; - title: string; - adult: boolean; - release_date: string; + id: number + original_language: string + episode_count: number + overview: string + origin_country: string[] + original_name: string + vote_count: number + name: string + media_type?: string + popularity: number + credit_id: string + backdrop_path?: string + first_air_date: string + vote_average: number + genre_ids?: number[] + poster_path?: string + original_title: string + video?: boolean + title: string + adult: boolean + release_date: string } export interface TmdbNetwork { - id: number; - name: string; - headquarters?: string; - homepage?: string; - logo_path?: string; - origin_country?: string; + id: number + name: string + headquarters?: string + homepage?: string + logo_path?: string + origin_country?: string } export interface TmdbWatchProviders { - link?: string; - buy?: TmdbWatchProviderDetails[]; - flatrate?: TmdbWatchProviderDetails[]; + link?: string + buy?: TmdbWatchProviderDetails[] + flatrate?: TmdbWatchProviderDetails[] } export interface TmdbWatchProviderDetails { - display_priority?: number; - logo_path?: string; - provider_id: number; - provider_name: string; + display_priority?: number + logo_path?: string + provider_id: number + provider_name: string } diff --git a/apps/server/src/modules/api/tmdb-api/tmdb-id.service.ts b/apps/server/src/modules/api/tmdb-api/tmdb-id.service.ts index cfe84d01..4d91c938 100644 --- a/apps/server/src/modules/api/tmdb-api/tmdb-id.service.ts +++ b/apps/server/src/modules/api/tmdb-api/tmdb-id.service.ts @@ -1,8 +1,8 @@ -import { MediaItem } from '@maintainerr/contracts'; -import { Injectable } from '@nestjs/common'; -import { TmdbApiService } from '../../../modules/api/tmdb-api/tmdb.service'; -import { MaintainerrLogger } from '../../logging/logs.service'; -import { MediaServerFactory } from '../media-server/media-server.factory'; +import { MediaItem } from '@maintainerr/contracts' +import { Injectable } from '@nestjs/common' +import { TmdbApiService } from '../../../modules/api/tmdb-api/tmdb.service' +import { MaintainerrLogger } from '../../logging/logs.service' +import { MediaServerFactory } from '../media-server/media-server.factory' @Injectable() export class TmdbIdService { @@ -11,33 +11,33 @@ export class TmdbIdService { private readonly mediaServerFactory: MediaServerFactory, private readonly logger: MaintainerrLogger, ) { - logger.setContext(TmdbIdService.name); + logger.setContext(TmdbIdService.name) } async getTmdbIdFromMediaServerId( mediaServerId: string, ): Promise<{ type: 'movie' | 'tv'; id: number | undefined }> { try { - const mediaServer = await this.mediaServerFactory.getService(); - let mediaItem = await mediaServer.getMetadata(mediaServerId); + const mediaServer = await this.mediaServerFactory.getService() + let mediaItem = await mediaServer.getMetadata(mediaServerId) if (mediaItem) { // fetch show in case of season / episode mediaItem = mediaItem.grandparentId ? await mediaServer.getMetadata(mediaItem.grandparentId) : mediaItem.parentId ? await mediaServer.getMetadata(mediaItem.parentId) - : mediaItem; + : mediaItem - return this.getTmdbIdFromMediaItem(mediaItem); + return this.getTmdbIdFromMediaItem(mediaItem) } else { this.logger.warn( `Failed to fetch metadata of media server item : ${mediaServerId}`, - ); + ) } } catch (e) { - this.logger.warn(`Failed to fetch id : ${e.message}`); - this.logger.debug(e); - return undefined; + this.logger.warn(`Failed to fetch id : ${e.message}`) + this.logger.debug(e) + return undefined } } @@ -48,12 +48,12 @@ export class TmdbIdService { item: MediaItem, ): Promise<{ type: 'movie' | 'tv'; id: number | undefined }> { try { - let id: number = undefined; + let id: number = undefined if (item.providerIds) { for (const tmdbId of item.providerIds.tmdb || []) { - id = +tmdbId; - if (id) break; + id = +tmdbId + if (id) break } if (!id) { @@ -61,14 +61,14 @@ export class TmdbIdService { const resp = await this.tmdbApi.getByExternalId({ externalId: +tvdbId, type: 'tvdb', - }); + }) if (resp) { id = resp.movie_results?.length > 0 ? resp.movie_results[0]?.id - : resp.tv_results[0]?.id; - if (id) break; + : resp.tv_results[0]?.id + if (id) break } } } @@ -78,14 +78,14 @@ export class TmdbIdService { const resp = await this.tmdbApi.getByExternalId({ externalId: imdbId, type: 'imdb', - }); + }) if (resp) { id = resp.movie_results?.length > 0 ? resp.movie_results[0]?.id - : resp.tv_results[0]?.id; - if (id) break; + : resp.tv_results[0]?.id + if (id) break } } } @@ -95,11 +95,11 @@ export class TmdbIdService { ? 'tv' : 'movie', id: id, - }; + } } catch (e) { - this.logger.warn(`Failed to fetch id : ${e.message}`); - this.logger.debug(e); - return undefined; + this.logger.warn(`Failed to fetch id : ${e.message}`) + this.logger.debug(e) + return undefined } } } diff --git a/apps/server/src/modules/api/tmdb-api/tmdb.controller.ts b/apps/server/src/modules/api/tmdb-api/tmdb.controller.ts index 1b6c8a89..702fbb8a 100644 --- a/apps/server/src/modules/api/tmdb-api/tmdb.controller.ts +++ b/apps/server/src/modules/api/tmdb-api/tmdb.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common'; -import { TmdbApiService } from './tmdb.service'; +import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common' +import { TmdbApiService } from './tmdb.service' @Controller('api/moviedb') export class TmdbApiController { @@ -7,27 +7,27 @@ export class TmdbApiController { @Get('/person/:personId') getPerson(@Param('personId', new ParseIntPipe()) personId: number) { - return this.movieDbApi.getPerson({ personId: personId }); + return this.movieDbApi.getPerson({ personId: personId }) } @Get('/movie/imdb/:id') getMovie(@Param('id') imdbId: string) { return this.movieDbApi.getByExternalId({ externalId: imdbId, type: 'imdb', - }); + }) } @Get('/backdrop/:type/:tmdbId') getBackdropImage( @Param('tmdbId', new ParseIntPipe()) tmdbId: number, @Param('type') type: 'movie' | 'show', ) { - return this.movieDbApi.getBackdropImagePath({ tmdbId: tmdbId, type: type }); + return this.movieDbApi.getBackdropImagePath({ tmdbId: tmdbId, type: type }) } @Get('/image/:type/:tmdbId') getImage( @Param('tmdbId', new ParseIntPipe()) tmdbId: number, @Param('type') type: 'movie' | 'show', ) { - return this.movieDbApi.getImagePath({ tmdbId: tmdbId, type: type }); + return this.movieDbApi.getImagePath({ tmdbId: tmdbId, type: type }) } } diff --git a/apps/server/src/modules/api/tmdb-api/tmdb.module.ts b/apps/server/src/modules/api/tmdb-api/tmdb.module.ts index e0533c02..f62ec97e 100644 --- a/apps/server/src/modules/api/tmdb-api/tmdb.module.ts +++ b/apps/server/src/modules/api/tmdb-api/tmdb.module.ts @@ -1,9 +1,9 @@ -import { Module } from '@nestjs/common'; -import { ExternalApiModule } from '../external-api/external-api.module'; -import { MediaServerModule } from '../media-server/media-server.module'; -import { TmdbIdService } from './tmdb-id.service'; -import { TmdbApiController } from './tmdb.controller'; -import { TmdbApiService } from './tmdb.service'; +import { Module } from '@nestjs/common' +import { ExternalApiModule } from '../external-api/external-api.module' +import { MediaServerModule } from '../media-server/media-server.module' +import { TmdbIdService } from './tmdb-id.service' +import { TmdbApiController } from './tmdb.controller' +import { TmdbApiService } from './tmdb.service' @Module({ imports: [ExternalApiModule, MediaServerModule], diff --git a/apps/server/src/modules/api/tmdb-api/tmdb.service.ts b/apps/server/src/modules/api/tmdb-api/tmdb.service.ts index 1679fe28..af8b6b0d 100644 --- a/apps/server/src/modules/api/tmdb-api/tmdb.service.ts +++ b/apps/server/src/modules/api/tmdb-api/tmdb.service.ts @@ -1,18 +1,18 @@ -import { Injectable } from '@nestjs/common'; -import { MaintainerrLogger } from '../../logging/logs.service'; -import { ExternalApiService } from '../external-api/external-api.service'; -import cacheManager from '../lib/cache'; +import { Injectable } from '@nestjs/common' +import { MaintainerrLogger } from '../../logging/logs.service' +import { ExternalApiService } from '../external-api/external-api.service' +import cacheManager from '../lib/cache' import { TmdbExternalIdResponse, TmdbMovieDetails, TmdbPersonDetail, TmdbTvDetails, -} from './interfaces/tmdb.interface'; +} from './interfaces/tmdb.interface' @Injectable() export class TmdbApiService extends ExternalApiService { constructor(protected readonly logger: MaintainerrLogger) { - logger.setContext(TmdbApiService.name); + logger.setContext(TmdbApiService.name) super( 'https://api.themoviedb.org/3', { @@ -22,34 +22,34 @@ export class TmdbApiService extends ExternalApiService { { nodeCache: cacheManager.getCache('tmdb').data, }, - ); + ) } public getPerson = async ({ personId, language = 'en', }: { - personId: number; - language?: string; + personId: number + language?: string }): Promise => { try { const data = await this.get(`/person/${personId}`, { params: { language }, - }); + }) - return data; + return data } catch (e) { - this.logger.warn(`Failed to fetch person details: ${e.message}`); - this.logger.debug(e); + this.logger.warn(`Failed to fetch person details: ${e.message}`) + this.logger.debug(e) } - }; + } public getMovie = async ({ movieId, language = 'en', }: { - movieId: number; - language?: string; + movieId: number + language?: string }): Promise => { try { const data = await this.get( @@ -62,21 +62,21 @@ export class TmdbApiService extends ExternalApiService { }, }, 43200, - ); + ) - return data; + return data } catch (e) { - this.logger.warn(`Failed to fetch movie details: ${e.message}`); - this.logger.debug(e); + this.logger.warn(`Failed to fetch movie details: ${e.message}`) + this.logger.debug(e) } - }; + } public getTvShow = async ({ tvId, language = 'en', }: { - tvId: number; - language?: string; + tvId: number + language?: string }): Promise => { try { const data = await this.get( @@ -89,53 +89,53 @@ export class TmdbApiService extends ExternalApiService { }, }, 43200, - ); + ) - return data; + return data } catch (e) { - this.logger.warn(`Failed to fetch TV show details: ${e.message}`); - this.logger.debug(e); + this.logger.warn(`Failed to fetch TV show details: ${e.message}`) + this.logger.debug(e) } - }; + } // TODO: ADD CACHING!!!! public getImagePath = async ({ tmdbId, type, }: { - tmdbId: number; - type: 'movie' | 'show'; + tmdbId: number + type: 'movie' | 'show' }): Promise => { try { if (type === 'movie') { - return (await this.getMovie({ movieId: tmdbId }))?.poster_path; + return (await this.getMovie({ movieId: tmdbId }))?.poster_path } else { - return (await this.getTvShow({ tvId: tmdbId }))?.poster_path; + return (await this.getTvShow({ tvId: tmdbId }))?.poster_path } } catch (e) { - this.logger.warn(`Failed to fetch image path: ${e.message}`); - this.logger.debug(e); + this.logger.warn(`Failed to fetch image path: ${e.message}`) + this.logger.debug(e) } - }; + } public getBackdropImagePath = async ({ tmdbId, type, }: { - tmdbId: number; - type: 'movie' | 'show'; + tmdbId: number + type: 'movie' | 'show' }): Promise => { try { if (type === 'movie') { - return (await this.getMovie({ movieId: tmdbId }))?.backdrop_path; + return (await this.getMovie({ movieId: tmdbId }))?.backdrop_path } else { - return (await this.getTvShow({ tvId: tmdbId }))?.backdrop_path; + return (await this.getTvShow({ tvId: tmdbId }))?.backdrop_path } } catch (e) { - this.logger.warn(`Failed to fetch backdrop image path: ${e.message}`); - this.logger.debug(e); + this.logger.warn(`Failed to fetch backdrop image path: ${e.message}`) + this.logger.debug(e) } - }; + } public async getByExternalId({ externalId, @@ -143,14 +143,14 @@ export class TmdbApiService extends ExternalApiService { language = 'en', }: | { - externalId: string; - type: 'imdb'; - language?: string; + externalId: string + type: 'imdb' + language?: string } | { - externalId: number; - type: 'tvdb'; - language?: string; + externalId: number + type: 'tvdb' + language?: string }): Promise { try { const data = await this.get( @@ -161,11 +161,11 @@ export class TmdbApiService extends ExternalApiService { language, }, }, - ); - return data; + ) + return data } catch (e) { - this.logger.warn(`Failed to find by external ID: ${e.message}`); - this.logger.debug(e); + this.logger.warn(`Failed to find by external ID: ${e.message}`) + this.logger.debug(e) } } } diff --git a/apps/server/src/modules/collections/collection-handler.spec.ts b/apps/server/src/modules/collections/collection-handler.spec.ts index 13a2053e..fabd2860 100644 --- a/apps/server/src/modules/collections/collection-handler.spec.ts +++ b/apps/server/src/modules/collections/collection-handler.spec.ts @@ -1,283 +1,283 @@ -import { Mocked, TestBed } from '@suites/unit'; +import { Mocked, TestBed } from '@suites/unit' import { createCollection, createCollectionMedia, createCollectionMediaWithMetadata, createMediaLibraries, -} from '../../../test/utils/data'; -import { RadarrActionHandler } from '../actions/radarr-action-handler'; -import { SonarrActionHandler } from '../actions/sonarr-action-handler'; -import { SeerrApiService } from '../api/seerr-api/seerr-api.service'; -import { MediaItem } from '@maintainerr/contracts'; -import { MediaServerFactory } from '../api/media-server/media-server.factory'; -import { IMediaServerService } from '../api/media-server/media-server.interface'; -import { SettingsService } from '../settings/settings.service'; -import { CollectionHandler } from './collection-handler'; -import { CollectionsService } from './collections.service'; -import { ServarrAction } from './interfaces/collection.interface'; +} from '../../../test/utils/data' +import { RadarrActionHandler } from '../actions/radarr-action-handler' +import { SonarrActionHandler } from '../actions/sonarr-action-handler' +import { SeerrApiService } from '../api/seerr-api/seerr-api.service' +import { MediaItem } from '@maintainerr/contracts' +import { MediaServerFactory } from '../api/media-server/media-server.factory' +import { IMediaServerService } from '../api/media-server/media-server.interface' +import { SettingsService } from '../settings/settings.service' +import { CollectionHandler } from './collection-handler' +import { CollectionsService } from './collections.service' +import { ServarrAction } from './interfaces/collection.interface' describe('CollectionHandler', () => { - let collectionHandler: CollectionHandler; - let mediaServerFactory: Mocked; - let mediaServer: Mocked; - let collectionsService: Mocked; - let radarrActionHandler: Mocked; - let sonarrActionHandler: Mocked; - let seerrApi: Mocked; - let settings: Mocked; + let collectionHandler: CollectionHandler + let mediaServerFactory: Mocked + let mediaServer: Mocked + let collectionsService: Mocked + let radarrActionHandler: Mocked + let sonarrActionHandler: Mocked + let seerrApi: Mocked + let settings: Mocked beforeEach(async () => { const { unit, unitRef } = - await TestBed.solitary(CollectionHandler).compile(); + await TestBed.solitary(CollectionHandler).compile() - collectionHandler = unit; - mediaServerFactory = unitRef.get(MediaServerFactory); - collectionsService = unitRef.get(CollectionsService); - radarrActionHandler = unitRef.get(RadarrActionHandler); - sonarrActionHandler = unitRef.get(SonarrActionHandler); - seerrApi = unitRef.get(SeerrApiService); - settings = unitRef.get(SettingsService); + collectionHandler = unit + mediaServerFactory = unitRef.get(MediaServerFactory) + collectionsService = unitRef.get(CollectionsService) + radarrActionHandler = unitRef.get(RadarrActionHandler) + sonarrActionHandler = unitRef.get(SonarrActionHandler) + seerrApi = unitRef.get(SeerrApiService) + settings = unitRef.get(SettingsService) // Setup media server mock mediaServer = { getMetadata: jest.fn(), deleteFromDisk: jest.fn(), getLibraries: jest.fn(), - } as unknown as Mocked; - mediaServerFactory.getService.mockResolvedValue(mediaServer); - }); + } as unknown as Mocked + mediaServerFactory.getService.mockResolvedValue(mediaServer) + }) // Helper to setup media server mock for each test const mockMediaServerMetadata = (mediaData: MediaItem) => { - mediaServer.getMetadata.mockResolvedValue(mediaData); - }; + mediaServer.getMetadata.mockResolvedValue(mediaData) + } it('should do nothing if action is DO_NOTHING', async () => { const collection = createCollection({ arrAction: ServarrAction.DO_NOTHING, type: 'movie', - }); - const collectionMedia = createCollectionMedia(collection); + }) + const collectionMedia = createCollectionMedia(collection) mediaServer.getLibraries.mockResolvedValue( createMediaLibraries({ id: collection.libraryId.toString(), }), - ); + ) - await collectionHandler.handleMedia(collection, collectionMedia); + await collectionHandler.handleMedia(collection, collectionMedia) - expect(collectionsService.removeFromCollection).not.toHaveBeenCalled(); - }); + expect(collectionsService.removeFromCollection).not.toHaveBeenCalled() + }) it('should delete from disk', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, type: 'show', - }); - const collectionMedia = createCollectionMedia(collection); + }) + const collectionMedia = createCollectionMedia(collection) mediaServer.getLibraries.mockResolvedValue( createMediaLibraries({ id: collection.libraryId.toString(), }), - ); + ) - await collectionHandler.handleMedia(collection, collectionMedia); + await collectionHandler.handleMedia(collection, collectionMedia) - expect(collectionsService.removeFromCollection).toHaveBeenCalledTimes(1); - expect(mediaServer.deleteFromDisk).toHaveBeenCalled(); - }); + expect(collectionsService.removeFromCollection).toHaveBeenCalledTimes(1) + expect(mediaServer.deleteFromDisk).toHaveBeenCalled() + }) it('should call Radarr action handler', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, radarrSettingsId: 1, type: 'movie', - }); - const collectionMedia = createCollectionMedia(collection); + }) + const collectionMedia = createCollectionMedia(collection) mediaServer.getLibraries.mockResolvedValue( createMediaLibraries({ id: collection.libraryId.toString(), type: 'movie', }), - ); + ) - await collectionHandler.handleMedia(collection, collectionMedia); + await collectionHandler.handleMedia(collection, collectionMedia) - expect(collectionsService.removeFromCollection).toHaveBeenCalledTimes(1); - expect(radarrActionHandler.handleAction).toHaveBeenCalled(); - }); + expect(collectionsService.removeFromCollection).toHaveBeenCalledTimes(1) + expect(radarrActionHandler.handleAction).toHaveBeenCalled() + }) it('should call Sonarr action handler', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, sonarrSettingsId: 1, type: 'show', - }); - const collectionMedia = createCollectionMedia(collection); + }) + const collectionMedia = createCollectionMedia(collection) mediaServer.getLibraries.mockResolvedValue( createMediaLibraries({ id: collection.libraryId.toString(), type: 'show', }), - ); + ) - await collectionHandler.handleMedia(collection, collectionMedia); + await collectionHandler.handleMedia(collection, collectionMedia) - expect(collectionsService.removeFromCollection).toHaveBeenCalledTimes(1); - expect(sonarrActionHandler.handleAction).toHaveBeenCalled(); - }); + expect(collectionsService.removeFromCollection).toHaveBeenCalledTimes(1) + expect(sonarrActionHandler.handleAction).toHaveBeenCalled() + }) it('should call removeSeasonRequest for seasons', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, forceSeerr: true, type: 'season', - }); - const collectionMedia = createCollectionMediaWithMetadata(collection); + }) + const collectionMedia = createCollectionMediaWithMetadata(collection) - settings.seerrConfigured.mockReturnValue(true); + settings.seerrConfigured.mockReturnValue(true) mediaServer.getLibraries.mockResolvedValue( createMediaLibraries({ id: collection.libraryId.toString(), type: 'show', }), - ); - mockMediaServerMetadata(collectionMedia.mediaData); + ) + mockMediaServerMetadata(collectionMedia.mediaData) - await collectionHandler.handleMedia(collection, collectionMedia); + await collectionHandler.handleMedia(collection, collectionMedia) expect(seerrApi.removeSeasonRequest).toHaveBeenCalledWith( collectionMedia.tmdbId, collectionMedia.mediaData.index, - ); - expect(seerrApi.removeSeasonRequest).toHaveBeenCalledTimes(1); - }); + ) + expect(seerrApi.removeSeasonRequest).toHaveBeenCalledTimes(1) + }) it('should call removeSeasonRequest for episodes', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, forceSeerr: true, type: 'episode', - }); - const collectionMedia = createCollectionMediaWithMetadata(collection); + }) + const collectionMedia = createCollectionMediaWithMetadata(collection) - settings.seerrConfigured.mockReturnValue(true); + settings.seerrConfigured.mockReturnValue(true) mediaServer.getLibraries.mockResolvedValue( createMediaLibraries({ id: collection.libraryId.toString(), type: 'show', }), - ); - mockMediaServerMetadata(collectionMedia.mediaData); + ) + mockMediaServerMetadata(collectionMedia.mediaData) - await collectionHandler.handleMedia(collection, collectionMedia); + await collectionHandler.handleMedia(collection, collectionMedia) expect(seerrApi.removeSeasonRequest).toHaveBeenCalledWith( collectionMedia.tmdbId, collectionMedia.mediaData.parentIndex, - ); - expect(seerrApi.removeSeasonRequest).toHaveBeenCalledTimes(1); - }); + ) + expect(seerrApi.removeSeasonRequest).toHaveBeenCalledTimes(1) + }) it('should call removeMediaByTmdbId for movies', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, forceSeerr: true, type: 'movie', - }); - const collectionMedia = createCollectionMedia(collection); + }) + const collectionMedia = createCollectionMedia(collection) - settings.seerrConfigured.mockReturnValue(true); + settings.seerrConfigured.mockReturnValue(true) mediaServer.getLibraries.mockResolvedValue( createMediaLibraries({ id: collection.libraryId.toString(), type: 'movie', }), - ); + ) - await collectionHandler.handleMedia(collection, collectionMedia); + await collectionHandler.handleMedia(collection, collectionMedia) expect(seerrApi.removeMediaByTmdbId).toHaveBeenCalledWith( collectionMedia.tmdbId, 'movie', - ); - expect(seerrApi.removeMediaByTmdbId).toHaveBeenCalledTimes(1); - }); + ) + expect(seerrApi.removeMediaByTmdbId).toHaveBeenCalledTimes(1) + }) it('should call removeMediaByTmdbId for shows', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, forceSeerr: true, type: 'show', - }); - const collectionMedia = createCollectionMedia(collection); + }) + const collectionMedia = createCollectionMedia(collection) - settings.seerrConfigured.mockReturnValue(true); + settings.seerrConfigured.mockReturnValue(true) mediaServer.getLibraries.mockResolvedValue( createMediaLibraries({ id: collection.libraryId.toString(), type: 'show', }), - ); + ) - await collectionHandler.handleMedia(collection, collectionMedia); + await collectionHandler.handleMedia(collection, collectionMedia) expect(seerrApi.removeMediaByTmdbId).toHaveBeenCalledWith( collectionMedia.tmdbId, 'tv', - ); - expect(seerrApi.removeMediaByTmdbId).toHaveBeenCalledTimes(1); - }); + ) + expect(seerrApi.removeMediaByTmdbId).toHaveBeenCalledTimes(1) + }) it('should not call SeerrApiService if forceSeerr is false', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, forceSeerr: false, type: 'movie', - }); - const collectionMedia = createCollectionMedia(collection); + }) + const collectionMedia = createCollectionMedia(collection) mediaServer.getLibraries.mockResolvedValue( createMediaLibraries({ id: collection.libraryId.toString(), type: 'movie', }), - ); + ) - await collectionHandler.handleMedia(collection, collectionMedia); + await collectionHandler.handleMedia(collection, collectionMedia) - expect(seerrApi.removeMediaByTmdbId).not.toHaveBeenCalled(); - expect(seerrApi.removeSeasonRequest).not.toHaveBeenCalled(); - }); + expect(seerrApi.removeMediaByTmdbId).not.toHaveBeenCalled() + expect(seerrApi.removeSeasonRequest).not.toHaveBeenCalled() + }) it('should not call SeerrApiService if Seerr is not configured', async () => { const collection = createCollection({ arrAction: ServarrAction.DELETE, forceSeerr: false, type: 'movie', - }); - const collectionMedia = createCollectionMedia(collection); + }) + const collectionMedia = createCollectionMedia(collection) - settings.seerrConfigured.mockReturnValue(false); + settings.seerrConfigured.mockReturnValue(false) mediaServer.getLibraries.mockResolvedValue( createMediaLibraries({ id: collection.libraryId.toString(), type: 'movie', }), - ); + ) - await collectionHandler.handleMedia(collection, collectionMedia); + await collectionHandler.handleMedia(collection, collectionMedia) - expect(seerrApi.removeMediaByTmdbId).not.toHaveBeenCalled(); - expect(seerrApi.removeSeasonRequest).not.toHaveBeenCalled(); - }); -}); + expect(seerrApi.removeMediaByTmdbId).not.toHaveBeenCalled() + expect(seerrApi.removeSeasonRequest).not.toHaveBeenCalled() + }) +}) diff --git a/apps/server/src/modules/collections/collection-handler.ts b/apps/server/src/modules/collections/collection-handler.ts index 60a1747d..1e71fbd5 100644 --- a/apps/server/src/modules/collections/collection-handler.ts +++ b/apps/server/src/modules/collections/collection-handler.ts @@ -1,15 +1,15 @@ -import { Injectable } from '@nestjs/common'; -import { RadarrActionHandler } from '../actions/radarr-action-handler'; -import { SonarrActionHandler } from '../actions/sonarr-action-handler'; -import { MediaServerFactory } from '../api/media-server/media-server.factory'; -import { IMediaServerService } from '../api/media-server/media-server.interface'; -import { SeerrApiService } from '../api/seerr-api/seerr-api.service'; -import { MaintainerrLogger } from '../logging/logs.service'; -import { SettingsService } from '../settings/settings.service'; -import { CollectionsService } from './collections.service'; -import { Collection } from './entities/collection.entities'; -import { CollectionMedia } from './entities/collection_media.entities'; -import { ServarrAction } from './interfaces/collection.interface'; +import { Injectable } from '@nestjs/common' +import { RadarrActionHandler } from '../actions/radarr-action-handler' +import { SonarrActionHandler } from '../actions/sonarr-action-handler' +import { MediaServerFactory } from '../api/media-server/media-server.factory' +import { IMediaServerService } from '../api/media-server/media-server.interface' +import { SeerrApiService } from '../api/seerr-api/seerr-api.service' +import { MaintainerrLogger } from '../logging/logs.service' +import { SettingsService } from '../settings/settings.service' +import { CollectionsService } from './collections.service' +import { Collection } from './entities/collection.entities' +import { CollectionMedia } from './entities/collection_media.entities' +import { ServarrAction } from './interfaces/collection.interface' @Injectable() export class CollectionHandler { @@ -22,60 +22,60 @@ export class CollectionHandler { private readonly sonarrActionHandler: SonarrActionHandler, private readonly logger: MaintainerrLogger, ) { - logger.setContext(CollectionHandler.name); + logger.setContext(CollectionHandler.name) } /** * Get the appropriate media server service based on current settings */ private async getMediaServer(): Promise { - return this.mediaServerFactory.getService(); + return this.mediaServerFactory.getService() } public async handleMedia(collection: Collection, media: CollectionMedia) { if (collection.arrAction === ServarrAction.DO_NOTHING) { - return; + return } - const mediaServer = await this.getMediaServer(); - const libraries = await mediaServer.getLibraries(); + const mediaServer = await this.getMediaServer() + const libraries = await mediaServer.getLibraries() const library = libraries.find( (e) => e.id === collection.libraryId.toString(), - ); + ) // TODO Media should only be removed from the collection if the handle action is performed successfully await this.collectionService.removeFromCollection(collection.id, [ { mediaServerId: media.mediaServerId, }, - ]); + ]) // update handled media amount - collection.handledMediaAmount++; + collection.handledMediaAmount++ // save a log record for the handled media item await this.collectionService.CollectionLogRecordForChild( media.mediaServerId, collection.id, 'handle', - ); + ) - await this.collectionService.saveCollection(collection); + await this.collectionService.saveCollection(collection) if (library?.type === 'movie' && collection.radarrSettingsId) { - await this.radarrActionHandler.handleAction(collection, media); + await this.radarrActionHandler.handleAction(collection, media) } else if (library?.type == 'show' && collection.sonarrSettingsId) { - await this.sonarrActionHandler.handleAction(collection, media); + await this.sonarrActionHandler.handleAction(collection, media) } else if (!collection.radarrSettingsId && !collection.sonarrSettingsId) { if (collection.arrAction !== ServarrAction.UNMONITOR) { this.logger.log( `Couldn't utilize *arr to find and remove the media with id ${media.mediaServerId}. Attempting to remove from the filesystem via media server. No unmonitor action was taken.`, - ); - await mediaServer.deleteFromDisk(media.mediaServerId); + ) + await mediaServer.deleteFromDisk(media.mediaServerId) } else { this.logger.log( `*arr unmonitor action isn't possible, since *arr is not available. Didn't unmonitor media with id ${media.mediaServerId}.}`, - ); + ) } } @@ -87,44 +87,44 @@ export class CollectionHandler { case 'season': const mediaDataSeason = await mediaServer.getMetadata( media.mediaServerId, - ); + ) if (mediaDataSeason?.index !== undefined) { await this.seerrApi.removeSeasonRequest( media.tmdbId, mediaDataSeason.index, - ); + ) this.logger.log( `[Seerr] Removed request of season ${mediaDataSeason.index} from show with tmdbid '${media.tmdbId}'`, - ); + ) } - break; + break case 'episode': const mediaDataEpisode = await mediaServer.getMetadata( media.mediaServerId, - ); + ) if (mediaDataEpisode?.parentIndex !== undefined) { await this.seerrApi.removeSeasonRequest( media.tmdbId, mediaDataEpisode.parentIndex, - ); + ) this.logger.log( `[Seerr] Removed request of season ${mediaDataEpisode.parentIndex} from show with tmdbid '${media.tmdbId}'. Because episode ${mediaDataEpisode.index} was removed.'`, - ); + ) } - break; + break default: await this.seerrApi.removeMediaByTmdbId( media.tmdbId, library?.type === 'show' ? 'tv' : 'movie', - ); + ) this.logger.log( `[Seerr] Removed requests of media with tmdbid '${media.tmdbId}'`, - ); - break; + ) + break } } } diff --git a/apps/server/src/modules/collections/collection-worker.server.spec.ts b/apps/server/src/modules/collections/collection-worker.server.spec.ts index 3fe13075..8d728c89 100644 --- a/apps/server/src/modules/collections/collection-worker.server.spec.ts +++ b/apps/server/src/modules/collections/collection-worker.server.spec.ts @@ -1,104 +1,102 @@ -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Mocked, TestBed } from '@suites/unit'; -import { Repository } from 'typeorm'; +import { getRepositoryToken } from '@nestjs/typeorm' +import { Mocked, TestBed } from '@suites/unit' +import { Repository } from 'typeorm' import { createCollection, createCollectionMedia, -} from '../../../test/utils/data'; -import { SeerrApiService } from '../api/seerr-api/seerr-api.service'; -import { SettingsService } from '../settings/settings.service'; -import { ExecutionLockService } from '../tasks/execution-lock.service'; -import { TasksService } from '../tasks/tasks.service'; -import { CollectionHandler } from './collection-handler'; -import { CollectionWorkerService } from './collection-worker.service'; -import { Collection } from './entities/collection.entities'; -import { CollectionMedia } from './entities/collection_media.entities'; -import { ServarrAction } from './interfaces/collection.interface'; - -jest.mock('../../utils/delay'); +} from '../../../test/utils/data' +import { SeerrApiService } from '../api/seerr-api/seerr-api.service' +import { SettingsService } from '../settings/settings.service' +import { ExecutionLockService } from '../tasks/execution-lock.service' +import { TasksService } from '../tasks/tasks.service' +import { CollectionHandler } from './collection-handler' +import { CollectionWorkerService } from './collection-worker.service' +import { Collection } from './entities/collection.entities' +import { CollectionMedia } from './entities/collection_media.entities' +import { ServarrAction } from './interfaces/collection.interface' + +jest.mock('../../utils/delay') describe('CollectionWorkerService', () => { - let collectionWorkerService: CollectionWorkerService; - let taskService: Mocked; - let settings: Mocked; - let collectionRepository: Mocked>; - let collectionMediaRepository: Mocked>; - let seerrApi: Mocked; - let collectionHandler: Mocked; - let executionLock: Mocked; + let collectionWorkerService: CollectionWorkerService + let taskService: Mocked + let settings: Mocked + let collectionRepository: Mocked> + let collectionMediaRepository: Mocked> + let seerrApi: Mocked + let collectionHandler: Mocked + let executionLock: Mocked beforeEach(async () => { const { unit, unitRef } = await TestBed.solitary( CollectionWorkerService, - ).compile(); - - collectionWorkerService = unit; - taskService = unitRef.get(TasksService); - settings = unitRef.get(SettingsService); - collectionRepository = unitRef.get( - getRepositoryToken(Collection) as string, - ); + ).compile() + + collectionWorkerService = unit + taskService = unitRef.get(TasksService) + settings = unitRef.get(SettingsService) + collectionRepository = unitRef.get(getRepositoryToken(Collection) as string) collectionMediaRepository = unitRef.get( getRepositoryToken(CollectionMedia) as string, - ); - seerrApi = unitRef.get(SeerrApiService); - collectionHandler = unitRef.get(CollectionHandler); - executionLock = unitRef.get(ExecutionLockService); + ) + seerrApi = unitRef.get(SeerrApiService) + collectionHandler = unitRef.get(CollectionHandler) + executionLock = unitRef.get(ExecutionLockService) - executionLock.acquire.mockResolvedValue(jest.fn()); - }); + executionLock.acquire.mockResolvedValue(jest.fn()) + }) it('should abort if another instance is running', async () => { - taskService.isRunning.mockReturnValue(true); + taskService.isRunning.mockReturnValue(true) - await collectionWorkerService.execute(); + await collectionWorkerService.execute() - expect(executionLock.acquire).not.toHaveBeenCalled(); - }); + expect(executionLock.acquire).not.toHaveBeenCalled() + }) it('should abort if testing connection fails', async () => { - settings.testConnections.mockResolvedValue(false); + settings.testConnections.mockResolvedValue(false) - await collectionWorkerService.execute(); + await collectionWorkerService.execute() - expect(executionLock.acquire).toHaveBeenCalled(); - expect(collectionRepository.find).not.toHaveBeenCalled(); - }); + expect(executionLock.acquire).toHaveBeenCalled() + expect(collectionRepository.find).not.toHaveBeenCalled() + }) it('should not handle media for Do Nothing collections', async () => { - settings.testConnections.mockResolvedValue(true); + settings.testConnections.mockResolvedValue(true) const collection = createCollection({ arrAction: ServarrAction.DO_NOTHING, - }); + }) - collectionRepository.find.mockResolvedValue([collection]); - collectionMediaRepository.find.mockResolvedValue([]); + collectionRepository.find.mockResolvedValue([collection]) + collectionMediaRepository.find.mockResolvedValue([]) - await collectionWorkerService.execute(); + await collectionWorkerService.execute() - expect(executionLock.acquire).toHaveBeenCalled(); - expect(collectionRepository.find).toHaveBeenCalled(); - expect(collectionHandler.handleMedia).not.toHaveBeenCalled(); - }); + expect(executionLock.acquire).toHaveBeenCalled() + expect(collectionRepository.find).toHaveBeenCalled() + expect(collectionHandler.handleMedia).not.toHaveBeenCalled() + }) it('should handle media for collection and trigger availability syncs', async () => { - settings.testConnections.mockResolvedValue(true); - settings.seerrConfigured.mockReturnValue(true); + settings.testConnections.mockResolvedValue(true) + settings.seerrConfigured.mockReturnValue(true) const collection = createCollection({ arrAction: ServarrAction.DELETE, type: 'show', - }); - const collectionMedia = createCollectionMedia(collection); + }) + const collectionMedia = createCollectionMedia(collection) - collectionRepository.find.mockResolvedValue([collection]); - collectionMediaRepository.find.mockResolvedValue([collectionMedia]); + collectionRepository.find.mockResolvedValue([collection]) + collectionMediaRepository.find.mockResolvedValue([collectionMedia]) - await collectionWorkerService.execute(); + await collectionWorkerService.execute() - expect(executionLock.acquire).toHaveBeenCalled(); - expect(collectionHandler.handleMedia).toHaveBeenCalled(); - expect(seerrApi.api.post).toHaveBeenCalled(); - }); -}); + expect(executionLock.acquire).toHaveBeenCalled() + expect(collectionHandler.handleMedia).toHaveBeenCalled() + expect(seerrApi.api.post).toHaveBeenCalled() + }) +}) diff --git a/apps/server/src/modules/collections/collection-worker.service.ts b/apps/server/src/modules/collections/collection-worker.service.ts index 44948680..40bd4d10 100644 --- a/apps/server/src/modules/collections/collection-worker.service.ts +++ b/apps/server/src/modules/collections/collection-worker.service.ts @@ -3,29 +3,29 @@ import { CollectionHandlerProgressedEventDto, CollectionHandlerStartedEventDto, MaintainerrEvent, -} from '@maintainerr/contracts'; -import { Injectable } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { InjectRepository } from '@nestjs/typeorm'; -import { LessThanOrEqual, Repository } from 'typeorm'; -import { delay } from '../../utils/delay'; -import { SeerrApiService } from '../api/seerr-api/seerr-api.service'; -import { CollectionMediaHandledDto } from '../events/events.dto'; -import { MaintainerrLogger } from '../logging/logs.service'; -import { SettingsService } from '../settings/settings.service'; -import { ExecutionLockService } from '../tasks/execution-lock.service'; -import { TaskBase } from '../tasks/task.base'; -import { TasksService } from '../tasks/tasks.service'; -import { CollectionHandler } from './collection-handler'; -import { CollectionsService } from './collections.service'; -import { Collection } from './entities/collection.entities'; -import { CollectionMedia } from './entities/collection_media.entities'; -import { ServarrAction } from './interfaces/collection.interface'; +} from '@maintainerr/contracts' +import { Injectable } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { InjectRepository } from '@nestjs/typeorm' +import { LessThanOrEqual, Repository } from 'typeorm' +import { delay } from '../../utils/delay' +import { SeerrApiService } from '../api/seerr-api/seerr-api.service' +import { CollectionMediaHandledDto } from '../events/events.dto' +import { MaintainerrLogger } from '../logging/logs.service' +import { SettingsService } from '../settings/settings.service' +import { ExecutionLockService } from '../tasks/execution-lock.service' +import { TaskBase } from '../tasks/task.base' +import { TasksService } from '../tasks/tasks.service' +import { CollectionHandler } from './collection-handler' +import { CollectionsService } from './collections.service' +import { Collection } from './entities/collection.entities' +import { CollectionMedia } from './entities/collection_media.entities' +import { ServarrAction } from './interfaces/collection.interface' @Injectable() export class CollectionWorkerService extends TaskBase { - protected name = 'Collection Handler'; - protected cronSchedule = ''; // overriden in onBootstrapHook + protected name = 'Collection Handler' + protected cronSchedule = '' // overriden in onBootstrapHook constructor( @InjectRepository(Collection) @@ -41,12 +41,12 @@ export class CollectionWorkerService extends TaskBase { protected readonly logger: MaintainerrLogger, private readonly executionLock: ExecutionLockService, ) { - logger.setContext(CollectionWorkerService.name); - super(taskService, logger); + logger.setContext(CollectionWorkerService.name) + super(taskService, logger) } protected onBootstrapHook(): void { - this.cronSchedule = this.settings.collection_handler_job_cron; + this.cronSchedule = this.settings.collection_handler_job_cron } protected async executeTask() { @@ -55,108 +55,108 @@ export class CollectionWorkerService extends TaskBase { new CollectionHandlerStartedEventDto( 'Started handling of all collections', ), - ); + ) // Acquire shared lock to avoid overlap with rule execution - const release = await this.executionLock.acquire('rules-collections-lock'); + const release = await this.executionLock.acquire('rules-collections-lock') try { // Start actual task - const appStatus = await this.settings.testConnections(); + const appStatus = await this.settings.testConnections() if (!appStatus) { this.infoLogger( 'Not all applications are reachable.. Skipping collection handling', - ); + ) this.eventEmitter.emit( MaintainerrEvent.CollectionHandler_Finished, new CollectionHandlerFinishedEventDto('Finished collection handling'), - ); + ) - this.eventEmitter.emit(MaintainerrEvent.CollectionHandler_Failed); - return; + this.eventEmitter.emit(MaintainerrEvent.CollectionHandler_Failed) + return } - this.logger.log('Started handling of all collections'); - let handledCollectionMedia = 0; + this.logger.log('Started handling of all collections') + let handledCollectionMedia = 0 // loop over all active collections const collections = await this.collectionRepo.find({ where: { isActive: true }, - }); + }) const collectionsToHandle = collections.filter((collection) => { if (collection.arrAction === ServarrAction.DO_NOTHING) { this.infoLogger( `Skipping collection '${collection.title}' as its action is 'Do Nothing'`, - ); - return false; + ) + return false } - return true; - }); + return true + }) const collectionHandleMediaGroup: { - collection: Collection; - mediaToHandle: CollectionMedia[]; - }[] = []; + collection: Collection + mediaToHandle: CollectionMedia[] + }[] = [] for (const collection of collectionsToHandle) { const dangerDate = new Date( new Date().getTime() - +collection.deleteAfterDays * 86400000, - ); + ) const mediaToHandle = await this.collectionMediaRepo.find({ where: { collectionId: collection.id, addDate: LessThanOrEqual(dangerDate), }, - }); + }) collectionHandleMediaGroup.push({ collection, mediaToHandle, - }); + }) } - const progressedEvent = new CollectionHandlerProgressedEventDto(); + const progressedEvent = new CollectionHandlerProgressedEventDto() const emitProgressedEvent = () => { - progressedEvent.time = new Date(); + progressedEvent.time = new Date() this.eventEmitter.emit( MaintainerrEvent.CollectionHandler_Progressed, progressedEvent, - ); - }; - progressedEvent.totalCollections = collectionsToHandle.length; + ) + } + progressedEvent.totalCollections = collectionsToHandle.length progressedEvent.totalMediaToHandle = collectionHandleMediaGroup.reduce( (acc, curr) => acc + curr.mediaToHandle.length, 0, - ); - emitProgressedEvent(); + ) + emitProgressedEvent() for (const collectionGroup of collectionHandleMediaGroup) { - const collection = collectionGroup.collection; - const collectionMedia = collectionGroup.mediaToHandle; + const collection = collectionGroup.collection + const collectionMedia = collectionGroup.mediaToHandle progressedEvent.processingCollection = { name: collection.title, processedMedias: 0, totalMedias: collectionMedia.length, - }; - emitProgressedEvent(); + } + emitProgressedEvent() - this.infoLogger(`Handling collection '${collection.title}'`); - const handledMediaForNotification = []; + this.infoLogger(`Handling collection '${collection.title}'`) + const handledMediaForNotification = [] for (const media of collectionMedia) { - await this.collectionHandler.handleMedia(collection, media); - handledCollectionMedia++; - progressedEvent.processingCollection.processedMedias++; - progressedEvent.processedMedias++; + await this.collectionHandler.handleMedia(collection, media) + handledCollectionMedia++ + progressedEvent.processingCollection.processedMedias++ + progressedEvent.processedMedias++ handledMediaForNotification.push({ mediaServerId: media.mediaServerId, - }); - emitProgressedEvent(); + }) + emitProgressedEvent() } // handle notification @@ -168,13 +168,13 @@ export class CollectionWorkerService extends TaskBase { collection.title, { type: 'collection', value: collection.id }, ), - ); + ) } - progressedEvent.processedCollections++; - emitProgressedEvent(); + progressedEvent.processedCollections++ + emitProgressedEvent() - this.infoLogger(`Handling collection '${collection.title}' finished`); + this.infoLogger(`Handling collection '${collection.title}' finished`) } if (handledCollectionMedia > 0) { @@ -183,49 +183,47 @@ export class CollectionWorkerService extends TaskBase { try { await this.seerrApi.api.post( '/settings/jobs/availability-sync/run', - ); + ) this.infoLogger( `All collections handled. Triggered Seerr's availability-sync because media was altered`, - ); + ) } catch (err) { this.logger.error( `Failed to trigger Seerr's availability-sync`, err, - ); + ) } - }); + }) } } else { - this.infoLogger(`All collections handled. No data was altered`); + this.infoLogger(`All collections handled. No data was altered`) } // Update cached total size for all collections - this.infoLogger('Updating collection size cache...'); - const allCollections = await this.collectionRepo.find(); + this.infoLogger('Updating collection size cache...') + const allCollections = await this.collectionRepo.find() for (const collection of allCollections) { try { - await this.collectionsService.updateCollectionTotalSize( - collection.id, - ); + await this.collectionsService.updateCollectionTotalSize(collection.id) } catch (e) { this.logger.debug( `Failed to update size for collection '${collection.title}': ${e.message}`, - ); + ) } } - this.infoLogger('Collection size cache updated'); + this.infoLogger('Collection size cache updated') } finally { - release(); + release() this.eventEmitter.emit( MaintainerrEvent.CollectionHandler_Finished, new CollectionHandlerFinishedEventDto('Finished collection handling'), - ); + ) } } private infoLogger(message: string) { - this.logger.log(message); + this.logger.log(message) } } diff --git a/apps/server/src/modules/collections/collections.controller.ts b/apps/server/src/modules/collections/collections.controller.ts index 6939e8d1..b7296cf6 100644 --- a/apps/server/src/modules/collections/collections.controller.ts +++ b/apps/server/src/modules/collections/collections.controller.ts @@ -1,4 +1,4 @@ -import { ECollectionLogType, MediaItemType } from '@maintainerr/contracts'; +import { ECollectionLogType, MediaItemType } from '@maintainerr/contracts' import { Body, Controller, @@ -11,14 +11,14 @@ import { Post, Put, Query, -} from '@nestjs/common'; -import { CollectionWorkerService } from './collection-worker.service'; -import { CollectionsService } from './collections.service'; +} from '@nestjs/common' +import { CollectionWorkerService } from './collection-worker.service' +import { CollectionsService } from './collections.service' import { AddRemoveCollectionMedia, IAlterableMediaDto, -} from './interfaces/collection-media.interface'; -import { MaintainerrLogger } from '../logging/logs.service'; +} from './interfaces/collection-media.interface' +import { MaintainerrLogger } from '../logging/logs.service' @Controller('api/collections') export class CollectionsController { @@ -27,45 +27,45 @@ export class CollectionsController { private readonly collectionWorkerService: CollectionWorkerService, private readonly logger: MaintainerrLogger, ) { - this.logger.setContext(CollectionsController.name); + this.logger.setContext(CollectionsController.name) } @Post() async createCollection(@Body() request: any) { await this.collectionService.createCollectionWithChildren( request.collection, request.media, - ); + ) } @Post('/add') async addToCollection( @Body() request: { - collectionId: number; - media: AddRemoveCollectionMedia[]; - manual?: boolean; + collectionId: number + media: AddRemoveCollectionMedia[] + manual?: boolean }, ) { await this.collectionService.addToCollection( request.collectionId, request.media, request.manual ? request.manual : false, - ); + ) } @Post('/remove') async removeFromCollection(@Body() request: any) { await this.collectionService.removeFromCollection( request.collectionId, request.media, - ); + ) } @Post('/removeCollection') removeCollection(@Body() request: any) { - return this.collectionService.deleteCollection(request.collectionId); + return this.collectionService.deleteCollection(request.collectionId) } @Put() updateCollection(@Body() request: any) { - return this.collectionService.updateCollection(request); + return this.collectionService.updateCollection(request) } @Post('/handle') @@ -74,7 +74,7 @@ export class CollectionsController { throw new HttpException( 'The collection handler is already running', HttpStatus.CONFLICT, - ); + ) } this.collectionWorkerService.execute().catch((e) => @@ -85,22 +85,22 @@ export class CollectionsController { }, e instanceof Error ? e.stack : undefined, ), - ); + ) } @Put('/schedule/update') updateSchedule(@Body() request: { schedule: string }) { - return this.collectionWorkerService.updateJob(request.schedule); + return this.collectionWorkerService.updateJob(request.schedule) } @Get('/deactivate/:id') deactivate(@Param('id', ParseIntPipe) id: number) { - return this.collectionService.deactivateCollection(id); + return this.collectionService.deactivateCollection(id) } @Get('/activate/:id') activate(@Param('id', ParseIntPipe) id: number) { - return this.collectionService.activateCollection(id); + return this.collectionService.activateCollection(id) } @Get() @@ -109,11 +109,11 @@ export class CollectionsController { @Query('typeId') typeId: MediaItemType, ) { if (libraryId) { - return this.collectionService.getCollections(libraryId, undefined); + return this.collectionService.getCollections(libraryId, undefined) } else if (typeId) { - return this.collectionService.getCollections(undefined, typeId); + return this.collectionService.getCollections(undefined, typeId) } else { - return this.collectionService.getCollections(undefined, undefined); + return this.collectionService.getCollections(undefined, undefined) } } @@ -123,27 +123,27 @@ export class CollectionsController { @Query('typeId') typeId: MediaItemType, ) { if (libraryId) { - return this.collectionService.getCalendarData(libraryId, undefined); + return this.collectionService.getCalendarData(libraryId, undefined) } else if (typeId) { - return this.collectionService.getCalendarData(undefined, typeId); + return this.collectionService.getCalendarData(undefined, typeId) } else { - return this.collectionService.getCalendarData(undefined, undefined); + return this.collectionService.getCalendarData(undefined, undefined) } } @Get('/collection/:id') getCollection(@Param('id', ParseIntPipe) collectionId: number) { - return this.collectionService.getCollection(collectionId); + return this.collectionService.getCollection(collectionId) } @Post('/media/add') ManualActionOnCollection( @Body() request: { - mediaId: string; - context: IAlterableMediaDto; - collectionId: number; - action: 0 | 1; + mediaId: string + context: IAlterableMediaDto + collectionId: number + action: 0 | 1 }, ) { return this.collectionService.MediaCollectionActionWithContext( @@ -151,7 +151,7 @@ export class CollectionsController { request.context, { mediaServerId: request.mediaId }, request.action === 0 ? 'add' : 'remove', - ); + ) } @Delete('/media') deleteMediaFromCollection( @@ -162,18 +162,18 @@ export class CollectionsController { if (!collectionId) { return this.collectionService.removeFromAllCollections([ { mediaServerId: mediaId }, - ]); + ]) } return this.collectionService.removeFromCollection(collectionId, [ { mediaServerId: mediaId }, - ]); + ]) } @Get('/media/') getMediaInCollection( @Query('collectionId', ParseIntPipe) collectionId: number, ) { - return this.collectionService.getCollectionMedia(collectionId); + return this.collectionService.getCollectionMedia(collectionId) } @Get('/media/count') @@ -181,12 +181,12 @@ export class CollectionsController { @Query('collectionId', new ParseIntPipe({ optional: true })) collectionId?: number, ) { - return this.collectionService.getCollectionMediaCount(collectionId); + return this.collectionService.getCollectionMediaCount(collectionId) } @Get('/storage-summary') getStorageSummary() { - return this.collectionService.getCollectionStorageSummary(); + return this.collectionService.getCollectionStorageSummary() } @Get('/media/:id/content/:page') @@ -195,15 +195,15 @@ export class CollectionsController { @Param('page', ParseIntPipe) page: number, @Query('size', new ParseIntPipe({ optional: true })) amount?: number, ) { - const size = amount ?? 25; - const offset = (page - 1) * size; + const size = amount ?? 25 + const offset = (page - 1) * size return this.collectionService.getCollectionMediaWithServerDataAndPaging( id, { offset: offset, size: size, }, - ); + ) } @Get('/exclusions/:id/content/:page') @@ -212,15 +212,15 @@ export class CollectionsController { @Param('page', ParseIntPipe) page: number, @Query('size', new ParseIntPipe({ optional: true })) amount?: number, ) { - const size = amount ?? 25; - const offset = (page - 1) * size; + const size = amount ?? 25 + const offset = (page - 1) * size return this.collectionService.getCollectionExclusionsWithServerDataAndPaging( id, { offset: offset, size: size, }, - ); + ) } @Get('/logs/:id/content/:page') @@ -232,8 +232,8 @@ export class CollectionsController { @Query('filter') filter: ECollectionLogType, @Query('size', new ParseIntPipe({ optional: true })) amount?: number, ) { - const size = amount ?? 25; - const offset = (page - 1) * size; + const size = amount ?? 25 + const offset = (page - 1) * size return this.collectionService.getCollectionLogsWithPaging( id, { @@ -243,6 +243,6 @@ export class CollectionsController { search, sort, filter, - ); + ) } } diff --git a/apps/server/src/modules/collections/collections.module.ts b/apps/server/src/modules/collections/collections.module.ts index 05661ecf..90e4867a 100644 --- a/apps/server/src/modules/collections/collections.module.ts +++ b/apps/server/src/modules/collections/collections.module.ts @@ -1,25 +1,25 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ActionsModule } from '../actions/actions.module'; -import { MediaServerModule } from '../api/media-server/media-server.module'; -import { SeerrApiModule } from '../api/seerr-api/seerr-api.module'; -import { PlexApiModule } from '../api/plex-api/plex-api.module'; -import { ServarrApiModule } from '../api/servarr-api/servarr-api.module'; -import { TautulliApiModule } from '../api/tautulli-api/tautulli-api.module'; -import { TmdbApiModule } from '../api/tmdb-api/tmdb.module'; -import { CollectionLog } from '../collections/entities/collection_log.entities'; -import { CollectionLogCleanerService } from '../collections/tasks/collection-log-cleaner.service'; -import { Exclusion } from '../rules/entities/exclusion.entities'; -import { RuleGroup } from '../rules/entities/rule-group.entities'; -import { RulesModule } from '../rules/rules.module'; -import { SettingsModule } from '../settings/settings.module'; -import { TasksModule } from '../tasks/tasks.module'; -import { CollectionHandler } from './collection-handler'; -import { CollectionWorkerService } from './collection-worker.service'; -import { CollectionsController } from './collections.controller'; -import { CollectionsService } from './collections.service'; -import { Collection } from './entities/collection.entities'; -import { CollectionMedia } from './entities/collection_media.entities'; +import { Module, forwardRef } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' +import { ActionsModule } from '../actions/actions.module' +import { MediaServerModule } from '../api/media-server/media-server.module' +import { SeerrApiModule } from '../api/seerr-api/seerr-api.module' +import { PlexApiModule } from '../api/plex-api/plex-api.module' +import { ServarrApiModule } from '../api/servarr-api/servarr-api.module' +import { TautulliApiModule } from '../api/tautulli-api/tautulli-api.module' +import { TmdbApiModule } from '../api/tmdb-api/tmdb.module' +import { CollectionLog } from '../collections/entities/collection_log.entities' +import { CollectionLogCleanerService } from '../collections/tasks/collection-log-cleaner.service' +import { Exclusion } from '../rules/entities/exclusion.entities' +import { RuleGroup } from '../rules/entities/rule-group.entities' +import { RulesModule } from '../rules/rules.module' +import { SettingsModule } from '../settings/settings.module' +import { TasksModule } from '../tasks/tasks.module' +import { CollectionHandler } from './collection-handler' +import { CollectionWorkerService } from './collection-worker.service' +import { CollectionsController } from './collections.controller' +import { CollectionsService } from './collections.service' +import { Collection } from './entities/collection.entities' +import { CollectionMedia } from './entities/collection_media.entities' @Module({ imports: [ diff --git a/apps/server/src/modules/collections/collections.service.ts b/apps/server/src/modules/collections/collections.service.ts index eb5aa40c..323ec030 100644 --- a/apps/server/src/modules/collections/collections.service.ts +++ b/apps/server/src/modules/collections/collections.service.ts @@ -11,64 +11,64 @@ import { MediaItemWithParent, MediaServerFeature, MediaServerType, -} from '@maintainerr/contracts'; -import { Injectable } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Brackets, DataSource, LessThan, Repository } from 'typeorm'; -import { CollectionLog } from '../../modules/collections/entities/collection_log.entities'; -import { MediaServerFactory } from '../api/media-server/media-server.factory'; -import { IMediaServerService } from '../api/media-server/media-server.interface'; +} from '@maintainerr/contracts' +import { Injectable } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { InjectRepository } from '@nestjs/typeorm' +import { Brackets, DataSource, LessThan, Repository } from 'typeorm' +import { CollectionLog } from '../../modules/collections/entities/collection_log.entities' +import { MediaServerFactory } from '../api/media-server/media-server.factory' +import { IMediaServerService } from '../api/media-server/media-server.interface' import { TmdbMovieDetails, TmdbTvDetails, -} from '../api/tmdb-api/interfaces/tmdb.interface'; -import { TmdbIdService } from '../api/tmdb-api/tmdb-id.service'; -import { TmdbApiService } from '../api/tmdb-api/tmdb.service'; -import { MaintainerrLogger } from '../logging/logs.service'; -import { Exclusion } from '../rules/entities/exclusion.entities'; -import { RuleGroup } from '../rules/entities/rule-group.entities'; -import { SettingsService } from '../settings/settings.service'; -import { Collection } from './entities/collection.entities'; +} from '../api/tmdb-api/interfaces/tmdb.interface' +import { TmdbIdService } from '../api/tmdb-api/tmdb-id.service' +import { TmdbApiService } from '../api/tmdb-api/tmdb.service' +import { MaintainerrLogger } from '../logging/logs.service' +import { Exclusion } from '../rules/entities/exclusion.entities' +import { RuleGroup } from '../rules/entities/rule-group.entities' +import { SettingsService } from '../settings/settings.service' +import { Collection } from './entities/collection.entities' import { CollectionMedia, CollectionMediaWithMetadata, -} from './entities/collection_media.entities'; +} from './entities/collection_media.entities' import { AddRemoveCollectionMedia, IAlterableMediaDto, -} from './interfaces/collection-media.interface'; +} from './interfaces/collection-media.interface' import { ICalendarCollection, ICollection, ServarrAction, -} from './interfaces/collection.interface'; +} from './interfaces/collection.interface' export interface CollectionStorageSummary { - collectionCount: number; - sizedCollectionCount: number; - totalSizeBytes: number; - reclaimableCollectionCount: number; - reclaimableSizeBytes: number; - byLibrary: CollectionStorageLibrarySummary[]; + collectionCount: number + sizedCollectionCount: number + totalSizeBytes: number + reclaimableCollectionCount: number + reclaimableSizeBytes: number + byLibrary: CollectionStorageLibrarySummary[] } export interface CollectionStorageLibrarySummary { - libraryId: string; - collectionCount: number; - collectedCount: number; - totalSizeBytes: number; - reclaimableSizeBytes: number; + libraryId: string + collectionCount: number + collectedCount: number + totalSizeBytes: number + reclaimableSizeBytes: number } interface addCollectionDbResponse { - id: number; - mediaServerId?: string; - isActive: boolean; - visibleOnRecommended: boolean; - visibleOnHome: boolean; - deleteAfterDays: number; - manualCollection: boolean; + id: number + mediaServerId?: string + isActive: boolean + visibleOnRecommended: boolean + visibleOnHome: boolean + deleteAfterDays: number + manualCollection: boolean } @Injectable() @@ -92,35 +92,33 @@ export class CollectionsService { private readonly eventEmitter: EventEmitter2, private readonly logger: MaintainerrLogger, ) { - logger.setContext(CollectionsService.name); + logger.setContext(CollectionsService.name) } /** * Get the appropriate media server service based on current settings */ private async getMediaServer(): Promise { - return this.mediaServerFactory.getService(); + return this.mediaServerFactory.getService() } /** * Get the currently configured media server type */ private async getMediaServerType(): Promise { - return this.mediaServerFactory.getConfiguredServerType(); + return this.mediaServerFactory.getConfiguredServerType() } async getCollection(id?: number, title?: string) { try { if (title) { - return await this.collectionRepo.findOne({ where: { title: title } }); + return await this.collectionRepo.findOne({ where: { title: title } }) } else { - return await this.collectionRepo.findOne({ where: { id: id } }); + return await this.collectionRepo.findOne({ where: { id: id } }) } } catch (err) { - this.logger.warn( - 'An error occurred while performing collection actions.', - ); - return undefined; + this.logger.warn('An error occurred while performing collection actions.') + return undefined } } @@ -128,12 +126,12 @@ export class CollectionsService { try { return await this.CollectionMediaRepo.find({ where: { collectionId: id }, - }); + }) } catch (err) { this.logger.warn( 'An error occurred while performing collection actions: ' + err, - ); - return undefined; + ) + return undefined } } @@ -141,10 +139,10 @@ export class CollectionsService { if (id !== undefined) { return await this.CollectionMediaRepo.count({ where: { collectionId: id }, - }); + }) } // No id = count ALL media across all collections - return await this.CollectionMediaRepo.count(); + return await this.CollectionMediaRepo.count() } public async getCollectionStorageSummary(): Promise { @@ -156,31 +154,31 @@ export class CollectionsService { deleteAfterDays: true, totalSizeBytes: true, }, - }); + }) const mediaCountRows = await this.CollectionMediaRepo.createQueryBuilder( 'media', ) .select('media.collectionId', 'collectionId') .addSelect('COUNT(media.id)', 'mediaCount') .groupBy('media.collectionId') - .getRawMany<{ collectionId: number; mediaCount: string }>(); + .getRawMany<{ collectionId: number; mediaCount: string }>() const mediaCountByCollection = new Map( mediaCountRows.map((row) => [ Number(row.collectionId), Number(row.mediaCount), ]), - ); + ) const librarySummaryById = new Map< string, CollectionStorageLibrarySummary - >(); + >() const summary = collections.reduce( (summary, collection) => { - const sizeBytes = Number(collection.totalSizeBytes ?? 0); - const hasSize = Number.isFinite(sizeBytes) && sizeBytes > 0; + const sizeBytes = Number(collection.totalSizeBytes ?? 0) + const hasSize = Number.isFinite(sizeBytes) && sizeBytes > 0 const isReclaimable = - collection.isActive && (collection.deleteAfterDays ?? 0) > 0; + collection.isActive && (collection.deleteAfterDays ?? 0) > 0 const librarySummary = librarySummaryById.get(collection.libraryId) ?? ({ @@ -189,30 +187,30 @@ export class CollectionsService { collectedCount: 0, totalSizeBytes: 0, reclaimableSizeBytes: 0, - } satisfies CollectionStorageLibrarySummary); + } satisfies CollectionStorageLibrarySummary) - summary.collectionCount += 1; - librarySummary.collectionCount += 1; + summary.collectionCount += 1 + librarySummary.collectionCount += 1 librarySummary.collectedCount += - mediaCountByCollection.get(collection.id) ?? 0; + mediaCountByCollection.get(collection.id) ?? 0 if (hasSize) { - summary.sizedCollectionCount += 1; - summary.totalSizeBytes += sizeBytes; - librarySummary.totalSizeBytes += sizeBytes; + summary.sizedCollectionCount += 1 + summary.totalSizeBytes += sizeBytes + librarySummary.totalSizeBytes += sizeBytes } if (isReclaimable) { - summary.reclaimableCollectionCount += 1; + summary.reclaimableCollectionCount += 1 if (hasSize) { - summary.reclaimableSizeBytes += sizeBytes; - librarySummary.reclaimableSizeBytes += sizeBytes; + summary.reclaimableSizeBytes += sizeBytes + librarySummary.reclaimableSizeBytes += sizeBytes } } - librarySummaryById.set(collection.libraryId, librarySummary); - return summary; + librarySummaryById.set(collection.libraryId, librarySummary) + return summary }, { collectionCount: 0, @@ -222,13 +220,13 @@ export class CollectionsService { reclaimableSizeBytes: 0, byLibrary: [], }, - ); + ) summary.byLibrary = [...librarySummaryById.values()].sort((a, b) => a.libraryId.localeCompare(b.libraryId), - ); + ) - return summary; + return summary } public async getCollectionMediaWithServerDataAndPaging( @@ -236,63 +234,63 @@ export class CollectionsService { { offset = 0, size = 25 }: { offset?: number; size?: number } = {}, ): Promise<{ totalSize: number; items: CollectionMediaWithMetadata[] }> { try { - const mediaServer = await this.getMediaServer(); + const mediaServer = await this.getMediaServer() const queryBuilder = - this.CollectionMediaRepo.createQueryBuilder('collection_media'); + this.CollectionMediaRepo.createQueryBuilder('collection_media') queryBuilder .where('collection_media.collectionId = :id', { id }) .orderBy('collection_media.addDate', 'DESC') .skip(offset) - .take(size); + .take(size) - const itemCount = await queryBuilder.getCount(); - const { entities } = await queryBuilder.getRawAndEntities(); + const itemCount = await queryBuilder.getCount() + const { entities } = await queryBuilder.getRawAndEntities() const entitiesWithMediaData: CollectionMediaWithMetadata[] = ( await Promise.all( entities.map(async (el) => { - const mediaItem = await mediaServer.getMetadata(el.mediaServerId); + const mediaItem = await mediaServer.getMetadata(el.mediaServerId) if (!mediaItem) { this.logger.debug( `Missing metadata for collection media ${el.id} (mediaServerId=${el.mediaServerId}); skipping item without deleting`, - ); - return { ...el, mediaData: undefined }; + ) + return { ...el, mediaData: undefined } } // Get parent metadata if needed (for episodes/seasons) - let parentItem: MediaItem | undefined; + let parentItem: MediaItem | undefined if (mediaItem.grandparentId) { parentItem = await mediaServer.getMetadata( mediaItem.grandparentId, - ); + ) } else if (mediaItem.parentId) { - parentItem = await mediaServer.getMetadata(mediaItem.parentId); + parentItem = await mediaServer.getMetadata(mediaItem.parentId) } const mediaData: MediaItemWithParent = { ...mediaItem, parentItem, - }; + } return { ...el, mediaData, - }; + } }), ) - ).filter((el) => el.mediaData !== undefined); + ).filter((el) => el.mediaData !== undefined) return { totalSize: itemCount, items: entitiesWithMediaData ?? [], - }; + } } catch (err) { this.logger.warn( 'An error occurred while performing collection actions: ' + err, - ); - return undefined; + ) + return undefined } } @@ -302,22 +300,22 @@ export class CollectionsService { * (e.g., after testConnections() in the maintenance task). */ async removeStaleCollectionMedia(): Promise { - const allMedia = await this.CollectionMediaRepo.find(); - const mediaServer = await this.getMediaServer(); - let removedCount = 0; + const allMedia = await this.CollectionMediaRepo.find() + const mediaServer = await this.getMediaServer() + let removedCount = 0 for (const entry of allMedia) { - const metadata = await mediaServer.getMetadata(entry.mediaServerId); + const metadata = await mediaServer.getMetadata(entry.mediaServerId) if (!metadata?.id) { - await this.CollectionMediaRepo.delete(entry.id); - removedCount++; + await this.CollectionMediaRepo.delete(entry.id) + removedCount++ } } if (removedCount > 0) { this.logger.log( `Removed ${removedCount} stale collection media entries (items no longer on media server)`, - ); + ) } } @@ -326,85 +324,85 @@ export class CollectionsService { { offset = 0, size = 25 }: { offset?: number; size?: number } = {}, ): Promise<{ totalSize: number; items: Exclusion[] }> { try { - const mediaServer = await this.getMediaServer(); + const mediaServer = await this.getMediaServer() const rulegroup = await this.ruleGroupRepo.findOne({ where: { collectionId: id, }, - }); + }) if (!rulegroup) { - return { totalSize: 0, items: [] }; + return { totalSize: 0, items: [] } } - const groupId = rulegroup.id; + const groupId = rulegroup.id // Determine which exclusion types to show based on collection dataType // Parent type exclusions should be shown (show exclusion appears in season collection) - const validTypes: string[] = [rulegroup.dataType]; + const validTypes: string[] = [rulegroup.dataType] if (rulegroup.dataType === 'season') { - validTypes.push('show'); + validTypes.push('show') } else if (rulegroup.dataType === 'episode') { - validTypes.push('show', 'season'); + validTypes.push('show', 'season') } - const queryBuilder = this.exclusionRepo.createQueryBuilder('exclusion'); + const queryBuilder = this.exclusionRepo.createQueryBuilder('exclusion') queryBuilder .where( new Brackets((qb) => { qb.where('exclusion.ruleGroupId = :groupId', { groupId }).orWhere( 'exclusion.ruleGroupId is null', - ); + ) }), ) .andWhere('exclusion.type IN (:...validTypes)', { validTypes }) .orderBy('id', 'DESC') .skip(offset) - .take(size); + .take(size) - const itemCount = await queryBuilder.getCount(); - let { entities } = await queryBuilder.getRawAndEntities(); + const itemCount = await queryBuilder.getCount() + let { entities } = await queryBuilder.getRawAndEntities() entities = ( await Promise.all( entities.map(async (el) => { const mediaItem = await mediaServer.getMetadata( el.mediaServerId.toString(), - ); + ) if (!mediaItem) { - return { ...el, mediaData: undefined }; + return { ...el, mediaData: undefined } } // Get parent metadata if needed (for episodes/seasons) - let parentItem: MediaItem | undefined; + let parentItem: MediaItem | undefined if (mediaItem.grandparentId) { parentItem = await mediaServer.getMetadata( mediaItem.grandparentId, - ); + ) } else if (mediaItem.parentId) { - parentItem = await mediaServer.getMetadata(mediaItem.parentId); + parentItem = await mediaServer.getMetadata(mediaItem.parentId) } el.mediaData = { ...mediaItem, parentItem, - }; - return el; + } + return el }), ) - ).filter((el) => el.mediaData !== undefined); + ).filter((el) => el.mediaData !== undefined) return { totalSize: itemCount, items: entities ?? [], - }; + } } catch (err) { this.logger.warn( 'An error occurred while performing collection actions: ' + err, - ); - return undefined; + ) + return undefined } } @@ -416,7 +414,7 @@ export class CollectionsService { : typeId ? { where: { type: typeId } } : undefined, - ); + ) return await Promise.all( collections.map(async (col) => { @@ -424,19 +422,17 @@ export class CollectionsService { where: { collectionId: +col.id, }, - }); + }) return { ...col, media: colls, - }; + } }), - ); + ) } catch (err) { - this.logger.warn( - 'An error occurred while performing collection actions.', - ); - this.logger.debug(err); - return undefined; + this.logger.warn('An error occurred while performing collection actions.') + this.logger.debug(err) + return undefined } } @@ -451,14 +447,14 @@ export class CollectionsService { : typeId ? { where: { type: typeId } } : undefined, - ); + ) const schedulableCollections = collections.filter( (collection) => collection.id != null && collection.deleteAfterDays != null && collection.arrAction !== ServarrAction.DO_NOTHING, - ); + ) return await Promise.all( schedulableCollections.map(async (collection) => { @@ -470,7 +466,7 @@ export class CollectionsService { addDate: 'DESC', id: 'DESC', }, - }); + }) return { id: collection.id, @@ -487,25 +483,25 @@ export class CollectionsService { mediaServerId: mediaItem.mediaServerId, addDate: mediaItem.addDate, })), - }; + } }), - ); + ) } catch (err) { this.logger.warn( 'An error occurred while fetching calendar collection data.', - ); - this.logger.debug(err); - return undefined; + ) + this.logger.debug(err) + return undefined } } async getAllCollections() { try { - return await this.collectionRepo.find(); + return await this.collectionRepo.find() } catch (err) { - this.logger.warn('An error occurred while fetching collections.'); - this.logger.debug(err); - return []; + this.logger.warn('An error occurred while fetching collections.') + this.logger.debug(err) + return [] } } @@ -513,11 +509,11 @@ export class CollectionsService { collection: ICollection, empty = true, ): Promise<{ - dbCollection: addCollectionDbResponse; + dbCollection: addCollectionDbResponse }> { try { - const mediaServer = await this.getMediaServer(); - let mediaCollection: MediaCollection; + const mediaServer = await this.getMediaServer() + let mediaCollection: MediaCollection if ( !empty && @@ -531,10 +527,10 @@ export class CollectionsService { summary: collection?.description, sortTitle: collection?.sortTitle, type: collection.type, - }); + }) // Store the media server ID from the created collection - collection.mediaServerId = mediaCollection.id; + collection.mediaServerId = mediaCollection.id // Handle visibility settings (Plex-only feature) if ( @@ -546,7 +542,7 @@ export class CollectionsService { recommended: collection.visibleOnRecommended, ownHome: collection.visibleOnHome, sharedHome: collection.visibleOnHome, - }); + }) } } // in case of manual, just fetch the collection media server ID @@ -554,7 +550,7 @@ export class CollectionsService { const foundCollection = await this.findMediaServerCollection( collection.manualCollectionName, collection.libraryId, - ); + ) if (foundCollection) { // Handle visibility settings (Plex-only feature) if ( @@ -568,15 +564,15 @@ export class CollectionsService { recommended: collection.visibleOnRecommended, ownHome: collection.visibleOnHome, sharedHome: collection.visibleOnHome, - }); + }) } - collection.mediaServerId = foundCollection.id; + collection.mediaServerId = foundCollection.id } else { this.logger.error( `Manual collection not found.. Is the spelling correct? `, - ); - return undefined; + ) + return undefined } } // create collection in db @@ -584,14 +580,14 @@ export class CollectionsService { await this.addCollectionToDB( collection, collection.mediaServerId ? collection.mediaServerId : undefined, - ); - return { dbCollection: collectionDb }; + ) + return { dbCollection: collectionDb } } catch (err) { this.logger.error( `An error occurred while creating or fetching a collection: ${err}`, - ); - this.logger.debug(err); - return undefined; + ) + this.logger.debug(err) + return undefined } } @@ -599,10 +595,10 @@ export class CollectionsService { collection: ICollection, media?: AddRemoveCollectionMedia[], ): Promise<{ - dbCollection: addCollectionDbResponse; + dbCollection: addCollectionDbResponse }> { try { - const createdCollection = await this.createCollection(collection, false); + const createdCollection = await this.createCollection(collection, false) for (const childMedia of media) { await this.addChildToCollection( @@ -613,43 +609,41 @@ export class CollectionsService { dbId: createdCollection.dbCollection.id, }, childMedia.mediaServerId, - ); + ) } - return createdCollection; + return createdCollection } catch (err) { - this.logger.warn( - 'An error occurred while performing collection actions.', - ); - return undefined; + this.logger.warn('An error occurred while performing collection actions.') + return undefined } } async updateCollection(collection: ICollection): Promise<{ - dbCollection?: ICollection; + dbCollection?: ICollection }> { try { - const mediaServer = await this.getMediaServer(); + const mediaServer = await this.getMediaServer() const dbCollection = await this.collectionRepo.findOne({ where: { id: +collection.id }, - }); + }) const sanitizedSortTitle = collection?.sortTitle && collection.sortTitle.trim() !== '' ? collection.sortTitle - : null; + : null if (dbCollection?.mediaServerId) { // Verify the media server collection still exists before updating const serverColl = await mediaServer.getCollection( dbCollection.mediaServerId, - ); + ) if (!serverColl) { // Collection was deleted from media server - clear the stale link this.logger.log( `Linked media server collection ${dbCollection.mediaServerId} no longer exists, clearing link`, - ); - collection.mediaServerId = null; + ) + collection.mediaServerId = null } else if ( // is the type the same & is it an automatic collection, then update collection.type === dbCollection.type && @@ -665,11 +659,11 @@ export class CollectionsService { title: collection.title, summary: collection?.description, sortTitle: sanitizedSortTitle ?? undefined, - }); + }) } catch (error) { this.logger.warn( `Failed to update collection metadata on media server: ${error instanceof Error ? error.message : String(error)}`, - ); + ) } // Handle visibility settings (Plex-only feature) if ( @@ -683,7 +677,7 @@ export class CollectionsService { recommended: collection.visibleOnRecommended, ownHome: collection.visibleOnHome, sharedHome: collection.visibleOnHome, - }); + }) } } else { // if the type, manual collection, or library changed - reset the media server collection @@ -696,9 +690,9 @@ export class CollectionsService { ) { if (!dbCollection.manualCollection) { // Don't remove the collections if it was a manual one - await mediaServer.deleteCollection(dbCollection.mediaServerId); + await mediaServer.deleteCollection(dbCollection.mediaServerId) } - collection.mediaServerId = null; + collection.mediaServerId = null } } } @@ -707,25 +701,25 @@ export class CollectionsService { ...dbCollection, ...collection, sortTitle: sanitizedSortTitle, - }); + }) await this.addLogRecord( { id: dbResp.id } as Collection, "Successfully updated the collection's settings", ECollectionLogType.COLLECTION, - ); + ) - return { dbCollection: dbResp }; + return { dbCollection: dbResp } } catch (err) { this.logger.warn( `An error occurred while performing collection actions: ${err.message || err}`, - ); + ) await this.addLogRecord( { id: collection.id } as Collection, "Failed to update the collection's settings", ECollectionLogType.COLLECTION, - ); - return undefined; + ) + return undefined } } @@ -733,24 +727,24 @@ export class CollectionsService { if (collection.id) { const oldCollection = await this.collectionRepo.findOne({ where: { id: collection.id }, - }); + }) - const response = await this.collectionRepo.save(collection); + const response = await this.collectionRepo.save(collection) this.eventEmitter.emit(MaintainerrEvent.Collection_Updated, { collection: response, oldCollection: oldCollection, - }); + }) - return response; + return response } else { - const response = await this.collectionRepo.save(collection); + const response = await this.collectionRepo.save(collection) this.eventEmitter.emit(MaintainerrEvent.Collection_Created, { collection: response, - }); + }) - return response; + return response } } @@ -762,67 +756,67 @@ export class CollectionsService { const foundColl = await this.findMediaServerCollection( collection.manualCollectionName, collection.libraryId, - ); + ) if (foundColl) { - collection.mediaServerId = foundColl.id; - collection = await this.saveCollection(collection); + collection.mediaServerId = foundColl.id + collection = await this.saveCollection(collection) await this.addLogRecord( { id: collection.id } as Collection, 'Successfully relinked the manual collection', ECollectionLogType.COLLECTION, - ); + ) } else { this.logger.error( 'Manual collection not found.. Is it still available in the media server?', - ); + ) await this.addLogRecord( { id: collection.id } as Collection, 'Failed to relink the manual collection', ECollectionLogType.COLLECTION, - ); + ) } } - return collection; + return collection } public async checkAutomaticMediaServerLink( collection: Collection, ): Promise { - const mediaServer = await this.getMediaServer(); + const mediaServer = await this.getMediaServer() // checks and fixes automatic collection link if (!collection.manualCollection) { - let serverColl: MediaCollection | undefined = undefined; - const originalMediaServerId = collection.mediaServerId; // Track if we already had a link + let serverColl: MediaCollection | undefined = undefined + const originalMediaServerId = collection.mediaServerId // Track if we already had a link this.logger.debug( `[checkAutomaticMediaServerLink] Collection "${collection.title}" (DB id: ${collection.id}, mediaServerId: ${collection.mediaServerId})`, - ); + ) if (collection.mediaServerId) { - serverColl = await mediaServer.getCollection(collection.mediaServerId); + serverColl = await mediaServer.getCollection(collection.mediaServerId) this.logger.debug( `[checkAutomaticMediaServerLink] getCollection(${collection.mediaServerId}) returned: ${serverColl ? `id=${serverColl.id}, childCount=${serverColl.childCount}` : 'undefined'}`, - ); + ) } if (!serverColl) { const foundColl = await this.findMediaServerCollection( collection.title, collection.libraryId, - ); + ) // Only log if we expected to find it (had a previous link) or if we actually found one if (foundColl || collection.mediaServerId) { this.logger.debug( `[checkAutomaticMediaServerLink] findMediaServerCollection("${collection.title}") returned: ${foundColl ? `id=${foundColl.id}, childCount=${foundColl.childCount}` : 'undefined'}`, - ); + ) } if (foundColl) { - collection.mediaServerId = foundColl.id; - collection = await this.saveCollection(collection); - serverColl = foundColl; + collection.mediaServerId = foundColl.id + collection = await this.saveCollection(collection) + serverColl = foundColl } } @@ -839,31 +833,31 @@ export class CollectionsService { collection.mediaServerId !== null && originalMediaServerId !== null ) { - const children = await mediaServer.getCollectionChildren(serverColl.id); - const actualChildCount = children?.length ?? 0; + const children = await mediaServer.getCollectionChildren(serverColl.id) + const actualChildCount = children?.length ?? 0 if (actualChildCount <= 0) { this.logger.debug( `[checkAutomaticMediaServerLink] Deleting empty collection ${serverColl.id} (actualChildCount=${actualChildCount})`, - ); - await mediaServer.deleteCollection(serverColl.id); - serverColl = undefined; + ) + await mediaServer.deleteCollection(serverColl.id) + serverColl = undefined } else { this.logger.debug( `[checkAutomaticMediaServerLink] Collection ${serverColl.id} has ${actualChildCount} children, keeping it`, - ); + ) } } if (!serverColl) { this.logger.debug( `[checkAutomaticMediaServerLink] Setting mediaServerId to null — collection was empty or not found on media server`, - ); - collection.mediaServerId = null; - collection = await this.saveCollection(collection); + ) + collection.mediaServerId = null + collection = await this.saveCollection(collection) } } - return collection; + return collection } async MediaCollectionActionWithContext( @@ -872,32 +866,32 @@ export class CollectionsService { media: AddRemoveCollectionMedia, action: 'add' | 'remove', ): Promise { - const mediaServer = await this.getMediaServer(); + const mediaServer = await this.getMediaServer() const collection = collectionDbId !== -1 && collectionDbId !== undefined ? await this.collectionRepo.findOne({ where: { id: collectionDbId }, }) - : undefined; + : undefined // get media - traverse show -> seasons -> episodes if needed const ids = await mediaServer.getAllIdsForContextAction( collection?.type, { type: context.type, id: String(context.id) }, media.mediaServerId, - ); + ) const handleMedia: AddRemoveCollectionMedia[] = ids.map((id) => ({ mediaServerId: id, - })); + })) if (handleMedia) { if (action === 'add') { - return this.addToCollection(collectionDbId, handleMedia, true); + return this.addToCollection(collectionDbId, handleMedia, true) } else if (action === 'remove') { if (collectionDbId) { - return this.removeFromCollection(collectionDbId, handleMedia); + return this.removeFromCollection(collectionDbId, handleMedia) } else { - await this.removeFromAllCollections(handleMedia); + await this.removeFromAllCollections(handleMedia) } } } @@ -909,41 +903,41 @@ export class CollectionsService { manual = false, ): Promise { try { - const mediaServer = await this.getMediaServer(); + const mediaServer = await this.getMediaServer() let collection = await this.collectionRepo.findOne({ where: { id: collectionDbId }, - }); + }) const collectionMedia = await this.CollectionMediaRepo.find({ where: { collectionId: collectionDbId }, - }); + }) // filter already existing out const newMedia = media.filter( (m) => !collectionMedia.find((el) => el.mediaServerId === m.mediaServerId), - ); + ) if (collection) { - collection = await this.checkAutomaticMediaServerLink(collection); + collection = await this.checkAutomaticMediaServerLink(collection) // Check if we need to create a new media server collection // This happens when: 1) we have new items to add, OR 2) we have existing items but no media server collection const needsMediaServerCollection = !collection.mediaServerId && - (newMedia.length > 0 || collectionMedia.length > 0); + (newMedia.length > 0 || collectionMedia.length > 0) // Check if we need to sync existing items to a newly created collection const needsResync = - !collection.mediaServerId && collectionMedia.length > 0; + !collection.mediaServerId && collectionMedia.length > 0 // Create media server collection if needed if (needsMediaServerCollection) { - let newColl: MediaCollection | undefined = undefined; + let newColl: MediaCollection | undefined = undefined if (collection.manualCollection) { newColl = await this.findMediaServerCollection( collection.manualCollectionName, collection.libraryId, - ); + ) } else { newColl = await mediaServer.createCollection({ libraryId: collection.libraryId, @@ -951,13 +945,13 @@ export class CollectionsService { summary: collection.description, sortTitle: collection.sortTitle, type: collection.type, - }); + }) } if (newColl?.id) { collection = await this.collectionRepo.save({ ...collection, mediaServerId: newColl.id, - }); + }) // Handle visibility settings (Plex-only feature) if ( mediaServer.supportsFeature( @@ -970,24 +964,24 @@ export class CollectionsService { recommended: collection.visibleOnRecommended, ownHome: collection.visibleOnHome, sharedHome: collection.visibleOnHome, - }); + }) } // If we had existing collection_media items, sync them to the new media server collection if (needsResync) { this.logger.log( `Syncing ${collectionMedia.length} existing items to newly created media server collection`, - ); + ) for (const existingMedia of collectionMedia) { try { await mediaServer.addToCollection( collection.mediaServerId, existingMedia.mediaServerId, - ); + ) } catch (err) { this.logger.warn( `Failed to sync item ${existingMedia.mediaServerId} to collection: ${err.message}`, - ); + ) } } } @@ -995,7 +989,7 @@ export class CollectionsService { if (collection.manualCollection) { this.logger.warn( `Manual Collection '${collection.manualCollectionName}' doesn't exist in media server..`, - ); + ) } } } @@ -1008,22 +1002,20 @@ export class CollectionsService { childMedia.mediaServerId, manual, childMedia.reason, - ); + ) } } // Update cached total size (non-blocking) - this.updateCollectionTotalSize(collectionDbId).catch(() => {}); + this.updateCollectionTotalSize(collectionDbId).catch(() => {}) - return collection; + return collection } else { - this.logger.warn("Collection doesn't exist."); + this.logger.warn("Collection doesn't exist.") } } catch (err) { - this.logger.warn( - 'An error occurred while performing collection actions.', - ); - return undefined; + this.logger.warn('An error occurred while performing collection actions.') + return undefined } } @@ -1032,24 +1024,24 @@ export class CollectionsService { media: AddRemoveCollectionMedia[], ) { try { - const mediaServer = await this.getMediaServer(); + const mediaServer = await this.getMediaServer() let collection = await this.collectionRepo.findOne({ where: { id: collectionDbId }, - }); + }) if (!collection) { this.logger.warn( `Collection with id ${collectionDbId} not found, skipping removal`, - ); - return undefined; + ) + return undefined } - collection = await this.checkAutomaticMediaServerLink(collection); + collection = await this.checkAutomaticMediaServerLink(collection) let collectionMedia = await this.CollectionMediaRepo.find({ where: { collectionId: collectionDbId, }, - }); + }) if (collectionMedia.length > 0) { for (const childMedia of media) { if ( @@ -1061,11 +1053,11 @@ export class CollectionsService { { mediaServerId: collection.mediaServerId, dbId: collection.id }, childMedia.mediaServerId, childMedia.reason, - ); + ) collectionMedia = collectionMedia.filter( (el) => el.mediaServerId !== childMedia.mediaServerId, - ); + ) } } @@ -1075,127 +1067,123 @@ export class CollectionsService { collection.mediaServerId ) { try { - await mediaServer.deleteCollection(collection.mediaServerId); + await mediaServer.deleteCollection(collection.mediaServerId) collection = await this.collectionRepo.save({ ...collection, mediaServerId: null, - }); + }) } catch (err) { this.logger.warn( `Failed to delete collection from media server: ${err.message}`, - ); + ) } } } // Update cached total size (non-blocking) - this.updateCollectionTotalSize(collectionDbId).catch(() => {}); + this.updateCollectionTotalSize(collectionDbId).catch(() => {}) - return collection; + return collection } catch (err) { this.logger.warn( `An error occurred while removing media from collection with internal id ${collectionDbId}`, - ); - this.logger.debug(err); - return undefined; + ) + this.logger.debug(err) + return undefined } } async removeFromAllCollections(media: AddRemoveCollectionMedia[]) { try { - const collections = await this.collectionRepo.find(); + const collections = await this.collectionRepo.find() for (const collection of collections) { - await this.removeFromCollection(collection.id, media); + await this.removeFromCollection(collection.id, media) } - return { status: 'OK', code: 1, message: 'Success' }; + return { status: 'OK', code: 1, message: 'Success' } } catch (e) { this.logger.warn( `An error occurred while removing media from all collections : ${e}`, - ); - return { status: 'NOK', code: 0, message: 'Failed' }; + ) + return { status: 'NOK', code: 0, message: 'Failed' } } } async deleteCollection(collectionDbId: number) { try { - const mediaServer = await this.getMediaServer(); + const mediaServer = await this.getMediaServer() let collection = await this.collectionRepo.findOne({ where: { id: collectionDbId }, - }); + }) if (!collection) { this.logger.warn( `Collection with id ${collectionDbId} not found in database`, - ); - return undefined; + ) + return undefined } - collection = await this.checkAutomaticMediaServerLink(collection); + collection = await this.checkAutomaticMediaServerLink(collection) if (collection.mediaServerId && !collection.manualCollection) { try { - await mediaServer.deleteCollection(collection.mediaServerId); + await mediaServer.deleteCollection(collection.mediaServerId) } catch (err) { this.logger.warn( `Failed to delete collection from media server: ${err.message}`, - ); + ) } } - return await this.RemoveCollectionFromDB(collection); + return await this.RemoveCollectionFromDB(collection) } catch (err) { - this.logger.warn( - 'An error occurred while performing collection actions.', - ); - return undefined; + this.logger.warn('An error occurred while performing collection actions.') + return undefined } } public async deactivateCollection(collectionDbId: number) { try { - const mediaServer = await this.getMediaServer(); + const mediaServer = await this.getMediaServer() const collection = await this.collectionRepo.findOne({ where: { id: collectionDbId }, - }); + }) if (!collection.manualCollection && collection.mediaServerId) { try { - await mediaServer.deleteCollection(collection.mediaServerId); + await mediaServer.deleteCollection(collection.mediaServerId) } catch (err) { this.logger.warn( `Failed to delete collection from media server: ${err.message}`, - ); + ) } } - await this.CollectionMediaRepo.delete({ collectionId: collection.id }); + await this.CollectionMediaRepo.delete({ collectionId: collection.id }) await this.saveCollection({ ...collection, isActive: false, mediaServerId: null, - }); + }) await this.addLogRecord( { id: collectionDbId } as Collection, 'Collection deactivated', ECollectionLogType.COLLECTION, - ); + ) const rulegroup = await this.ruleGroupRepo.findOne({ where: { collectionId: collection.id, }, - }); + }) if (rulegroup) { await this.ruleGroupRepo.save({ ...rulegroup, isActive: false, - }); + }) } } catch (err) { - this.logger.warn( - 'An error occurred while performing collection actions.', - ); - return undefined; + this.logger.warn('An error occurred while performing collection actions.') + return undefined } } @@ -1203,35 +1191,33 @@ export class CollectionsService { try { const collection = await this.collectionRepo.findOne({ where: { id: collectionDbId }, - }); + }) await this.saveCollection({ ...collection, isActive: true, - }); + }) await this.addLogRecord( { id: collectionDbId } as Collection, 'Collection activated', ECollectionLogType.COLLECTION, - ); + ) const rulegroup = await this.ruleGroupRepo.findOne({ where: { collectionId: collection.id, }, - }); + }) if (rulegroup) { await this.ruleGroupRepo.save({ ...rulegroup, isActive: true, - }); + }) } } catch (err) { - this.logger.warn( - 'An error occurred while performing collection actions.', - ); - return undefined; + this.logger.warn('An error occurred while performing collection actions.') + return undefined } } @@ -1242,23 +1228,23 @@ export class CollectionsService { logMeta?: CollectionLogMeta, ) { try { - const mediaServer = await this.getMediaServer(); - this.infoLogger(`Adding media with id ${childId} to collection..`); + const mediaServer = await this.getMediaServer() + this.infoLogger(`Adding media with id ${childId} to collection..`) - const tmdb = await this.tmdbIdHelper.getTmdbIdFromMediaServerId(childId); + const tmdb = await this.tmdbIdHelper.getTmdbIdFromMediaServerId(childId) - let tmdbMedia: TmdbTvDetails | TmdbMovieDetails; + let tmdbMedia: TmdbTvDetails | TmdbMovieDetails switch (tmdb.type) { case 'movie': - tmdbMedia = await this.tmdbApi.getMovie({ movieId: tmdb.id }); - break; + tmdbMedia = await this.tmdbApi.getMovie({ movieId: tmdb.id }) + break case 'tv': - tmdbMedia = await this.tmdbApi.getTvShow({ tvId: tmdb.id }); - break; + tmdbMedia = await this.tmdbApi.getTvShow({ tvId: tmdb.id }) + break } try { - await mediaServer.addToCollection(collectionIds.mediaServerId, childId); + await mediaServer.addToCollection(collectionIds.mediaServerId, childId) await this.connection .createQueryBuilder() @@ -1274,7 +1260,7 @@ export class CollectionsService { isManual: manual, }, ]) - .execute(); + .execute() // log record await this.CollectionLogRecordForChild( @@ -1282,15 +1268,15 @@ export class CollectionsService { collectionIds.dbId, 'add', logMeta, - ); + ) } catch (err) { - this.logger.warn(`Couldn't add media to collection: ${err.message}`); + this.logger.warn(`Couldn't add media to collection: ${err.message}`) } } catch (err) { this.logger.warn( `An error occurred while performing collection actions: ${err}`, - ); - return undefined; + ) + return undefined } } @@ -1301,8 +1287,8 @@ export class CollectionsService { logMeta?: CollectionLogMeta, ) { // log record - const mediaServer = await this.getMediaServer(); - const mediaData = await mediaServer.getMetadata(mediaServerId); // fetch data from cache + const mediaServer = await this.getMediaServer() + const mediaData = await mediaServer.getMetadata(mediaServerId) // fetch data from cache // if there's no data.. skip logging if (mediaData) { @@ -1311,18 +1297,18 @@ export class CollectionsService { mediaData, mediaServer, type, - ); + ) const subject = isMediaType(mediaData.type, 'episode') ? `${mediaData.grandparentTitle} - season ${mediaData.parentIndex} - episode ${mediaData.index}` : isMediaType(mediaData.type, 'season') ? `${mediaData.parentTitle} - season ${mediaData.index}` - : mediaData.title; + : mediaData.title await this.addLogRecord( { id: collectionId } as Collection, `${type === 'add' ? 'Added' : type === 'handle' ? 'Successfully handled' : type === 'exclude' ? 'Added a specific exclusion for' : type === 'include' ? 'Removed specific exclusion of' : 'Removed'} "${subject}"`, ECollectionLogType.MEDIA, logMetaWithMedia, - ); + ) } } @@ -1332,10 +1318,10 @@ export class CollectionsService { mediaServer: IMediaServerService, actionType: 'add' | 'remove' | 'handle' | 'exclude' | 'include', ): Promise { - const media = await this.getLogMediaSnapshot(mediaData, mediaServer); + const media = await this.getLogMediaSnapshot(mediaData, mediaServer) if (logMeta) { - return { ...logMeta, media } as CollectionLogMeta; + return { ...logMeta, media } as CollectionLogMeta } return { @@ -1344,7 +1330,7 @@ export class CollectionsService { ? 'media_removed_manually' : 'media_added_manually', media, - }; + } } private async getLogMediaSnapshot( @@ -1356,11 +1342,11 @@ export class CollectionsService { ? mediaData.grandparentId : mediaData.type === 'season' ? mediaData.parentId - : undefined; + : undefined const parentItem = parentId ? await mediaServer.getMetadata(parentId) - : undefined; - const posterSource = mediaData.type === 'movie' ? mediaData : parentItem; + : undefined + const posterSource = mediaData.type === 'movie' ? mediaData : parentItem return { mediaServerId: mediaData.id, @@ -1373,7 +1359,7 @@ export class CollectionsService { episodeNumber: mediaData.type === 'episode' ? mediaData.index : undefined, tmdbId: posterSource?.providerIds?.tmdb?.[0], posterType: mediaData.type === 'movie' ? 'movie' : 'show', - }; + } } private async removeChildFromCollection( @@ -1382,14 +1368,14 @@ export class CollectionsService { logMeta?: CollectionLogMeta, ) { try { - const mediaServer = await this.getMediaServer(); - this.infoLogger(`Removing media with id ${childId} from collection..`); + const mediaServer = await this.getMediaServer() + this.infoLogger(`Removing media with id ${childId} from collection..`) try { await mediaServer.removeFromCollection( collectionIds.mediaServerId, childId, - ); + ) await this.connection .createQueryBuilder() @@ -1401,28 +1387,26 @@ export class CollectionsService { mediaServerId: childId, }, ]) - .execute(); + .execute() await this.CollectionLogRecordForChild( childId, collectionIds.dbId, 'remove', logMeta, - ); + ) } catch (err) { // 404 means media is not in collection, which is fine if (!err.message?.includes('404')) { this.infoLogger( `Couldn't remove media from collection: ${err.message}`, - ); + ) } } } catch (err) { - this.logger.warn( - 'An error occurred while performing collection actions.', - ); - this.logger.debug(err); - return undefined; + this.logger.warn('An error occurred while performing collection actions.') + this.logger.debug(err) + return undefined } } @@ -1430,9 +1414,9 @@ export class CollectionsService { collection: ICollection, mediaServerId?: string, ): Promise { - this.infoLogger(`Adding collection to the database..`); + this.infoLogger(`Adding collection to the database..`) try { - const mediaServerType = await this.getMediaServerType(); + const mediaServerType = await this.getMediaServerType() const insertResult = await this.connection .createQueryBuilder() .insert() @@ -1468,11 +1452,11 @@ export class CollectionsService { sortTitle: collection.sortTitle, }, ]) - .execute(); + .execute() // generatedMaps only returns auto-generated columns (like id), not the full row // We need to include mediaServerId since it was passed as a parameter - const generatedId = insertResult.generatedMaps[0] as { id: number }; + const generatedId = insertResult.generatedMaps[0] as { id: number } const dbCol: addCollectionDbResponse = { id: generatedId.id, mediaServerId: mediaServerId, @@ -1481,45 +1465,45 @@ export class CollectionsService { visibleOnHome: collection.visibleOnHome, deleteAfterDays: collection.deleteAfterDays, manualCollection: collection.manualCollection ?? false, - }; + } await this.addLogRecord( dbCol as Collection, 'Collection Created', ECollectionLogType.COLLECTION, - ); - return dbCol; + ) + return dbCol } catch (err) { this.logger.error( `Something went wrong creating the collection in the database..`, err, - ); - return undefined; + ) + return undefined } } private async RemoveCollectionFromDB( collection: ICollection, ): Promise { - this.infoLogger(`Removing collection from database..`); + this.infoLogger(`Removing collection from database..`) try { - await this.collectionRepo.delete(collection.id); + await this.collectionRepo.delete(collection.id) this.eventEmitter.emit(MaintainerrEvent.Collection_Deleted, { collection, - }); + }) this.infoLogger( `Collection with id ${collection.id} has been removed from the database.`, - ); + ) - return { status: 'OK', code: 1, message: 'Success' }; + return { status: 'OK', code: 1, message: 'Success' } } catch (err) { this.logger.error( `Something went wrong deleting the collection from the database..`, err, - ); - return { status: 'NOK', code: 0, message: 'Removing from DB failed' }; + ) + return { status: 'NOK', code: 0, message: 'Removing from DB failed' } } } @@ -1534,25 +1518,25 @@ export class CollectionsService { if (!libraryId || libraryId === '') { this.logger.debug( `[findMediaServerCollection] Skipping search - libraryId is empty`, - ); - return undefined; + ) + return undefined } try { - const mediaServer = await this.getMediaServer(); - const collections = await mediaServer.getCollections(libraryId); + const mediaServer = await this.getMediaServer() + const collections = await mediaServer.getCollections(libraryId) if (collections) { const found = collections.find((coll) => { - return coll.title.trim() === name.trim() && !coll.smart; - }); - return found; + return coll.title.trim() === name.trim() && !coll.smart + }) + return found } } catch (err) { this.logger.warn( 'An error occurred while searching for a specific collection.', - ); - this.logger.debug(err); - return undefined; + ) + this.logger.debug(err) + return undefined } } @@ -1564,32 +1548,32 @@ export class CollectionsService { filter: ECollectionLogType = undefined, ) { const queryBuilder = - this.CollectionLogRepo.createQueryBuilder('collection_log'); + this.CollectionLogRepo.createQueryBuilder('collection_log') queryBuilder .where('collection_log.collectionId = :id', { id }) .orderBy('id', sort) .skip(offset) - .take(size); + .take(size) if (search !== undefined) { queryBuilder.andWhere('collection_log.message like :search', { search: `%${search}%`, - }); + }) } if (filter !== undefined) { queryBuilder.andWhere('collection_log.type like :filter', { filter: `%${filter}%`, - }); + }) } - const itemCount = await queryBuilder.getCount(); - const { entities } = await queryBuilder.getRawAndEntities(); + const itemCount = await queryBuilder.getCount() + const { entities } = await queryBuilder.getRawAndEntities() return { totalSize: itemCount, items: entities ?? [], - }; + } } public async addLogRecord( @@ -1611,14 +1595,14 @@ export class CollectionsService { meta, }, ]) - .execute(); + .execute() } public async removeAllCollectionLogs(collectionId: number) { const collection = await this.collectionRepo.findOne({ where: { id: collectionId }, - }); - await this.CollectionLogRepo.delete({ collection: collection }); + }) + await this.CollectionLogRepo.delete({ collection: collection }) } /** @@ -1631,28 +1615,28 @@ export class CollectionsService { try { // If keepLogsForMonths is 0, no need to remove logs. User explicitly configured it to keep logs forever if (collection.keepLogsForMonths !== 0) { - const currentDate = new Date(); - const configuredMonths = new Date(currentDate); + const currentDate = new Date() + const configuredMonths = new Date(currentDate) // Calculate the target month and year - let targetMonth = currentDate.getMonth() - collection.keepLogsForMonths; - let targetYear = currentDate.getFullYear(); + let targetMonth = currentDate.getMonth() - collection.keepLogsForMonths + let targetYear = currentDate.getFullYear() // Adjust for negative months while (targetMonth < 0) { - targetMonth += 12; - targetYear -= 1; + targetMonth += 12 + targetYear -= 1 } // Ensure the day is within bounds for the target month const targetDay = Math.min( currentDate.getDate(), new Date(targetYear, targetMonth + 1, 0).getDate(), - ); + ) - configuredMonths.setMonth(targetMonth); - configuredMonths.setFullYear(targetYear); - configuredMonths.setDate(targetDay); + configuredMonths.setMonth(targetMonth) + configuredMonths.setFullYear(targetYear) + configuredMonths.setDate(targetDay) // get all logs older than param const logs = await this.CollectionLogRepo.find({ @@ -1660,26 +1644,26 @@ export class CollectionsService { collection: collection, timestamp: LessThan(configuredMonths), }, - }); + }) if (logs.length > 0) { // delete all old logs - await this.CollectionLogRepo.remove(logs); + await this.CollectionLogRepo.remove(logs) this.infoLogger( `Removed ${logs.length} old collection log ${logs.length === 1 ? 'record' : 'records'} from collection '${collection.title}'`, - ); + ) await this.addLogRecord( collection, `Removed ${logs.length} log ${logs.length === 1 ? 'record' : 'records'} older than ${collection.keepLogsForMonths} months`, ECollectionLogType.COLLECTION, - ); + ) } } } catch (e) { this.logger.warn( `An error occurred while removing old collection logs for collection '${collection?.title}'`, - ); - this.logger.debug(e); + ) + this.logger.debug(e) } } @@ -1692,60 +1676,60 @@ export class CollectionsService { try { const collection = await this.collectionRepo.findOne({ where: { id: collectionId }, - }); - if (!collection) return; + }) + if (!collection) return - const mediaServer = await this.getMediaServer(); + const mediaServer = await this.getMediaServer() const collectionMedia = await this.CollectionMediaRepo.find({ where: { collectionId }, - }); + }) if (collectionMedia.length === 0) { await this.collectionRepo.update(collectionId, { totalSizeBytes: null, - }); - return; + }) + return } - let totalBytes = 0; - let hasAnySize = false; + let totalBytes = 0 + let hasAnySize = false for (const media of collectionMedia) { try { - const metadata = await mediaServer.getMetadata(media.mediaServerId); - if (!metadata) continue; + const metadata = await mediaServer.getMetadata(media.mediaServerId) + if (!metadata) continue - const itemSize = this.sumMediaSourceSizes(metadata); + const itemSize = this.sumMediaSourceSizes(metadata) if (itemSize > 0) { - totalBytes += itemSize; - hasAnySize = true; + totalBytes += itemSize + hasAnySize = true } else if (metadata.type === 'show' || metadata.type === 'season') { // Show/season items may not have file sizes at the top level. // Traverse children to sum episode-level sizes. const childSize = await this.getChildrenTotalSize( mediaServer, metadata, - ); + ) if (childSize > 0) { - totalBytes += childSize; - hasAnySize = true; + totalBytes += childSize + hasAnySize = true } } } catch (e) { this.logger.debug( `Failed to get size for media ${media.mediaServerId}: ${e.message}`, - ); + ) } } await this.collectionRepo.update(collectionId, { totalSizeBytes: hasAnySize ? totalBytes : null, - }); + }) } catch (e) { this.logger.debug( `Failed to update total size for collection ${collectionId}: ${e.message}`, - ); + ) } } @@ -1753,11 +1737,11 @@ export class CollectionsService { * Sum sizeBytes across all mediaSources on a MediaItem. */ private sumMediaSourceSizes(item: MediaItem): number { - if (!item.mediaSources?.length) return 0; + if (!item.mediaSources?.length) return 0 return item.mediaSources.reduce( (sum, source) => sum + (source.sizeBytes || 0), 0, - ); + ) } /** @@ -1767,22 +1751,22 @@ export class CollectionsService { mediaServer: IMediaServerService, parent: MediaItem, ): Promise { - let total = 0; + let total = 0 - const children = await mediaServer.getChildrenMetadata(parent.id); + const children = await mediaServer.getChildrenMetadata(parent.id) for (const child of children) { - const childSize = this.sumMediaSourceSizes(child); + const childSize = this.sumMediaSourceSizes(child) if (childSize > 0) { - total += childSize; + total += childSize } else if (child.type === 'show' || child.type === 'season') { - total += await this.getChildrenTotalSize(mediaServer, child); + total += await this.getChildrenTotalSize(mediaServer, child) } } - return total; + return total } private infoLogger(message: string) { - this.logger.log(message); + this.logger.log(message) } } diff --git a/apps/server/src/modules/collections/entities/collection.entities.ts b/apps/server/src/modules/collections/entities/collection.entities.ts index 942d1b9f..1631bfa6 100644 --- a/apps/server/src/modules/collections/entities/collection.entities.ts +++ b/apps/server/src/modules/collections/entities/collection.entities.ts @@ -1,4 +1,4 @@ -import { MediaItemType, MediaServerType } from '@maintainerr/contracts'; +import { MediaItemType, MediaServerType } from '@maintainerr/contracts' import { Column, Entity, @@ -7,111 +7,111 @@ import { OneToMany, OneToOne, PrimaryGeneratedColumn, -} from 'typeorm'; -import { CollectionLog } from '../../collections/entities/collection_log.entities'; -import { RulesDto } from '../../rules/dtos/rules.dto'; -import { RuleGroup } from '../../rules/entities/rule-group.entities'; -import { RadarrSettings } from '../../settings/entities/radarr_settings.entities'; -import { SonarrSettings } from '../../settings/entities/sonarr_settings.entities'; -import { CollectionMedia } from './collection_media.entities'; +} from 'typeorm' +import { CollectionLog } from '../../collections/entities/collection_log.entities' +import { RulesDto } from '../../rules/dtos/rules.dto' +import { RuleGroup } from '../../rules/entities/rule-group.entities' +import { RadarrSettings } from '../../settings/entities/radarr_settings.entities' +import { SonarrSettings } from '../../settings/entities/sonarr_settings.entities' +import { CollectionMedia } from './collection_media.entities' @Entity() export class Collection { @PrimaryGeneratedColumn() - id: number; + id: number @Column({ nullable: true }) - mediaServerId: string; + mediaServerId: string @Column({ type: 'varchar', default: MediaServerType.PLEX }) - mediaServerType: MediaServerType; + mediaServerType: MediaServerType @Column({ type: 'varchar' }) - libraryId: string; + libraryId: string @Column() - title: string; + title: string @Column({ nullable: true }) - description: string; + description: string @Column({ default: true }) - isActive: boolean; + isActive: boolean @Column({ default: 0 }) - arrAction: number; + arrAction: number @Column({ default: false }) - visibleOnRecommended: boolean; + visibleOnRecommended: boolean @Column({ default: false }) - visibleOnHome: boolean; + visibleOnHome: boolean @Column({ nullable: true, default: null }) - deleteAfterDays: number; + deleteAfterDays: number @Column({ nullable: false, default: false }) - manualCollection: boolean; + manualCollection: boolean @Column({ nullable: true, default: '' }) - manualCollectionName: string; + manualCollectionName: string @Column({ nullable: false, default: false }) - listExclusions: boolean; + listExclusions: boolean @Column({ nullable: false, default: false }) - forceSeerr: boolean; + forceSeerr: boolean @Column({ nullable: false, default: 'movie' }) - type: MediaItemType; + type: MediaItemType @Column({ nullable: false, default: 6 }) - keepLogsForMonths: number; + keepLogsForMonths: number @OneToOne(() => RuleGroup, (rg) => rg.collection) - ruleGroup: RulesDto; + ruleGroup: RulesDto @Column({ type: 'date', nullable: true, default: () => 'CURRENT_TIMESTAMP' }) // nullable = true for old collections - addDate: Date; + addDate: Date @Column({ nullable: false, default: 0 }) - handledMediaAmount: number; + handledMediaAmount: number @Column({ nullable: false, default: 0 }) - lastDurationInSeconds: number; + lastDurationInSeconds: number @Column({ nullable: true, default: null }) - tautulliWatchedPercentOverride: number; + tautulliWatchedPercentOverride: number @Column({ nullable: true }) - radarrSettingsId: number; + radarrSettingsId: number @ManyToOne(() => RadarrSettings, { nullable: true }) @JoinColumn({ name: 'radarrSettingsId', referencedColumnName: 'id' }) - radarrSettings: RadarrSettings; + radarrSettings: RadarrSettings @Column({ nullable: true }) - sonarrSettingsId: number; + sonarrSettingsId: number @ManyToOne(() => SonarrSettings, { nullable: true }) @JoinColumn({ name: 'sonarrSettingsId', referencedColumnName: 'id' }) - sonarrSettings: SonarrSettings; + sonarrSettings: SonarrSettings @Column({ nullable: true }) - sortTitle: string; + sortTitle: string @Column({ type: 'bigint', nullable: true, default: null }) - totalSizeBytes: number | null; + totalSizeBytes: number | null @OneToMany( () => CollectionMedia, (collectionMedia) => collectionMedia.collectionId, { onDelete: 'CASCADE' }, ) - collectionMedia: CollectionMedia[]; + collectionMedia: CollectionMedia[] @OneToMany(() => CollectionLog, (collectionLog) => collectionLog.collection, { onDelete: 'CASCADE', }) - collectionLog: CollectionLog[]; + collectionLog: CollectionLog[] } diff --git a/apps/server/src/modules/collections/entities/collection_log.entities.ts b/apps/server/src/modules/collections/entities/collection_log.entities.ts index 1ad13fea..b3b7e796 100644 --- a/apps/server/src/modules/collections/entities/collection_log.entities.ts +++ b/apps/server/src/modules/collections/entities/collection_log.entities.ts @@ -1,37 +1,37 @@ -import { CollectionLogMeta, ECollectionLogType } from '@maintainerr/contracts'; +import { CollectionLogMeta, ECollectionLogType } from '@maintainerr/contracts' import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, -} from 'typeorm'; -import { Collection } from '../../collections/entities/collection.entities'; +} from 'typeorm' +import { Collection } from '../../collections/entities/collection.entities' @Entity() @Index('idx_collection_log_collection_id', ['collection']) export class CollectionLog { @PrimaryGeneratedColumn() - id: number; + id: number @ManyToOne(() => Collection, (collection) => collection.collectionLog, { nullable: false, onDelete: 'CASCADE', }) - collection: Collection; + collection: Collection @Column({ type: 'datetime', nullable: false, }) - timestamp: Date; + timestamp: Date @Column() - message: string; + message: string @Column({ nullable: false }) - type: ECollectionLogType; + type: ECollectionLogType @Column('simple-json', { nullable: true }) - meta: CollectionLogMeta; + meta: CollectionLogMeta } diff --git a/apps/server/src/modules/collections/entities/collection_media.entities.ts b/apps/server/src/modules/collections/entities/collection_media.entities.ts index b817a3ed..6ad01178 100644 --- a/apps/server/src/modules/collections/entities/collection_media.entities.ts +++ b/apps/server/src/modules/collections/entities/collection_media.entities.ts @@ -1,46 +1,46 @@ -import { MediaItemWithParent } from '@maintainerr/contracts'; +import { MediaItemWithParent } from '@maintainerr/contracts' import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, -} from 'typeorm'; -import { Collection } from './collection.entities'; +} from 'typeorm' +import { Collection } from './collection.entities' @Entity() @Index('idx_collection_media_collection_id', ['collectionId']) export class CollectionMedia { @PrimaryGeneratedColumn() - id: number; + id: number @Column() - collectionId: number; + collectionId: number @Column() - mediaServerId: string; + mediaServerId: string @Column({ nullable: true }) - tmdbId: number; + tmdbId: number @Column() - addDate: Date; + addDate: Date @Column({ nullable: true }) - image_path: string; + image_path: string @Column({ default: false, nullable: true }) - isManual: boolean; + isManual: boolean @ManyToOne(() => Collection, (collection) => collection.collectionMedia, { onDelete: 'CASCADE', }) - collection: Collection; + collection: Collection } /** * Collection media with server-agnostic metadata. */ export class CollectionMediaWithMetadata extends CollectionMedia { - mediaData: MediaItemWithParent; + mediaData: MediaItemWithParent } diff --git a/apps/server/src/modules/collections/interfaces/collection-media.interface.ts b/apps/server/src/modules/collections/interfaces/collection-media.interface.ts index c7e16724..cd585593 100644 --- a/apps/server/src/modules/collections/interfaces/collection-media.interface.ts +++ b/apps/server/src/modules/collections/interfaces/collection-media.interface.ts @@ -1,22 +1,22 @@ -import { CollectionLogMeta, MediaItemType } from '@maintainerr/contracts'; +import { CollectionLogMeta, MediaItemType } from '@maintainerr/contracts' export interface ICollectionMedia { - id: number; - collectionId: number; - mediaServerId: string; - tmdbId: number; - tvdbid: number; - addDate: Date; + id: number + collectionId: number + mediaServerId: string + tmdbId: number + tvdbid: number + addDate: Date } export interface AddRemoveCollectionMedia { - mediaServerId: string; - reason?: CollectionLogMeta; + mediaServerId: string + reason?: CollectionLogMeta } export interface IAlterableMediaDto { - id: number; - index?: number; - parenIndex?: number; - type: MediaItemType; + id: number + index?: number + parenIndex?: number + type: MediaItemType } diff --git a/apps/server/src/modules/collections/interfaces/collection.interface.ts b/apps/server/src/modules/collections/interfaces/collection.interface.ts index 356526fb..13ece004 100644 --- a/apps/server/src/modules/collections/interfaces/collection.interface.ts +++ b/apps/server/src/modules/collections/interfaces/collection.interface.ts @@ -1,46 +1,46 @@ -import { MediaItemType, MediaServerType } from '@maintainerr/contracts'; -import { CollectionMedia } from '../entities/collection_media.entities'; +import { MediaItemType, MediaServerType } from '@maintainerr/contracts' +import { CollectionMedia } from '../entities/collection_media.entities' export interface ICollection { - id?: number; - type: MediaItemType; - mediaServerId?: string; - mediaServerType?: MediaServerType; - libraryId: string; - title: string; - description?: string; - isActive: boolean; - arrAction: number; - visibleOnRecommended?: boolean; - visibleOnHome?: boolean; - listExclusions?: boolean; - forceSeerr?: boolean; - deleteAfterDays?: number; // amount of days after add - media?: CollectionMedia[]; - manualCollection?: boolean; - manualCollectionName?: string; - keepLogsForMonths?: number; - tautulliWatchedPercentOverride?: number; - radarrSettingsId?: number; - sonarrSettingsId?: number; - sortTitle?: string; + id?: number + type: MediaItemType + mediaServerId?: string + mediaServerType?: MediaServerType + libraryId: string + title: string + description?: string + isActive: boolean + arrAction: number + visibleOnRecommended?: boolean + visibleOnHome?: boolean + listExclusions?: boolean + forceSeerr?: boolean + deleteAfterDays?: number // amount of days after add + media?: CollectionMedia[] + manualCollection?: boolean + manualCollectionName?: string + keepLogsForMonths?: number + tautulliWatchedPercentOverride?: number + radarrSettingsId?: number + sonarrSettingsId?: number + sortTitle?: string } export interface ICalendarCollectionMedia { - id: number; - mediaServerId: string; - addDate: Date; + id: number + mediaServerId: string + addDate: Date } export interface ICalendarCollection { - id: number; - title: string; - type: MediaItemType; - arrAction: number; - deleteAfterDays: number; - radarrSettingsId?: number; - sonarrSettingsId?: number; - media: ICalendarCollectionMedia[]; + id: number + title: string + type: MediaItemType + arrAction: number + deleteAfterDays: number + radarrSettingsId?: number + sonarrSettingsId?: number + media: ICalendarCollectionMedia[] } export enum ServarrAction { diff --git a/apps/server/src/modules/collections/tasks/collection-log-cleaner.service.ts b/apps/server/src/modules/collections/tasks/collection-log-cleaner.service.ts index a4d68ca1..86746dc3 100644 --- a/apps/server/src/modules/collections/tasks/collection-log-cleaner.service.ts +++ b/apps/server/src/modules/collections/tasks/collection-log-cleaner.service.ts @@ -1,35 +1,35 @@ -import { Injectable } from '@nestjs/common'; -import { TasksService } from '../..//tasks/tasks.service'; -import { CollectionsService } from '../../collections/collections.service'; -import { MaintainerrLogger } from '../../logging/logs.service'; -import { TaskBase } from '../../tasks/task.base'; +import { Injectable } from '@nestjs/common' +import { TasksService } from '../..//tasks/tasks.service' +import { CollectionsService } from '../../collections/collections.service' +import { MaintainerrLogger } from '../../logging/logs.service' +import { TaskBase } from '../../tasks/task.base' @Injectable() export class CollectionLogCleanerService extends TaskBase { - protected name = 'Collection Log Cleaner'; - protected cronSchedule = '45 5 * * *'; + protected name = 'Collection Log Cleaner' + protected cronSchedule = '45 5 * * *' constructor( private readonly collectionService: CollectionsService, protected readonly taskService: TasksService, protected readonly logger: MaintainerrLogger, ) { - logger.setContext(CollectionLogCleanerService.name); - super(taskService, logger); + logger.setContext(CollectionLogCleanerService.name) + super(taskService, logger) } protected async executeTask() { try { // start execution // get all collections - const collections = await this.collectionService.getAllCollections(); + const collections = await this.collectionService.getAllCollections() // for each collection for (const collection of collections) { - await this.collectionService.removeOldCollectionLogs(collection); + await this.collectionService.removeOldCollectionLogs(collection) } } catch (e) { - this.logger.debug(e); + this.logger.debug(e) } } } diff --git a/apps/server/src/modules/events/events-buffer.service.spec.ts b/apps/server/src/modules/events/events-buffer.service.spec.ts index c7202259..55fcd1a9 100644 --- a/apps/server/src/modules/events/events-buffer.service.spec.ts +++ b/apps/server/src/modules/events/events-buffer.service.spec.ts @@ -1,108 +1,108 @@ -import { RawBodyRequest } from '@nestjs/common'; -import { IncomingMessage } from 'http'; -import { EventsBufferService } from './events-buffer.service'; +import { RawBodyRequest } from '@nestjs/common' +import { IncomingMessage } from 'http' +import { EventsBufferService } from './events-buffer.service' const getPrivateStatic = (key: string) => - (EventsBufferService as unknown as Record)[key]; + (EventsBufferService as unknown as Record)[key] const buildRequest = ( headers: Record = {}, ): RawBodyRequest => - ({ headers }) as unknown as RawBodyRequest; + ({ headers }) as unknown as RawBodyRequest describe('EventsBufferService', () => { - let service: EventsBufferService; + let service: EventsBufferService beforeEach(() => { - service = new EventsBufferService(); - }); + service = new EventsBufferService() + }) afterEach(() => { - jest.restoreAllMocks(); - }); + jest.restoreAllMocks() + }) describe('parseLastEventId', () => { it('returns undefined when header is missing', () => { - expect(service.parseLastEventId(buildRequest())).toBeUndefined(); - }); + expect(service.parseLastEventId(buildRequest())).toBeUndefined() + }) it('parses numeric header values', () => { expect( service.parseLastEventId(buildRequest({ 'last-event-id': '42' })), - ).toBe(42); - }); + ).toBe(42) + }) it('uses the last entry when header is an array', () => { expect( service.parseLastEventId( buildRequest({ 'last-event-id': ['10', '12'] }), ), - ).toBe(12); - }); + ).toBe(12) + }) it('returns undefined for non-numeric input', () => { expect( service.parseLastEventId(buildRequest({ 'last-event-id': 'abc' })), - ).toBeUndefined(); - }); - }); + ).toBeUndefined() + }) + }) describe('buffering', () => { it('assigns incrementing ids when buffering events', () => { const first = service.buildBufferedEvent({ type: 'foo', data: { id: 1 }, - }); + }) const second = service.buildBufferedEvent({ type: 'bar', data: { id: 2 }, - }); + }) - expect(first.id).toBe('1'); - expect(second.id).toBe('2'); - }); + expect(first.id).toBe('1') + expect(second.id).toBe('2') + }) it('returns buffered events newer than the given id', () => { const first = service.buildBufferedEvent({ type: 'foo', data: { id: 1 }, - }); + }) const second = service.buildBufferedEvent({ type: 'bar', data: { id: 2 }, - }); + }) const third = service.buildBufferedEvent({ type: 'baz', data: { id: 3 }, - }); + }) - expect(service.getEventsAfter(undefined)).toHaveLength(0); - expect(service.getEventsAfter(Number(first.id))).toEqual([second, third]); - expect(service.getEventsAfter(Number(third.id))).toHaveLength(0); - }); + expect(service.getEventsAfter(undefined)).toHaveLength(0) + expect(service.getEventsAfter(Number(first.id))).toEqual([second, third]) + expect(service.getEventsAfter(Number(third.id))).toHaveLength(0) + }) it('drops events that exceed the TTL window', () => { - const ttl = getPrivateStatic('EVENT_BUFFER_TTL_MS'); - const nowSpy = jest.spyOn(Date, 'now'); + const ttl = getPrivateStatic('EVENT_BUFFER_TTL_MS') + const nowSpy = jest.spyOn(Date, 'now') - nowSpy.mockReturnValue(0); - service.buildBufferedEvent({ type: 'foo', data: { id: 1 } }); + nowSpy.mockReturnValue(0) + service.buildBufferedEvent({ type: 'foo', data: { id: 1 } }) - nowSpy.mockReturnValue(ttl + 1); - expect(service.getEventsAfter(0)).toHaveLength(0); - }); + nowSpy.mockReturnValue(ttl + 1) + expect(service.getEventsAfter(0)).toHaveLength(0) + }) it('enforces the maximum buffer size', () => { - const maxSize = getPrivateStatic('EVENT_BUFFER_MAX_SIZE'); + const maxSize = getPrivateStatic('EVENT_BUFFER_MAX_SIZE') for (let i = 0; i < maxSize + 5; i += 1) { - service.buildBufferedEvent({ type: 'foo', data: { id: i } }); + service.buildBufferedEvent({ type: 'foo', data: { id: i } }) } - const events = service.getEventsAfter(0); - expect(events).toHaveLength(maxSize); - expect(events[0].id).toBe(String(6)); - expect(events[events.length - 1].id).toBe(String(maxSize + 5)); - }); - }); -}); + const events = service.getEventsAfter(0) + expect(events).toHaveLength(maxSize) + expect(events[0].id).toBe(String(6)) + expect(events[events.length - 1].id).toBe(String(maxSize + 5)) + }) + }) +}) diff --git a/apps/server/src/modules/events/events-buffer.service.ts b/apps/server/src/modules/events/events-buffer.service.ts index 92d6230e..a2da5973 100644 --- a/apps/server/src/modules/events/events-buffer.service.ts +++ b/apps/server/src/modules/events/events-buffer.service.ts @@ -2,82 +2,82 @@ import { Injectable, MessageEvent as NestMessageEvent, RawBodyRequest, -} from '@nestjs/common'; -import { IncomingMessage } from 'http'; +} from '@nestjs/common' +import { IncomingMessage } from 'http' interface BufferedEvent { - id: number; - message: NestMessageEvent; - timestamp: number; + id: number + message: NestMessageEvent + timestamp: number } @Injectable() export class EventsBufferService { - private static readonly EVENT_BUFFER_TTL_MS = 5 * 60 * 1000; // 5 minutes - private static readonly EVENT_BUFFER_MAX_SIZE = 100; + private static readonly EVENT_BUFFER_TTL_MS = 5 * 60 * 1000 // 5 minutes + private static readonly EVENT_BUFFER_MAX_SIZE = 100 - private nextEventId = 1; - private eventBuffer: BufferedEvent[] = []; + private nextEventId = 1 + private eventBuffer: BufferedEvent[] = [] parseLastEventId( request: RawBodyRequest, ): number | undefined { - const headerValue = request.headers['last-event-id']; + const headerValue = request.headers['last-event-id'] if (!headerValue) { - return undefined; + return undefined } const idString = Array.isArray(headerValue) ? headerValue[headerValue.length - 1] - : headerValue; + : headerValue - const parsed = Number.parseInt(idString ?? '', 10); - return Number.isNaN(parsed) ? undefined : parsed; + const parsed = Number.parseInt(idString ?? '', 10) + return Number.isNaN(parsed) ? undefined : parsed } buildBufferedEvent(message: Omit) { - const eventId = this.nextEventId++; + const eventId = this.nextEventId++ const eventMessage: NestMessageEvent = { ...message, id: String(eventId), - }; + } - this.bufferEvent(eventId, eventMessage); - return eventMessage; + this.bufferEvent(eventId, eventMessage) + return eventMessage } getEventsAfter(lastEventId?: number): NestMessageEvent[] { if (lastEventId === undefined) { - return []; + return [] } - this.pruneEventBuffer(Date.now()); + this.pruneEventBuffer(Date.now()) return this.eventBuffer .filter((event) => event.id > lastEventId) - .map((event) => event.message); + .map((event) => event.message) } private bufferEvent(id: number, message: NestMessageEvent) { - const timestamp = Date.now(); + const timestamp = Date.now() - this.eventBuffer.push({ id, message, timestamp }); - this.pruneEventBuffer(timestamp); + this.eventBuffer.push({ id, message, timestamp }) + this.pruneEventBuffer(timestamp) } private pruneEventBuffer(now: number) { - const cutoff = now - EventsBufferService.EVENT_BUFFER_TTL_MS; + const cutoff = now - EventsBufferService.EVENT_BUFFER_TTL_MS while (this.eventBuffer.length > 0) { const shouldDropOldest = this.eventBuffer[0].timestamp < cutoff || - this.eventBuffer.length > EventsBufferService.EVENT_BUFFER_MAX_SIZE; + this.eventBuffer.length > EventsBufferService.EVENT_BUFFER_MAX_SIZE if (!shouldDropOldest) { - break; + break } - this.eventBuffer.shift(); + this.eventBuffer.shift() } } } diff --git a/apps/server/src/modules/events/events.controller.ts b/apps/server/src/modules/events/events.controller.ts index ad6699b6..f294e6a3 100644 --- a/apps/server/src/modules/events/events.controller.ts +++ b/apps/server/src/modules/events/events.controller.ts @@ -8,7 +8,7 @@ import { RuleHandlerProgressedEventDto, RuleHandlerQueueStatusUpdatedEventDto, RuleHandlerStartedEventDto, -} from '@maintainerr/contracts'; +} from '@maintainerr/contracts' import { BeforeApplicationShutdown, Controller, @@ -17,26 +17,26 @@ import { RawBodyRequest, Req, Res, -} from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { Response } from 'express'; -import { IncomingMessage } from 'http'; -import { interval, map, Subject } from 'rxjs'; -import { EventsBufferService } from './events-buffer.service'; +} from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Response } from 'express' +import { IncomingMessage } from 'http' +import { interval, map, Subject } from 'rxjs' +import { EventsBufferService } from './events-buffer.service' @Controller('/api/events') export class EventsController implements BeforeApplicationShutdown { - private mostRecentEvent: NestMessageEvent | null = null; + private mostRecentEvent: NestMessageEvent | null = null constructor(private readonly eventsBufferService: EventsBufferService) {} connectedClients = new Map< string, { close: () => void; subject: Subject } - >(); + >() async beforeApplicationShutdown() { for (const [, client] of this.connectedClients) { - client.close(); + client.close() } } @@ -46,70 +46,70 @@ export class EventsController implements BeforeApplicationShutdown { @Res() response: Response, @Req() request: RawBodyRequest, ) { - const lastEventId = this.eventsBufferService.parseLastEventId(request); + const lastEventId = this.eventsBufferService.parseLastEventId(request) if (request?.socket) { - request.socket.setKeepAlive(true); - request.socket.setNoDelay(true); - request.socket.setTimeout(0); + request.socket.setKeepAlive(true) + request.socket.setNoDelay(true) + request.socket.setTimeout(0) } - const subject = new Subject(); + const subject = new Subject() const observer = { next: (msg: NestMessageEvent) => { - if (msg.type) response.write(`event: ${msg.type}\n`); - if (msg.id) response.write(`id: ${msg.id}\n`); - if (msg.retry) response.write(`retry: ${msg.retry}\n`); + if (msg.type) response.write(`event: ${msg.type}\n`) + if (msg.id) response.write(`id: ${msg.id}\n`) + if (msg.retry) response.write(`retry: ${msg.retry}\n`) - response.write(`data: ${JSON.stringify(msg.data)}\n\n`); + response.write(`data: ${JSON.stringify(msg.data)}\n\n`) }, - }; + } - subject.subscribe(observer); + subject.subscribe(observer) - const clientKey = String(Math.random()); + const clientKey = String(Math.random()) this.connectedClients.set(clientKey, { close: () => { - response.end(); + response.end() }, subject, - }); + }) // Send data to the client every 30s to keep the connection alive const pingSubscription = interval(30 * 1000) .pipe(map(() => response.write(': ping\n\n'))) - .subscribe(); + .subscribe() response.on('close', () => { - subject.complete(); - pingSubscription.unsubscribe(); - this.connectedClients.delete(clientKey); - response.end(); - }); + subject.complete() + pingSubscription.unsubscribe() + this.connectedClients.delete(clientKey) + response.end() + }) response.set({ 'Cache-Control': 'private, no-cache, no-store, must-revalidate, max-age=0, no-transform', Connection: 'keep-alive', 'Content-Type': 'text/event-stream', - }); + }) - response.flushHeaders(); - response.write('\n'); + response.flushHeaders() + response.write('\n') - const bufferedEvents = this.eventsBufferService.getEventsAfter(lastEventId); + const bufferedEvents = this.eventsBufferService.getEventsAfter(lastEventId) if (bufferedEvents.length) { for (const event of bufferedEvents) { - this.sendDataToClient(clientKey, event); + this.sendDataToClient(clientKey, event) } - return; + return } if (this.mostRecentEvent) { - const eventTime = (this.mostRecentEvent.data as BaseEventDto).time; + const eventTime = (this.mostRecentEvent.data as BaseEventDto).time if (eventTime > new Date(Date.now() - 5000)) { - this.sendDataToClient(clientKey, this.mostRecentEvent); + this.sendDataToClient(clientKey, this.mostRecentEvent) } } } @@ -134,16 +134,16 @@ export class EventsController implements BeforeApplicationShutdown { const eventMessage = this.eventsBufferService.buildBufferedEvent({ type: payload.type, data: payload, - }); + }) for (const [, client] of this.connectedClients) { - client.subject.next(eventMessage); + client.subject.next(eventMessage) } - this.mostRecentEvent = eventMessage; + this.mostRecentEvent = eventMessage } sendDataToClient(clientId: string, message: NestMessageEvent) { - this.connectedClients.get(clientId)?.subject.next(message); + this.connectedClients.get(clientId)?.subject.next(message) } } diff --git a/apps/server/src/modules/events/events.module.ts b/apps/server/src/modules/events/events.module.ts index ee13d005..9fde758a 100644 --- a/apps/server/src/modules/events/events.module.ts +++ b/apps/server/src/modules/events/events.module.ts @@ -1,6 +1,6 @@ -import { Module } from '@nestjs/common'; -import { EventsBufferService } from './events-buffer.service'; -import { EventsController } from './events.controller'; +import { Module } from '@nestjs/common' +import { EventsBufferService } from './events-buffer.service' +import { EventsController } from './events.controller' @Module({ providers: [EventsBufferService], diff --git a/apps/server/src/modules/logging/entities/logSettings.entities.ts b/apps/server/src/modules/logging/entities/logSettings.entities.ts index 1c18327e..472ee2f7 100644 --- a/apps/server/src/modules/logging/entities/logSettings.entities.ts +++ b/apps/server/src/modules/logging/entities/logSettings.entities.ts @@ -1,21 +1,21 @@ -import { LogLevel, LogSetting } from '@maintainerr/contracts'; -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { LogLevel, LogSetting } from '@maintainerr/contracts' +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm' -export const DEFAULT_LOG_LEVEL = 'info'; -export const DEFAULT_LOG_MAX_SIZE = 20; -export const DEFAULT_LOG_MAX_FILES = 7; +export const DEFAULT_LOG_LEVEL = 'info' +export const DEFAULT_LOG_MAX_SIZE = 20 +export const DEFAULT_LOG_MAX_FILES = 7 @Entity() export class LogSettings implements LogSetting { @PrimaryGeneratedColumn() - id: number; + id: number @Column({ nullable: false, default: DEFAULT_LOG_LEVEL }) - level: LogLevel; + level: LogLevel @Column({ nullable: false, default: DEFAULT_LOG_MAX_SIZE }) - max_size: number; + max_size: number @Column({ nullable: false, default: DEFAULT_LOG_MAX_FILES }) - max_files: number; + max_files: number } diff --git a/apps/server/src/modules/logging/logFormatting.ts b/apps/server/src/modules/logging/logFormatting.ts index 561d7ed0..3c561762 100644 --- a/apps/server/src/modules/logging/logFormatting.ts +++ b/apps/server/src/modules/logging/logFormatting.ts @@ -1,20 +1,20 @@ export const formatLogMessage = (message: any, stack: any) => { if (Array.isArray(stack) && stack.length > 0 && stack[0] != null) { - let stackMessage = ''; + let stackMessage = '' if (stack[0] instanceof Error) { - stackMessage = stack[0].stack; + stackMessage = stack[0].stack } else if (typeof stack[0] === 'string') { - stackMessage = stack[0]; + stackMessage = stack[0] } if (typeof message === 'string' && stackMessage.includes(message)) { // Remove duplicate messaging - message = stackMessage; + message = stackMessage } else { - message = `${message}\n${stackMessage}`; + message = `${message}\n${stackMessage}` } } - return message; -}; + return message +} diff --git a/apps/server/src/modules/logging/logs.controller.ts b/apps/server/src/modules/logging/logs.controller.ts index 3bc79273..1dc69693 100644 --- a/apps/server/src/modules/logging/logs.controller.ts +++ b/apps/server/src/modules/logging/logs.controller.ts @@ -3,7 +3,7 @@ import { LogFile, LogSetting, logSettingSchema, -} from '@maintainerr/contracts'; +} from '@maintainerr/contracts' import { BeforeApplicationShutdown, Body, @@ -18,16 +18,16 @@ import { Req, Res, StreamableFile, -} from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { ZodValidationPipe } from 'nestjs-zod'; -import { Response } from 'express'; -import { createReadStream, readdir } from 'fs'; -import { readdir as readdirp, stat } from 'fs/promises'; -import { IncomingMessage } from 'http'; -import mime from 'mime-types'; -import path from 'path'; -import readLastLines from 'read-last-lines'; +} from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { ZodValidationPipe } from 'nestjs-zod' +import { Response } from 'express' +import { createReadStream, readdir } from 'fs' +import { readdir as readdirp, stat } from 'fs/promises' +import { IncomingMessage } from 'http' +import mime from 'mime-types' +import path from 'path' +import readLastLines from 'read-last-lines' import { catchError, concat, @@ -40,18 +40,18 @@ import { of, Subject, switchMap, -} from 'rxjs'; -import { Readable } from 'stream'; -import { formatLogMessage } from './logFormatting'; -import { MaintainerrLogger } from './logs.service'; -import { LogSettingsService } from './logs.service'; +} from 'rxjs' +import { Readable } from 'stream' +import { formatLogMessage } from './logFormatting' +import { MaintainerrLogger } from './logs.service' +import { LogSettingsService } from './logs.service' const logsDirectory = process.env.NODE_ENV === 'production' ? '/opt/data/logs' - : path.join(__dirname, `../../../../../data/logs`); + : path.join(__dirname, `../../../../../data/logs`) -const safeLogFileRegex = /maintainerr-\d{4}-\d{2}-\d{2}\.log(\.gz)?/; +const safeLogFileRegex = /maintainerr-\d{4}-\d{2}-\d{2}\.log(\.gz)?/ @Controller('/api/logs') export class LogsController implements BeforeApplicationShutdown { @@ -60,17 +60,17 @@ export class LogsController implements BeforeApplicationShutdown { private readonly eventEmitter: EventEmitter2, private readonly logger: MaintainerrLogger, ) { - this.logger.setContext(LogsController.name); + this.logger.setContext(LogsController.name) } connectedClients = new Map< string, { close: () => void; subject: Subject } - >(); + >() async beforeApplicationShutdown() { for (const [, client] of this.connectedClients) { - client.close(); + client.close() } } @@ -81,88 +81,88 @@ export class LogsController implements BeforeApplicationShutdown { @Req() request: RawBodyRequest, ) { if (request?.socket) { - request.socket.setKeepAlive(true); - request.socket.setNoDelay(true); - request.socket.setTimeout(0); + request.socket.setKeepAlive(true) + request.socket.setNoDelay(true) + request.socket.setTimeout(0) } - const subject = new Subject(); + const subject = new Subject() const observer = { next: (msg: NestMessageEvent) => { - if (msg.type) response.write(`event: ${msg.type}\n`); - if (msg.id) response.write(`id: ${msg.id}\n`); - if (msg.retry) response.write(`retry: ${msg.retry}\n`); + if (msg.type) response.write(`event: ${msg.type}\n`) + if (msg.id) response.write(`id: ${msg.id}\n`) + if (msg.retry) response.write(`retry: ${msg.retry}\n`) - response.write(`data: ${JSON.stringify(msg.data)}\n\n`); + response.write(`data: ${JSON.stringify(msg.data)}\n\n`) }, - }; + } - subject.subscribe(observer); + subject.subscribe(observer) - const clientKey = String(Math.random()); + const clientKey = String(Math.random()) this.connectedClients.set(clientKey, { close: () => { - response.end(); + response.end() }, subject, - }); + }) response.on('close', () => { - subject.complete(); - pingSubscription.unsubscribe(); - logEventStreamSubscription.unsubscribe(); - this.connectedClients.delete(clientKey); - response.end(); - }); + subject.complete() + pingSubscription.unsubscribe() + logEventStreamSubscription.unsubscribe() + this.connectedClients.delete(clientKey) + response.end() + }) response.set({ 'Cache-Control': 'private, no-cache, no-store, must-revalidate, max-age=0, no-transform', Connection: 'keep-alive', 'Content-Type': 'text/event-stream', - }); + }) - response.flushHeaders(); - response.write('\n'); + response.flushHeaders() + response.write('\n') const currentLogFile = new Promise( (resolve, reject) => { readdir(logsDirectory, (err, files) => { if (err) { - reject(err); - return; + reject(err) + return } else { const currentLogFile = files .filter((x) => x.endsWith('.log')) .sort() - .reverse()?.[0]; + .reverse()?.[0] if (!currentLogFile) { - resolve(undefined); - return; + resolve(undefined) + return } - const filePath = path.join(logsDirectory, currentLogFile); - resolve(filePath); + const filePath = path.join(logsDirectory, currentLogFile) + resolve(filePath) } - }); + }) }, - ); + ) const currentLogFileRecentLines = from(currentLogFile).pipe( switchMap((file) => file ? from(readLastLines.read(file, 200)) : of(''), ), catchError(() => of('')), - ); + ) const strToDate = (dtStr: string) => { - if (!dtStr) return null; + if (!dtStr) return null - const dateParts = dtStr.split('/'); - const timeParts = dateParts[2].split(' ')[1].split(':'); - dateParts[2] = dateParts[2].split(' ')[0]; + const dateParts = dtStr.split('/') + const timeParts = dateParts[2].split(' ')[1].split(':') + dateParts[2] = dateParts[2].split(' ')[0] return new Date( +dateParts[2], @@ -171,28 +171,28 @@ export class LogsController implements BeforeApplicationShutdown { +timeParts[0], +timeParts[1], +timeParts[2], - ); - }; + ) + } const parseLogLine = (line: string): LogEvent | null => { const regex = - /\[(?[^\]]+)\] \| (?[^\[]+) \[(?[^\]]+)\] \[(?