From a306ebc2d18317d8dbe4ccd3f24c22f55ffcd6a6 Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 5 Apr 2021 21:16:55 +0900 Subject: [PATCH] feat: radarr/sonarr tag support (#1366) --- overseerr-api.yml | 9 + server/api/servarr/base.ts | 169 ++++++++++++++++++ server/api/{ => servarr}/radarr.ts | 107 +---------- server/api/{ => servarr}/sonarr.ts | 143 +-------------- server/entity/Media.ts | 36 ++-- server/entity/MediaRequest.ts | 92 ++++++++-- server/interfaces/api/serviceInterfaces.ts | 11 +- server/lib/downloadtracker.ts | 8 +- server/lib/scanners/radarr/index.ts | 4 +- server/lib/scanners/sonarr/index.ts | 4 +- server/lib/settings.ts | 4 +- ...624225464-CreateTagsFieldonMediaRequest.ts | 32 ++++ server/routes/request.ts | 5 + server/routes/service.ts | 19 +- server/routes/settings/radarr.ts | 89 ++++----- server/routes/settings/sonarr.ts | 6 +- .../RequestModal/AdvancedRequester/index.tsx | 96 +++++++--- .../RequestModal/MovieRequestModal.tsx | 3 + .../RequestModal/SearchByNameModal/index.tsx | 2 +- .../RequestModal/TvRequestModal.tsx | 3 + src/components/Settings/RadarrModal/index.tsx | 91 +++++++++- src/components/Settings/SonarrModal/index.tsx | 158 +++++++++++++++- src/i18n/locale/en.json | 19 ++ src/styles/globals.css | 16 ++ 24 files changed, 760 insertions(+), 366 deletions(-) create mode 100644 server/api/servarr/base.ts rename server/api/{ => servarr}/radarr.ts (68%) rename server/api/{ => servarr}/sonarr.ts (69%) create mode 100644 server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index 0fdfdcea..0775ad43 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -441,6 +441,15 @@ components: - is4k - enableSeasonFolders - isDefault + ServarrTag: + type: object + properties: + id: + type: number + example: 1 + label: + type: string + example: A Label PublicSettings: type: object properties: diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts new file mode 100644 index 00000000..75f138b5 --- /dev/null +++ b/server/api/servarr/base.ts @@ -0,0 +1,169 @@ +import cacheManager, { AvailableCacheIds } from '../../lib/cache'; +import { DVRSettings } from '../../lib/settings'; +import ExternalAPI from '../externalapi'; + +export interface RootFolder { + id: number; + path: string; + freeSpace: number; + totalSpace: number; + unmappedFolders: { + name: string; + path: string; + }[]; +} + +export interface QualityProfile { + id: number; + name: string; +} + +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; +} + +export interface Tag { + id: number; + label: string; +} + +interface QueueResponse { + page: number; + pageSize: number; + sortKey: string; + sortDirection: string; + totalRecords: number; + records: (QueueItem & QueueItemAppendT)[]; +} + +class ServarrBase extends ExternalAPI { + static buildUrl(settings: DVRSettings, path?: string): string { + return `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${ + settings.port + }${settings.baseUrl ?? ''}${path}`; + } + + protected apiName: string; + + constructor({ + url, + apiKey, + cacheName, + apiName, + }: { + url: string; + apiKey: string; + cacheName: AvailableCacheIds; + apiName: string; + }) { + super( + url, + { + apikey: apiKey, + }, + { + nodeCache: cacheManager.getCache(cacheName).data, + } + ); + + this.apiName = apiName; + } + + public getProfiles = async (): Promise => { + try { + const data = await this.getRolling( + `/qualityProfile`, + undefined, + 3600 + ); + + return data; + } catch (e) { + throw new Error( + `[${this.apiName}] Failed to retrieve profiles: ${e.message}` + ); + } + }; + + public getRootFolders = async (): Promise => { + try { + const data = await this.getRolling( + `/rootfolder`, + undefined, + 3600 + ); + + return data; + } catch (e) { + throw new Error( + `[${this.apiName}] Failed to retrieve root folders: ${e.message}` + ); + } + }; + + public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => { + try { + const response = await this.axios.get>( + `/queue` + ); + + return response.data.records; + } catch (e) { + throw new Error( + `[${this.apiName}] Failed to retrieve queue: ${e.message}` + ); + } + }; + + public getTags = async (): Promise => { + try { + const response = await this.axios.get(`/tag`); + + return response.data; + } catch (e) { + throw new Error( + `[${this.apiName}] 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; + } catch (e) { + throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`); + } + }; + + protected async runCommand( + commandName: string, + options: Record + ): Promise { + try { + await this.axios.post(`/command`, { + name: commandName, + ...options, + }); + } catch (e) { + throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`); + } + } +} + +export default ServarrBase; diff --git a/server/api/radarr.ts b/server/api/servarr/radarr.ts similarity index 68% rename from server/api/radarr.ts rename to server/api/servarr/radarr.ts index 187a52ba..59407720 100644 --- a/server/api/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -1,12 +1,11 @@ -import cacheManager from '../lib/cache'; -import { RadarrSettings } from '../lib/settings'; -import logger from '../logger'; -import ExternalAPI from './externalapi'; +import logger from '../../logger'; +import ServarrBase from './base'; interface RadarrMovieOptions { title: string; qualityProfileId: number; minimumAvailability: string; + tags: number[]; profileId: number; year: number; rootFolderPath: string; @@ -32,65 +31,9 @@ export interface RadarrMovie { hasFile: boolean; } -export interface RadarrRootFolder { - id: number; - path: string; - freeSpace: number; - totalSpace: number; - unmappedFolders: { - name: string; - path: string; - }[]; -} - -export interface RadarrProfile { - id: number; - name: string; -} - -interface QueueItem { - movieId: 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; -} - -interface QueueResponse { - page: number; - pageSize: number; - sortKey: string; - sortDirection: string; - totalRecords: number; - records: QueueItem[]; -} - -class RadarrAPI extends ExternalAPI { - static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string { - return `${radarrSettings.useSsl ? 'https' : 'http'}://${ - radarrSettings.hostname - }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`; - } - +class RadarrAPI extends ServarrBase<{ movieId: number }> { constructor({ url, apiKey }: { url: string; apiKey: string }) { - super( - url, - { - apikey: apiKey, - }, - { - nodeCache: cacheManager.getCache('radarr').data, - } - ); + super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr' }); } public getMovies = async (): Promise => { @@ -162,6 +105,7 @@ class RadarrAPI extends ExternalAPI { minimumAvailability: options.minimumAvailability, tmdbId: options.tmdbId, year: options.year, + tags: options.tags, rootFolderPath: options.rootFolderPath, monitored: options.monitored, addOptions: { @@ -206,6 +150,7 @@ class RadarrAPI extends ExternalAPI { year: options.year, rootFolderPath: options.rootFolderPath, monitored: options.monitored, + tags: options.tags, addOptions: { searchForMovie: options.searchNow, }, @@ -238,44 +183,6 @@ class RadarrAPI extends ExternalAPI { throw new Error('Failed to add movie to Radarr'); } }; - - public getProfiles = async (): Promise => { - try { - const data = await this.getRolling( - `/qualityProfile`, - undefined, - 3600 - ); - - return data; - } catch (e) { - throw new Error(`[Radarr] Failed to retrieve profiles: ${e.message}`); - } - }; - - public getRootFolders = async (): Promise => { - try { - const data = await this.getRolling( - `/rootfolder`, - undefined, - 3600 - ); - - return data; - } catch (e) { - throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`); - } - }; - - public getQueue = async (): Promise => { - try { - const response = await this.axios.get(`/queue`); - - return response.data.records; - } catch (e) { - throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`); - } - }; } export default RadarrAPI; diff --git a/server/api/sonarr.ts b/server/api/servarr/sonarr.ts similarity index 69% rename from server/api/sonarr.ts rename to server/api/servarr/sonarr.ts index e2e8bd19..12337839 100644 --- a/server/api/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -1,7 +1,5 @@ -import cacheManager from '../lib/cache'; -import { SonarrSettings } from '../lib/settings'; -import logger from '../logger'; -import ExternalAPI from './externalapi'; +import logger from '../../logger'; +import ServarrBase from './base'; interface SonarrSeason { seasonNumber: number; @@ -49,7 +47,7 @@ export interface SonarrSeries { titleSlug: string; certification: string; genres: string[]; - tags: string[]; + tags: number[]; added: string; ratings: { votes: number; @@ -65,49 +63,6 @@ export interface SonarrSeries { }; } -interface QueueItem { - seriesId: number; - episodeId: 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; -} - -interface QueueResponse { - page: number; - pageSize: number; - sortKey: string; - sortDirection: string; - totalRecords: number; - records: QueueItem[]; -} - -interface SonarrProfile { - id: number; - name: string; -} - -interface SonarrRootFolder { - id: number; - path: string; - freeSpace: number; - totalSpace: number; - unmappedFolders: { - name: string; - path: string; - }[]; -} - interface AddSeriesOptions { tvdbid: number; title: string; @@ -116,6 +71,7 @@ interface AddSeriesOptions { seasons: number[]; seasonFolder: boolean; rootFolderPath: string; + tags?: number[]; seriesType: SonarrSeries['seriesType']; monitored?: boolean; searchNow?: boolean; @@ -126,23 +82,9 @@ export interface LanguageProfile { name: string; } -class SonarrAPI extends ExternalAPI { - static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string { - return `${sonarrSettings.useSsl ? 'https' : 'http'}://${ - sonarrSettings.hostname - }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`; - } - +class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> { constructor({ url, apiKey }: { url: string; apiKey: string }) { - super( - url, - { - apikey: apiKey, - }, - { - nodeCache: cacheManager.getCache('sonarr').data, - } - ); + super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' }); } public async getSeries(): Promise { @@ -151,7 +93,7 @@ class SonarrAPI extends ExternalAPI { return response.data; } catch (e) { - throw new Error(`[Radarr] Failed to retrieve series: ${e.message}`); + throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`); } } @@ -205,6 +147,7 @@ class SonarrAPI extends ExternalAPI { // If the series already exists, we will simply just update it if (series.id) { + series.tags = options.tags ?? series.tags; series.seasons = this.buildSeasonList(options.seasons, series.seasons); const newSeriesResponse = await this.axios.put( @@ -249,6 +192,7 @@ class SonarrAPI extends ExternalAPI { monitored: false, })) ), + tags: options.tags, seasonFolder: options.seasonFolder, monitored: options.monitored, rootFolderPath: options.rootFolderPath, @@ -286,46 +230,6 @@ class SonarrAPI extends ExternalAPI { } } - public async getProfiles(): Promise { - try { - const data = await this.getRolling( - '/qualityProfile', - undefined, - 3600 - ); - - return data; - } catch (e) { - logger.error('Something went wrong while retrieving Sonarr profiles.', { - label: 'Sonarr API', - message: e.message, - }); - throw new Error('Failed to get profiles'); - } - } - - public async getRootFolders(): Promise { - try { - const data = await this.getRolling( - '/rootfolder', - undefined, - 3600 - ); - - return data; - } catch (e) { - logger.error( - 'Something went wrong while retrieving Sonarr root folders.', - { - label: 'Sonarr API', - message: e.message, - } - ); - - throw new Error('Failed to get root folders'); - } - } - public async getLanguageProfiles(): Promise { try { const data = await this.getRolling( @@ -356,25 +260,6 @@ class SonarrAPI extends ExternalAPI { await this.runCommand('SeriesSearch', { seriesId }); } - private async runCommand( - commandName: string, - options: Record - ): Promise { - try { - await this.axios.post(`/command`, { - name: commandName, - ...options, - }); - } catch (e) { - logger.error('Something went wrong attempting to run a Sonarr command.', { - label: 'Sonarr API', - message: e.message, - }); - - throw new Error('Failed to run Sonarr command.'); - } - } - private buildSeasonList( seasons: number[], existingSeasons?: SonarrSeason[] @@ -399,16 +284,6 @@ class SonarrAPI extends ExternalAPI { return newSeasons; } - - public getQueue = async (): Promise => { - try { - const response = await this.axios.get(`/queue`); - - return response.data.records; - } catch (e) { - throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`); - } - }; } export default SonarrAPI; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 9ca195c6..3d821651 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -1,23 +1,23 @@ import { - Entity, - PrimaryGeneratedColumn, + AfterLoad, Column, - Index, - OneToMany, CreateDateColumn, - UpdateDateColumn, + Entity, getRepository, In, - AfterLoad, + Index, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm'; -import { MediaRequest } from './MediaRequest'; +import RadarrAPI from '../api/servarr/radarr'; +import SonarrAPI from '../api/servarr/sonarr'; import { MediaStatus, MediaType } from '../constants/media'; +import downloadTracker, { DownloadingItem } from '../lib/downloadtracker'; +import { getSettings } from '../lib/settings'; import logger from '../logger'; +import { MediaRequest } from './MediaRequest'; import Season from './Season'; -import { getSettings } from '../lib/settings'; -import RadarrAPI from '../api/radarr'; -import downloadTracker, { DownloadingItem } from '../lib/downloadtracker'; -import SonarrAPI from '../api/sonarr'; @Entity() class Media { @@ -168,10 +168,7 @@ class Media { if (server) { this.serviceUrl = server.externalUrl ? `${server.externalUrl}/movie/${this.externalServiceSlug}` - : RadarrAPI.buildRadarrUrl( - server, - `/movie/${this.externalServiceSlug}` - ); + : RadarrAPI.buildUrl(server, `/movie/${this.externalServiceSlug}`); } } @@ -184,7 +181,7 @@ class Media { if (server) { this.serviceUrl4k = server.externalUrl ? `${server.externalUrl}/movie/${this.externalServiceSlug4k}` - : RadarrAPI.buildRadarrUrl( + : RadarrAPI.buildUrl( server, `/movie/${this.externalServiceSlug4k}` ); @@ -202,10 +199,7 @@ class Media { if (server) { this.serviceUrl = server.externalUrl ? `${server.externalUrl}/series/${this.externalServiceSlug}` - : SonarrAPI.buildSonarrUrl( - server, - `/series/${this.externalServiceSlug}` - ); + : SonarrAPI.buildUrl(server, `/series/${this.externalServiceSlug}`); } } @@ -218,7 +212,7 @@ class Media { if (server) { this.serviceUrl4k = server.externalUrl ? `${server.externalUrl}/series/${this.externalServiceSlug4k}` - : SonarrAPI.buildSonarrUrl( + : SonarrAPI.buildUrl( server, `/series/${this.externalServiceSlug4k}` ); diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 6e5c1135..6f719bb1 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,28 +1,29 @@ +import { isEqual } from 'lodash'; import { - Entity, - PrimaryGeneratedColumn, - ManyToOne, + AfterInsert, + AfterRemove, + AfterUpdate, Column, CreateDateColumn, - UpdateDateColumn, - AfterUpdate, - AfterInsert, + Entity, getRepository, + ManyToOne, OneToMany, - AfterRemove, + PrimaryGeneratedColumn, RelationCount, + UpdateDateColumn, } from 'typeorm'; -import { User } from './User'; -import Media from './Media'; -import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; -import { getSettings } from '../lib/settings'; +import RadarrAPI from '../api/servarr/radarr'; +import SonarrAPI, { SonarrSeries } from '../api/servarr/sonarr'; import TheMovieDb from '../api/themoviedb'; import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants'; -import RadarrAPI from '../api/radarr'; +import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media'; +import notificationManager, { Notification } from '../lib/notifications'; +import { getSettings } from '../lib/settings'; import logger from '../logger'; +import Media from './Media'; import SeasonRequest from './SeasonRequest'; -import SonarrAPI, { SonarrSeries } from '../api/sonarr'; -import notificationManager, { Notification } from '../lib/notifications'; +import { User } from './User'; @Entity() export class MediaRequest { @@ -85,6 +86,37 @@ export class MediaRequest { @Column({ nullable: true }) public languageProfileId: number; + @Column({ + type: 'text', + nullable: true, + transformer: { + from: (value: string | null): number[] | null => { + if (value) { + if (value === 'none') { + return []; + } + return value.split(',').map((v) => Number(v)); + } + return null; + }, + to: (value: number[] | null): string | null => { + if (value) { + const finalValue = value.join(','); + + // We want to keep the actual state of an "empty array" so we use + // the keyword "none" to track this. + if (!finalValue) { + return 'none'; + } + + return finalValue; + } + return null; + }, + }, + }) + public tags?: number[]; + constructor(init?: Partial) { Object.assign(this, init); } @@ -365,6 +397,7 @@ export class MediaRequest { let rootFolder = radarrSettings.activeDirectory; let qualityProfile = radarrSettings.activeProfileId; + let tags = radarrSettings.tags; if ( this.rootFolder && @@ -387,10 +420,22 @@ export class MediaRequest { }); } + if ( + this.tags && + (radarrSettings.tags.length !== (this.tags?.length ?? 0) || + radarrSettings.tags.every((num) => (this.tags ?? []).includes(num))) + ) { + tags = this.tags; + logger.info(`Request has override tags`, { + label: 'Media Request', + tagIds: tags, + }); + } + const tmdb = new TheMovieDb(); const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, - url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'), + url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), }); const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); @@ -420,6 +465,7 @@ export class MediaRequest { tmdbId: movie.id, year: Number(movie.release_date.slice(0, 4)), monitored: true, + tags, searchNow: !radarrSettings.preventSearch, }) .then(async (radarrMovie) => { @@ -531,7 +577,7 @@ export class MediaRequest { const tmdb = new TheMovieDb(); const sonarr = new SonarrAPI({ apiKey: sonarrSettings.apiKey, - url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'), + url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), }); const series = await tmdb.getTvShow({ tvId: media.tmdbId }); const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; @@ -568,6 +614,11 @@ export class MediaRequest { ? sonarrSettings.activeAnimeLanguageProfileId : sonarrSettings.activeLanguageProfileId; + let tags = + seriesType === 'anime' + ? sonarrSettings.animeTags + : sonarrSettings.tags; + if ( this.rootFolder && this.rootFolder !== '' && @@ -599,6 +650,14 @@ export class MediaRequest { ); } + if (this.tags && !isEqual(this.tags, tags)) { + tags = this.tags; + logger.info(`Request has override tags`, { + label: 'Media Request', + tags, + }); + } + // Run this asynchronously so we don't wait for it on the UI side sonarr .addSeries({ @@ -610,6 +669,7 @@ export class MediaRequest { seasons: this.seasons.map((season) => season.seasonNumber), seasonFolder: sonarrSettings.enableSeasonFolders, seriesType, + tags, monitored: true, searchNow: !sonarrSettings.preventSearch, }) diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts index 3bfa289e..1188f24c 100644 --- a/server/interfaces/api/serviceInterfaces.ts +++ b/server/interfaces/api/serviceInterfaces.ts @@ -1,5 +1,5 @@ -import { RadarrProfile, RadarrRootFolder } from '../../api/radarr'; -import { LanguageProfile } from '../../api/sonarr'; +import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base'; +import { LanguageProfile } from '../../api/servarr/sonarr'; export interface ServiceCommonServer { id: number; @@ -12,11 +12,14 @@ export interface ServiceCommonServer { activeAnimeProfileId?: number; activeAnimeDirectory?: string; activeAnimeLanguageProfileId?: number; + activeTags: number[]; + activeAnimeTags?: number[]; } export interface ServiceCommonServerWithDetails { server: ServiceCommonServer; - profiles: RadarrProfile[]; - rootFolders: Partial[]; + profiles: QualityProfile[]; + rootFolders: Partial[]; languageProfiles?: LanguageProfile[]; + tags: Tag[]; } diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts index 9faf411a..33282285 100644 --- a/server/lib/downloadtracker.ts +++ b/server/lib/downloadtracker.ts @@ -1,6 +1,6 @@ import { uniqWith } from 'lodash'; -import RadarrAPI from '../api/radarr'; -import SonarrAPI from '../api/sonarr'; +import RadarrAPI from '../api/servarr/radarr'; +import SonarrAPI from '../api/servarr/sonarr'; import { MediaType } from '../constants/media'; import logger from '../logger'; import { getSettings } from './settings'; @@ -73,7 +73,7 @@ class DownloadTracker { if (server.syncEnabled) { const radarr = new RadarrAPI({ apiKey: server.apiKey, - url: RadarrAPI.buildRadarrUrl(server, '/api/v3'), + url: RadarrAPI.buildUrl(server, '/api/v3'), }); const queueItems = await radarr.getQueue(); @@ -140,7 +140,7 @@ class DownloadTracker { if (server.syncEnabled) { const radarr = new SonarrAPI({ apiKey: server.apiKey, - url: SonarrAPI.buildSonarrUrl(server, '/api/v3'), + url: SonarrAPI.buildUrl(server, '/api/v3'), }); const queueItems = await radarr.getQueue(); diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts index 74682cc5..f3573209 100644 --- a/server/lib/scanners/radarr/index.ts +++ b/server/lib/scanners/radarr/index.ts @@ -1,5 +1,5 @@ import { uniqWith } from 'lodash'; -import RadarrAPI, { RadarrMovie } from '../../../api/radarr'; +import RadarrAPI, { RadarrMovie } from '../../../api/servarr/radarr'; import { getSettings, RadarrSettings } from '../../settings'; import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner'; @@ -52,7 +52,7 @@ class RadarrScanner this.radarrApi = new RadarrAPI({ apiKey: server.apiKey, - url: RadarrAPI.buildRadarrUrl(server, '/api/v3'), + url: RadarrAPI.buildUrl(server, '/api/v3'), }); this.items = await this.radarrApi.getMovies(); diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts index 4bc505fb..73500db9 100644 --- a/server/lib/scanners/sonarr/index.ts +++ b/server/lib/scanners/sonarr/index.ts @@ -1,6 +1,6 @@ import { uniqWith } from 'lodash'; import { getRepository } from 'typeorm'; -import SonarrAPI, { SonarrSeries } from '../../../api/sonarr'; +import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr'; import Media from '../../../entity/Media'; import { getSettings, SonarrSettings } from '../../settings'; import BaseScanner, { @@ -58,7 +58,7 @@ class SonarrScanner this.sonarrApi = new SonarrAPI({ apiKey: server.apiKey, - url: SonarrAPI.buildSonarrUrl(server, '/api/v3'), + url: SonarrAPI.buildUrl(server, '/api/v3'), }); this.items = await this.sonarrApi.getSeries(); diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 5809600f..6e1009c9 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -30,7 +30,7 @@ export interface PlexSettings { libraries: Library[]; } -interface DVRSettings { +export interface DVRSettings { id: number; name: string; hostname: string; @@ -41,6 +41,7 @@ interface DVRSettings { activeProfileId: number; activeProfileName: string; activeDirectory: string; + tags: number[]; is4k: boolean; isDefault: boolean; externalUrl?: string; @@ -58,6 +59,7 @@ export interface SonarrSettings extends DVRSettings { activeAnimeDirectory?: string; activeAnimeLanguageProfileId?: number; activeLanguageProfileId?: number; + animeTags?: number[]; enableSeasonFolders: boolean; } diff --git a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts new file mode 100644 index 00000000..c8bd6dd4 --- /dev/null +++ b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateTagsFieldonMediaRequest1617624225464 + implements MigrationInterface { + name = 'CreateTagsFieldonMediaRequest1617624225464'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId" FROM "media_request"` + ); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + } +} diff --git a/server/routes/request.ts b/server/routes/request.ts index b7598f4e..6ad4ac05 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -278,6 +278,7 @@ requestRoutes.post( serverId: req.body.serverId, profileId: req.body.profileId, rootFolder: req.body.rootFolder, + tags: req.body.tags, }); await requestRepository.save(request); @@ -356,6 +357,7 @@ requestRoutes.post( profileId: req.body.profileId, rootFolder: req.body.rootFolder, languageProfileId: req.body.languageProfileId, + tags: req.body.tags, seasons: finalSeasons.map( (sn) => new SeasonRequest({ @@ -497,6 +499,7 @@ requestRoutes.put<{ requestId: string }>( request.serverId = req.body.serverId; request.profileId = req.body.profileId; request.rootFolder = req.body.rootFolder; + request.tags = req.body.tags; request.requestedBy = requestUser as User; requestRepository.save(request); @@ -505,6 +508,8 @@ requestRoutes.put<{ requestId: string }>( request.serverId = req.body.serverId; request.profileId = req.body.profileId; request.rootFolder = req.body.rootFolder; + request.languageProfileId = req.body.languageProfileId; + request.tags = req.body.tags; request.requestedBy = requestUser as User; const requestedSeasons = req.body.seasons as number[] | undefined; diff --git a/server/routes/service.ts b/server/routes/service.ts index 5e6dccc8..51bbc4e3 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -1,12 +1,12 @@ import { Router } from 'express'; -import RadarrAPI from '../api/radarr'; -import SonarrAPI from '../api/sonarr'; +import RadarrAPI from '../api/servarr/radarr'; +import SonarrAPI from '../api/servarr/sonarr'; +import TheMovieDb from '../api/themoviedb'; import { ServiceCommonServer, ServiceCommonServerWithDetails, } from '../interfaces/api/serviceInterfaces'; import { getSettings } from '../lib/settings'; -import TheMovieDb from '../api/themoviedb'; import logger from '../logger'; const serviceRoutes = Router(); @@ -22,6 +22,7 @@ serviceRoutes.get('/radarr', async (req, res) => { isDefault: radarr.isDefault, activeDirectory: radarr.activeDirectory, activeProfileId: radarr.activeProfileId, + activeTags: radarr.tags ?? [], }) ); @@ -46,11 +47,12 @@ serviceRoutes.get<{ radarrId: string }>( const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, - url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'), + url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), }); const profiles = await radarr.getProfiles(); const rootFolders = await radarr.getRootFolders(); + const tags = await radarr.getTags(); return res.status(200).json({ server: { @@ -60,6 +62,7 @@ serviceRoutes.get<{ radarrId: string }>( isDefault: radarrSettings.isDefault, activeDirectory: radarrSettings.activeDirectory, activeProfileId: radarrSettings.activeProfileId, + activeTags: radarrSettings.tags, }, profiles: profiles.map((profile) => ({ id: profile.id, @@ -71,6 +74,7 @@ serviceRoutes.get<{ radarrId: string }>( path: folder.path, totalSpace: folder.totalSpace, })), + tags, } as ServiceCommonServerWithDetails); } ); @@ -90,6 +94,7 @@ serviceRoutes.get('/sonarr', async (req, res) => { activeAnimeDirectory: sonarr.activeAnimeDirectory, activeLanguageProfileId: sonarr.activeLanguageProfileId, activeAnimeLanguageProfileId: sonarr.activeAnimeLanguageProfileId, + activeTags: [], }) ); @@ -114,13 +119,14 @@ serviceRoutes.get<{ sonarrId: string }>( const sonarr = new SonarrAPI({ apiKey: sonarrSettings.apiKey, - url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'), + url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), }); try { const profiles = await sonarr.getProfiles(); const rootFolders = await sonarr.getRootFolders(); const languageProfiles = await sonarr.getLanguageProfiles(); + const tags = await sonarr.getTags(); return res.status(200).json({ server: { @@ -135,6 +141,8 @@ serviceRoutes.get<{ sonarrId: string }>( activeLanguageProfileId: sonarrSettings.activeLanguageProfileId, activeAnimeLanguageProfileId: sonarrSettings.activeAnimeLanguageProfileId, + activeTags: sonarrSettings.tags, + activeAnimeTags: sonarrSettings.animeTags, }, profiles: profiles.map((profile) => ({ id: profile.id, @@ -147,6 +155,7 @@ serviceRoutes.get<{ sonarrId: string }>( totalSpace: folder.totalSpace, })), languageProfiles: languageProfiles, + tags, } as ServiceCommonServerWithDetails); } catch (e) { next({ status: 500, message: e.message }); diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts index 1e17a475..d250ea29 100644 --- a/server/routes/settings/radarr.ts +++ b/server/routes/settings/radarr.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import RadarrAPI from '../../api/radarr'; +import RadarrAPI from '../../api/servarr/radarr'; import { getSettings, RadarrSettings } from '../../lib/settings'; import logger from '../../logger'; @@ -35,15 +35,20 @@ radarrRoutes.post('/', (req, res) => { return res.status(201).json(newRadarr); }); -radarrRoutes.post('/test', async (req, res, next) => { +radarrRoutes.post< + undefined, + Record, + RadarrSettings & { tagLabel?: string } +>('/test', async (req, res, next) => { try { const radarr = new RadarrAPI({ apiKey: req.body.apiKey, - url: RadarrAPI.buildRadarrUrl(req.body, '/api/v3'), + url: RadarrAPI.buildUrl(req.body, '/api/v3'), }); const profiles = await radarr.getProfiles(); const folders = await radarr.getRootFolders(); + const tags = await radarr.getTags(); return res.status(200).json({ profiles, @@ -51,6 +56,7 @@ radarrRoutes.post('/test', async (req, res, next) => { id: folder.id, path: folder.path, })), + tags, }); } catch (e) { logger.error('Failed to test Radarr', { @@ -62,40 +68,41 @@ radarrRoutes.post('/test', async (req, res, next) => { } }); -radarrRoutes.put<{ id: string }>('/:id', (req, res) => { - const settings = getSettings(); - - const radarrIndex = settings.radarr.findIndex( - (r) => r.id === Number(req.params.id) - ); - - if (radarrIndex === -1) { - return res - .status(404) - .json({ status: '404', message: 'Settings instance not found' }); - } - - // If we are setting this as the default, clear any previous defaults for the same type first - // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true - // and are the default - if (req.body.isDefault) { - settings.radarr - .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k) - .forEach((radarrInstance) => { - radarrInstance.isDefault = false; - }); +radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>( + '/:id', + (req, res, next) => { + const settings = getSettings(); + + const radarrIndex = settings.radarr.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (radarrIndex === -1) { + return next({ status: '404', message: 'Settings instance not found' }); + } + + // If we are setting this as the default, clear any previous defaults for the same type first + // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true + // and are the default + if (req.body.isDefault) { + settings.radarr + .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k) + .forEach((radarrInstance) => { + radarrInstance.isDefault = false; + }); + } + + settings.radarr[radarrIndex] = { + ...req.body, + id: Number(req.params.id), + } as RadarrSettings; + settings.save(); + + return res.status(200).json(settings.radarr[radarrIndex]); } +); - settings.radarr[radarrIndex] = { - ...req.body, - id: Number(req.params.id), - } as RadarrSettings; - settings.save(); - - return res.status(200).json(settings.radarr[radarrIndex]); -}); - -radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => { +radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => { const settings = getSettings(); const radarrSettings = settings.radarr.find( @@ -103,14 +110,12 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => { ); if (!radarrSettings) { - return res - .status(404) - .json({ status: '404', message: 'Settings instance not found' }); + return next({ status: '404', message: 'Settings instance not found' }); } const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, - url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'), + url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), }); const profiles = await radarr.getProfiles(); @@ -123,7 +128,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => { ); }); -radarrRoutes.delete<{ id: string }>('/:id', (req, res) => { +radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => { const settings = getSettings(); const radarrIndex = settings.radarr.findIndex( @@ -131,9 +136,7 @@ radarrRoutes.delete<{ id: string }>('/:id', (req, res) => { ); if (radarrIndex === -1) { - return res - .status(404) - .json({ status: '404', message: 'Settings instance not found' }); + return next({ status: '404', message: 'Settings instance not found' }); } const removed = settings.radarr.splice(radarrIndex, 1); diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts index d9bbe3c2..4f63ebb3 100644 --- a/server/routes/settings/sonarr.ts +++ b/server/routes/settings/sonarr.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import SonarrAPI from '../../api/sonarr'; +import SonarrAPI from '../../api/servarr/sonarr'; import { getSettings, SonarrSettings } from '../../lib/settings'; import logger from '../../logger'; @@ -39,12 +39,13 @@ sonarrRoutes.post('/test', async (req, res, next) => { try { const sonarr = new SonarrAPI({ apiKey: req.body.apiKey, - url: SonarrAPI.buildSonarrUrl(req.body, '/api/v3'), + url: SonarrAPI.buildUrl(req.body, '/api/v3'), }); const profiles = await sonarr.getProfiles(); const folders = await sonarr.getRootFolders(); const languageProfiles = await sonarr.getLanguageProfiles(); + const tags = await sonarr.getTags(); return res.status(200).json({ profiles, @@ -53,6 +54,7 @@ sonarrRoutes.post('/test', async (req, res, next) => { path: folder.path, })), languageProfiles, + tags, }); } catch (e) { logger.error('Failed to test Sonarr', { diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index c9f54f7b..e9f5a508 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -1,7 +1,10 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { Listbox, Transition } from '@headlessui/react'; +import { isEqual } from 'lodash'; +import dynamic from 'next/dynamic'; import React, { useEffect, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import type { OptionsType, OptionTypeBase } from 'react-select'; import useSWR from 'swr'; import type { ServiceCommonServer, @@ -13,6 +16,13 @@ import globalMessages from '../../../i18n/globalMessages'; import { formatBytes } from '../../../utils/numberHelpers'; import { SmallLoadingSpinner } from '../../Common/LoadingSpinner'; +type OptionType = { + value: string; + label: string; +}; + +const Select = dynamic(() => import('react-select'), { ssr: false }); + const messages = defineMessages({ advancedoptions: 'Advanced Options', destinationserver: 'Destination Server', @@ -23,12 +33,16 @@ const messages = defineMessages({ folder: '{path} ({space})', requestas: 'Request As', languageprofile: 'Language Profile', + tags: 'Tags', + selecttags: 'Select tags', + notagoptions: 'No Tags', }); export type RequestOverrides = { server?: number; profile?: number; folder?: string; + tags?: number[]; language?: number; user?: User; }; @@ -77,6 +91,10 @@ const AdvancedRequester: React.FC = ({ defaultOverrides?.language ?? -1 ); + const [selectedTags, setSelectedTags] = useState( + defaultOverrides?.tags ?? [] + ); + const { data: serverData, isValidating, @@ -150,6 +168,9 @@ const AdvancedRequester: React.FC = ({ ? serverData.server.activeAnimeLanguageProfileId : serverData.server.activeLanguageProfileId) ); + const defaultTags = isAnime + ? serverData.server.activeAnimeTags + : serverData.server.activeTags; if ( defaultProfile && @@ -174,46 +195,43 @@ const AdvancedRequester: React.FC = ({ ) { setSelectedLanguage(defaultLanguage.id); } + + if ( + defaultTags && + !isEqual(defaultTags, selectedTags) && + (!defaultOverrides || defaultOverrides.tags === null) + ) { + setSelectedTags(defaultTags); + } } }, [serverData]); useEffect(() => { - if ( - defaultOverrides && - defaultOverrides.server !== null && - defaultOverrides.server !== undefined - ) { + if (defaultOverrides && defaultOverrides.server != null) { setSelectedServer(defaultOverrides.server); } - if ( - defaultOverrides && - defaultOverrides.profile !== null && - defaultOverrides.profile !== undefined - ) { + if (defaultOverrides && defaultOverrides.profile != null) { setSelectedProfile(defaultOverrides.profile); } - if ( - defaultOverrides && - defaultOverrides.folder !== null && - defaultOverrides.folder !== undefined - ) { + if (defaultOverrides && defaultOverrides.folder != null) { setSelectedFolder(defaultOverrides.folder); } - if ( - defaultOverrides && - defaultOverrides.language !== null && - defaultOverrides.language !== undefined - ) { + if (defaultOverrides && defaultOverrides.language != null) { setSelectedLanguage(defaultOverrides.language); } + + if (defaultOverrides && defaultOverrides.tags != null) { + setSelectedTags(defaultOverrides.tags); + } }, [ defaultOverrides?.server, defaultOverrides?.folder, defaultOverrides?.profile, defaultOverrides?.language, + defaultOverrides?.tags, ]); useEffect(() => { @@ -224,6 +242,7 @@ const AdvancedRequester: React.FC = ({ server: selectedServer ?? undefined, user: selectedUser ?? undefined, language: selectedLanguage ?? undefined, + tags: selectedTags, }); } }, [ @@ -232,6 +251,7 @@ const AdvancedRequester: React.FC = ({ selectedProfile, selectedUser, selectedLanguage, + selectedTags, ]); if (!data && !error) { @@ -436,9 +456,43 @@ const AdvancedRequester: React.FC = ({ )} + {!!data && selectedServer !== null && ( +
+ + ({ + label: tag.label, + value: tag.id, + })) + : [] + } + isMulti + isDisabled={!isValidated} + placeholder={ + !isValidated + ? intl.formatMessage(messages.testFirstTags) + : intl.formatMessage(messages.selecttags) + } + className="react-select-container" + classNamePrefix="react-select" + value={values.tags.map((tagId) => { + const foundTag = testResponse.tags.find( + (tag) => tag.id === tagId + ); + return { + value: foundTag?.id, + label: foundTag?.label, + }; + })} + onChange={( + value: OptionTypeBase | OptionsType | null + ) => { + if (!Array.isArray(value)) { + return; + } + setFieldValue( + 'tags', + value?.map((option) => option.value) + ); + }} + noOptionsMessage={() => + intl.formatMessage(messages.notagoptions) + } + /> +
+