From 9385592362eeba1dba05c5aa8fc7a2de1d054d74 Mon Sep 17 00:00:00 2001 From: sct Date: Thu, 29 Oct 2020 01:53:45 +0000 Subject: [PATCH] feat(api): sonarr api wrapper / send to sonarr --- overseerr-api.yml | 14 +-- server/api/sonarr.ts | 226 ++++++++++++++++++++++++++++++++++ server/entity/MediaRequest.ts | 60 +++++++++ server/lib/settings.ts | 2 +- 4 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 server/api/sonarr.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index dbb389a5b..6fe35de6e 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -159,14 +159,14 @@ components: example: false baseUrl: type: string - activeProfile: - type: string - example: '1080p' + activeProfileId: + type: number + example: 1 activeDirectory: type: string - example: '/movies' - activeAnimeProfile: - type: string + example: '/tv/' + activeAnimeProfileId: + type: number nullable: true activeAnimeDirectory: type: string @@ -183,7 +183,7 @@ components: - port - apiKey - useSsl - - activeProfile + - activeProfileId - activeDirectory - is4k - enableSeasonFolders diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts new file mode 100644 index 000000000..72a2de6b4 --- /dev/null +++ b/server/api/sonarr.ts @@ -0,0 +1,226 @@ +import Axios, { AxiosInstance } from 'axios'; +import logger from '../logger'; + +interface SonarrSeason { + seasonNumber: number; + monitored: boolean; +} + +interface SonarrSeries { + title: string; + sortTitle: string; + seasonCount: number; + status: string; + overview: string; + network: string; + airTime: string; + images: { + coverType: string; + url: string; + }[]; + remotePoster: string; + seasons: SonarrSeason[]; + year: number; + path: string; + profileId: number; + languageProfileId: number; + seasonFolder: boolean; + monitored: boolean; + useSceneNumbering: boolean; + runtime: number; + tvdbId: number; + tvRageId: number; + tvMazeId: number; + firstAired: string; + lastInfoSync?: string; + seriesType: string; + cleanTitle: string; + imdbId: string; + titleSlug: string; + certification: string; + genres: string[]; + tags: string[]; + added: string; + ratings: { + votes: number; + value: number; + }; + qualityProfileId: number; + id?: number; + rootFolderPath?: string; + addOptions?: { + ignoreEpisodesWithFiles?: boolean; + ignoreEpisodesWithoutFiles?: boolean; + searchForMissingEpisodes?: boolean; + }; +} + +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; + profileId: number; + seasons: number[]; + rootFolderPath: string; + monitored?: boolean; + searchNow?: boolean; +} + +class SonarrAPI { + private axios: AxiosInstance; + constructor({ url, apiKey }: { url: string; apiKey: string }) { + this.axios = Axios.create({ + baseURL: url, + params: { + apikey: apiKey, + }, + }); + } + + public async getSeriesByTvdbId(id: number): Promise { + try { + const response = await this.axios.get('/series/lookup', { + params: { + term: `tvdb:${id}`, + }, + }); + + if (!response.data[0]) { + throw new Error('Series not found'); + } + + return response.data[0]; + } catch (e) { + logger.error('Error retrieving series by tvdb ID', { + label: 'Sonarr API', + message: e.message, + }); + throw new Error('Series not found'); + } + } + + public async addSeries(options: AddSeriesOptions): Promise { + try { + const series = await this.getSeriesByTvdbId(options.tvdbid); + + // If the series already exists, we will simply just update it + if (series.id) { + series.seasons = this.buildSeasonList(options.seasons, series.seasons); + + series.addOptions = { + ignoreEpisodesWithFiles: true, + searchForMissingEpisodes: true, + }; + + const newSeriesResponse = await this.axios.put( + '/series', + series + ); + + return newSeriesResponse.data; + } + + const createdSeriesResponse = await this.axios.post( + '/series', + { + tvdbId: options.tvdbid, + title: options.title, + profileId: options.profileId, + seasons: this.buildSeasonList( + options.seasons, + series.seasons.map((season) => ({ + seasonNumber: season.seasonNumber, + // We force all seasons to false if its the first request + monitored: false, + })) + ), + monitored: options.monitored, + rootFolderPath: options.rootFolderPath, + addOptions: { + ignoreEpisodesWithFiles: true, + searchForMissingEpisodes: options.searchNow, + }, + } as Partial + ); + + return createdSeriesResponse.data; + } catch (e) { + logger.error('Something went wrong adding a series to Sonarr', { + label: 'Sonarr API', + message: e.message, + error: e, + }); + throw new Error('Failed to add series'); + } + } + + public async getProfiles(): Promise { + try { + const response = await this.axios.get('/profile'); + + return response.data; + } catch (e) { + logger.error('Something went wrong retrieving Sonarr profiles', { + label: 'Sonarr API', + message: e.message, + }); + throw new Error('Failed to get profiles'); + } + } + + public async getRootFolders(): Promise { + try { + const response = await this.axios.get('/rootfolder'); + + return response.data; + } catch (e) { + logger.error('Something went wrong retrieving Sonarr root folders', { + label: 'Sonarr API', + message: e.message, + }); + throw new Error('Failed to get root folders'); + } + } + + private buildSeasonList( + seasons: number[], + existingSeasons?: SonarrSeason[] + ): SonarrSeason[] { + if (existingSeasons) { + const newSeasons = existingSeasons.map((season) => { + if (seasons.includes(season.seasonNumber)) { + season.monitored = true; + } + return season; + }); + + return newSeasons; + } + + const newSeasons = seasons.map( + (seasonNumber): SonarrSeason => ({ + seasonNumber, + monitored: true, + }) + ); + + return newSeasons; + } +} + +export default SonarrAPI; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 36932f09c..a90dbb66f 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -19,6 +19,7 @@ import TheMovieDb from '../api/themoviedb'; import RadarrAPI from '../api/radarr'; import logger from '../logger'; import SeasonRequest from './SeasonRequest'; +import SonarrAPI from '../api/sonarr'; @Entity() export class MediaRequest { @@ -168,4 +169,63 @@ export class MediaRequest { } } } + + @AfterUpdate() + @AfterInsert() + private async sendToSonarr() { + if ( + this.status === MediaRequestStatus.APPROVED && + this.type === MediaType.TV + ) { + try { + const mediaRepository = getRepository(Media); + const settings = getSettings(); + if (settings.sonarr.length === 0 && !settings.sonarr[0]) { + logger.info( + 'Skipped sonarr request as there is no sonarr configured', + { label: 'Media Request' } + ); + return; + } + + const media = await mediaRepository.findOne({ + where: { id: this.media.id }, + relations: ['requests'], + }); + + if (!media) { + throw new Error('Media data is missing'); + } + + const tmdb = new TheMovieDb(); + const sonarrSettings = settings.sonarr[0]; + const sonarr = new SonarrAPI({ + apiKey: sonarrSettings.apiKey, + url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${ + sonarrSettings.hostname + }:${sonarrSettings.port}/api`, + }); + const series = await tmdb.getTvShow({ tvId: media.tmdbId }); + + if (!series.external_ids.tvdb_id) { + throw new Error('Series was missing tvdb id'); + } + + await sonarr.addSeries({ + profileId: sonarrSettings.activeProfileId, + rootFolderPath: sonarrSettings.activeDirectory, + title: series.name, + tvdbid: series.external_ids.tvdb_id, + seasons: this.seasons.map((season) => season.seasonNumber), + monitored: true, + searchNow: true, + }); + logger.info('Sent request to Sonarr', { label: 'Media Request' }); + } catch (e) { + throw new Error( + `[MediaRequest] Request failed to send to sonarr: ${e.message}` + ); + } + } + } } diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 0daf56f68..50edd6cb1 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -34,7 +34,7 @@ export interface RadarrSettings extends DVRSettings { } export interface SonarrSettings extends DVRSettings { - activeAnimeProfile?: string; + activeAnimeProfileId?: number; activeAnimeDirectory?: string; enableSeasonFolders: boolean; }